DEV Community

Scheduling weekly SNS notifications with Amazon EventBridge

A manual weekly SNS campaign was replaced by a scheduled EventBridge trigger in two CloudFormation templates: an Events rule in UTC and a Scheduler in local time.

Every Friday around 11 PM, someone on the marketing team of a retail client opened the Amazon SNS console, pasted the Saturday campaign copy into the message field and clicked publish. The topic carried a few million confirmed subscriptions, in a mix of email, SMS, mobile push and one SQS queue feeding an internal consumer. The procedure lived in a shared document, with the topic ARN in bold and an all-caps warning to confirm the region before publishing.

What brought me in was not a push for automation. It was an incident. On two consecutive Saturdays the campaign went out twice. The first time, the person published, the page stopped responding, and she published again. The second time, two people each assumed the other was on vacation. Between those two episodes there was a Saturday when the campaign did not go out at all, and nobody noticed until Monday, when the conversion report came back at zero.

What I found when I opened the account: no alarms, and no record of who published what beyond raw CloudTrail. The delivery mechanism worked fine. The operational knowledge sat with two people, and the task runs 52 times a year, always at the same hour, finishing in under a second.

What had to change, and what could not

I needed a reliable weekly trigger, with no server to maintain, that leaves a trace of every firing. The delivery mechanism was not up for discussion: the SNS topic, the confirmed subscriptions and the endpoints stay exactly as they are. Any design that would require re-enrolling millions of subscribers was out before we started.

Requirements for the weekly trigger

  • The firing happens with no human in the loop, on Saturday, at a fixed local time in Sao Paulo. The hour itself moved: the manual ritual published late on Friday night so the message would be waiting when subscribers woke up, and the marketing team agreed to 09:00 on Saturday instead, which is a better hour for the campaign and a far easier one to reason about in a cron expression.
  • No new compute resource to maintain, patch or monitor.
  • The existing SNS topic stays as the delivery point, with its subscriptions untouched.
  • The topic supports subscriptions in the order of millions, and the scheduling layer cannot be the bottleneck.
  • A failed firing produces recoverable evidence, with payload and error code, instead of disappearing.
  • No principal outside the account can publish to the campaign topic.
  • The scheduling layer carries no fixed hourly cost, so the time it sits idle between firings is not billed.
  • Every firing is observable through a metric and an alarm, without depending on someone watching.

Four designs for the same weekly trigger

Cron on a dedicated EC2 instance

This is the design anyone with an infrastructure background reaches for first, and it does work. The team already knows how to write a script and how to edit a crontab. The script can do anything before publishing, including querying a database inside the VPC to assemble the message body. There is no learning curve, and there is full control over the payload.

What breaks is the host. A single instance sits on the critical path of a weekly campaign. If that instance is stopped, degraded, halfway through a reboot, or replaced by an Auto Scaling event during the scheduled minute, the cron entry does not fire. Linux cron keeps no memory of a missed run: if the instance came up at 9:05, the 9:00 job does not run, and nothing reports the miss. That is a Reliability failure, and it is the decisive one here, because it is the exact mechanism behind the Saturday that the client discovered on Monday. It comes paired with an Operational Excellence failure: someone has to patch the host, own a script nobody reviews, and the audit trail of who fired what is whatever the script happens to log. There is a Security failure hanging off the same host whenever the credential arrives as a long-lived access key instead of an instance profile, which is how I have most often found these instances configured, and I count it as a qualified risk rather than a defect of the design, because nothing forces the credential to arrive that way.

The obvious mitigation trades one problem for another. Putting the instance in an Auto Scaling group with capacity 1 fixes availability and introduces duplicate firing during a replacement, when two instances can briefly carry the same crontab. Duplicate firing is the complaint that opened this work.

Cost and sustainability come after, and they are consequence rather than cause. The instance stays up 168 hours a week to run a command that takes less than a second, once a week. The utilization rate is in the order of 0.0001 percent, which is a Cost Optimization failure in the form of a resource billed by time for work measured in milliseconds, and a Sustainability failure in the form of allocated hardware sitting almost entirely idle. Neither of those is the reason to reject the design. They are what the design costs after it already failed on reliability.

An instance starts to earn its place when campaign preparation needs state, a heavy library, or access to something that exists only inside the VPC. That was not the case here. The message body was text.

A fanout subscription on the existing topic

Fanout, in SNS, is the topology of one topic with multiple subscribers receiving the same message in parallel: SQS queues, Lambda functions, HTTP and HTTPS endpoints, email, SMS, mobile push. It is a correct and useful pattern, and it is exactly what this client already ran in production. That is what makes the proposal hard to dismiss on sight: the vocabulary is right.

Fanout is a distribution topology, not a time trigger. An SNS topic is reactive by definition: it distributes what was published to it, at the moment it was published. No topic attribute, subscription attribute or delivery policy introduces time. The question of who publishes on Saturday remains unanswered, so sending stays manual and the original problem does not move. That is an architecture failure in its most direct form, a component chosen that does not address the requirement, and in the vocabulary of the pillars it lands on Operational Excellence, because the manual step stays on the critical path.

One pillar, tied to one mechanism, is the whole analysis here. Reaching for more would be padding.

A Step Functions state machine on a schedule

Before any pillar discussion, the premise has to come apart: AWS Step Functions has no scheduler of its own. The official documentation on starting a state machine execution lists the ways it can happen, and none of them is a scheduler internal to the service: the Step Functions console, a StartExecution API call, Amazon EventBridge reacting to an event, Amazon EventBridge Scheduler for a time-based start, a nested execution from a Task state, and Amazon API Gateway. The service does have wait states, which delay an execution that already started, and that is not scheduling. Step Functions even publishes a page of its own on using EventBridge Scheduler to start a state machine execution at a given time. A pillar does not apply to a capability that does not exist, so this is a factual error, not a design trade-off.

Once the premise is fixed, the design behind the proposal becomes assessable: EventBridge or EventBridge Scheduler triggering a state machine that publishes to SNS. There, pillars apply. A single-step state machine adds a workflow definition, an execution role, an execution history and per-state-transition billing to the path, to do what EventBridge already does by publishing straight to the topic. That is a Cost Optimization failure, since it inserts a component billed per state transition where the native integration bills nothing for the hop, and an Operational Excellence failure, since it adds one more versioned artifact, one more role and one more console to operate without adding a single business step.

The threshold where that changes is worth naming. Step Functions pays for itself when there is real orchestration: branching per audience segment, waiting on human approval, per-step error handling, parallel calls, retry policies that differ by step. Publishing one message is not orchestration.

An EventBridge rule publishing straight to the topic

EventBridge accepts an SNS topic as the target of a rule and performs the publish itself, authorized by the topic resource policy. There is no intermediate function, no code, and no runtime to version. It wins on Operational Excellence, because schedule and authorization are declared in CloudFormation, versioned in Git, and the change history becomes the repository history, with native metrics in the AWS/Events namespace instead of whatever a script chose to log. It wins on Cost Optimization, because no compute resource participates in the scheduling at all, which leaves SNS delivery as the dominant cost. And it wins on Reliability against the other three designs, because there is no host on the path, with a retry policy and a dead letter queue configurable on the target itself.

It is not perfect, and that belongs in the post as much as the win does.

  • Delivery is at-least-once. For a marketing campaign that means a risk of duplicate email, which is the original complaint. The design lowers the probability without eliminating it.
  • The cron expression of an AWS::Events::Rule is interpreted in UTC only, with no time zone property. That is where the wrong-hour mistakes come from, and it is the reason a second template exists further down.
  • The payload arrives in a standard event format. An input transformer fixes that, and the objection is legitimate enough to deserve the mention.
  • There is no segmentation, no per-recipient content template and no delivery report. Those limits are the subject of the trade-offs section.

Choosing the smallest operational surface

I chose the scheduled rule on the criterion of smallest operational surface: it is the only one of the four designs where nothing new has to be maintained after the deploy. No operating system, no runtime dependency, no build artifact. What remains to operate is a set of alarms and an empty dead letter queue.

I delivered two templates. The first uses AWS::Events::Rule, which is the classic answer and the one the team recognized. The second uses AWS::Scheduler::Schedule, and it is the one I would leave running, for the reason developed further down. The two stacks are alternatives, not complements, and they deploy under different stack names.

The publish path, from schedule to subscriber

EventBridge evaluates the schedule expression and, at the scheduled time, calls sns:Publish on the campaign topic. The topic distributes to the confirmed subscriptions, each protocol at its own pace. If the target invocation fails permanently, the message goes to an SQS dead letter queue with attributes identifying the rule, the target and the error. Four CloudWatch alarms in the rule template watch the places where the flow can break quietly: invocation failure on the EventBridge side, a denied write to the dead letter queue, dead letter queue depth, and delivery failure on the SNS side. The Scheduler template has five, because its trigger side reports the target error and the dropped invocation as separate metrics.

flowchart LR
  RULE[EventBridge scheduled rule] -->|sns:Publish| TOPIC[SNS topic encrypted with KMS]
  TOPIC --> MAIL[Email subscriptions]
  TOPIC --> SMS[SMS subscriptions]
  TOPIC --> PUSH[Mobile push subscriptions]
  TOPIC --> QUEUE[SQS queue for the internal consumer]
  RULE -->|retries exhausted| DLQ[(SQS dead letter queue)]
  RULE -.->|FailedInvocations| CW[CloudWatch alarms]
  RULE -.->|InvocationsFailedToBeSentToDlq| CW
  DLQ -.->|ApproximateNumberOfMessagesVisible| CW
  TOPIC -.->|NumberOfNotificationsFailed| CW

EventBridge cron expressions run in UTC

This detail cost a campaign at an earlier client, so it comes before the template.

The cron expression of an AWS::Events::Rule has six fields and is always evaluated in UTC. There is no time zone property on the resource. The AWS documentation makes it explicit by annotating every example hour with UTC+0.

Field Values Wildcards accepted
Minutes 0-59 , - * /
Hours 0-23 , - * /
Day-of-month 1-31 , - * ? / L W
Month 1-12 or JAN-DEC , - * /
Day-of-week 1-7 or SUN-SAT , - * ? L #
Year 1970-2199 , - * /

The sixth field, year, is not optional. Anyone pasting a five-field crontab gets a validation error.

The second trap sits in the day fields. The documentation does not allow day-of-month and day-of-week in the same expression: if one of them carries a value or *, the other needs ?. That is why the campaign expression is:

cron(0 12 ? * SAT *)
Enter fullscreen mode Exit fullscreen mode

and not cron(0 12 * * SAT *), which is rejected for using * in day-of-month alongside SAT in day-of-week. The ? means any, and it marks the field as not participating in the decision.

On the hour itself: 12:00 UTC corresponds to 09:00 in America/Sao_Paulo, which sits at UTC-3. For this origin zone the risk is not that the offset moves. It is that the conversion exists at all and lives in the operator's head, so the number in the template no longer says what the business asked for, and reading it back correctly depends on someone remembering the offset. Add the subscribers in other countries, where the offset does shift twice a year, and the rule keeps firing at the same absolute instant while the local hour moves under it. Both failures come from the same place: the expression carries no time zone, so the time zone has to be carried by a person.

The CloudFormation template for the EventBridge rule

Full file in iac/eventbridge-sns-notifications.yaml. What follows are the parts that need justification.

Parameter validation that fails the deploy instead of the campaign

Everything that changes between accounts is a parameter, with validation. Three of them carry most of the information. The topic ARN decides whether the stack owns the topic at all and gets a section of its own below. The other two are the key and the schedule expression:

  ExistingKmsKeyArn:
    Type: String
    Default: ''
    AllowedPattern: '^$|^arn:aws[a-zA-Z-]*:kms:[a-z0-9-]+:[0-9]{12}:key/[0-9a-zA-Z-]+$'
    ConstraintDescription: >-
      Leave empty or provide the ARN of a KMS key, in the form
      arn:aws:kms:REGION:ACCOUNT:key/KEY_ID. An alias ARN is rejected on
      purpose: alias/aws/sns has a key policy that cannot be edited, so the
      grant to events.amazonaws.com has nowhere to go.
    Description: >-
      ARN of an existing customer managed KMS key for the topic this stack
      creates. Leave empty and the stack creates its own key. Ignored when
      ExistingTopicArn is set, because that topic keeps whatever key it already
      has. The key you name here has to grant kms:GenerateDataKey* and
      kms:Decrypt to events.amazonaws.com in its own key policy: this stack
      writes a key policy only for the key it creates. Do not point this at
      alias/aws/sns either, because the key policy of an AWS managed key cannot
      be edited, so the grant has nowhere to go and the rule publish fails with
      KMSAccessDeniedException.
Enter fullscreen mode Exit fullscreen mode

The AllowedPattern on that parameter is the part doing real work. The warning in the Description is readable, and a warning in a description stops nobody: pass an alias ARN and the stack reaches CREATE_COMPLETE, then the campaign fails on the first Saturday. The pattern accepts an empty string or a key ARN and rejects anything with alias/ in the resource part, which turns the warning into a parameter validation failure at deploy time. The same parameter in the Scheduler template carries the pattern for a second reason: there the value also lands in the Resource of an IAM statement, and a malformed ARN there fails the role creation with MalformedPolicyDocument, an error that names neither the parameter nor the template line that caused it.

AlarmTopicArn gets the same treatment, accepting an empty string or an SNS topic ARN. That one is a partial guard and worth saying so: the ARN of the campaign topic is itself a valid SNS topic ARN, so the pattern catches a typo and does not catch the mistake that matters, which is pointing the alarm action at the campaign topic.

  ScheduleExpression:
    Type: String
    Default: cron(0 12 ? * SAT *)
    MinLength: 1
    Description: >-
      Schedule expression of the rule. AWS::Events::Rule always evaluates cron
      in UTC, with no time zone option. cron(0 12 ? * SAT *) is Saturday 12:00
      UTC, which is 09:00 in America/Sao_Paulo. The ? in the day-of-month field
      is required whenever the day-of-week field carries a value.
Enter fullscreen mode Exit fullscreen mode

Whether the stack owns the campaign topic

The requirement was that the existing topic keeps its subscriptions. The first version of this template declared an AWS::SNS::Topic and took the name as a parameter, which looks like it honors that requirement and does the opposite, for one reason: sns:CreateTopic is idempotent. The API reference states it plainly, that a requester who already owns a topic with the given name gets that topic's ARN back without a new topic being created.

Follow what CloudFormation does with that. It calls CreateTopic with the name from the parameter. The production topic already exists under that name, so the call succeeds and returns its ARN, and the stack reaches CREATE_COMPLETE holding the production topic as the physical resource of a logical resource it believes it created. Then: the topic policy written by the stack replaces the policy of that topic, so every publisher that depended on the previous one loses access; DisplayName and KmsMasterKeyId overwrite what was there, which changes the sender name on email and the prefix on SMS for the current subscribers; and delete-stack deletes the topic along with the confirmed subscriptions. Nothing in that sequence produces an error. A loud CREATE_FAILED would have been the safe outcome, and it is not what happens.

So ownership is a parameter, not an accident:

  ExistingTopicArn:
    Type: String
    Default: ''
    AllowedPattern: '^$|^arn:aws[a-zA-Z-]*:sns:[a-z0-9-]+:[0-9]{12}:[a-zA-Z0-9_-]{1,256}$'
    ConstraintDescription: >-
      Leave empty or provide the ARN of an SNS topic, in the form
      arn:aws:sns:REGION:ACCOUNT:TOPIC_NAME. The pattern accepts a standard
      topic only. The ARN of a FIFO topic ends in .fifo and is rejected on
      purpose, because a FIFO topic is limited to 100 subscriptions and cannot
      carry this campaign.
    Description: >-
      ARN of the campaign topic that already carries the production
      subscriptions. Leave empty and the stack creates its own topic, which is
      the self-contained lab. With a value, the stack creates no topic and does
      not delete one on teardown, and TopicName, TopicDisplayName and
      ExistingKmsKeyArn are ignored. TestSubscriptionEmail still applies and
      creates a real subscription on the topic you name, so leave it empty here
      unless you want that address on the live campaign. Two prerequisites on
      the topic you name: this stack replaces its access policy, so any
      publisher that depended on the previous policy loses access, and if the
      topic is encrypted its key
      policy already has to grant kms:GenerateDataKey* and kms:Decrypt to
      events.amazonaws.com, because this stack does not write to a key it did
      not create.
Enter fullscreen mode Exit fullscreen mode
  CreateTopic: !Equals [!Ref ExistingTopicArn, '']
  # The stack only writes a key policy for a key it creates, and it only has a
  # topic to attach a key to when it creates the topic. Pointing at an existing
  # topic therefore creates no key, instead of leaving an orphan key behind.
  CreateKmsKey: !And
    - !Condition CreateTopic
    - !Equals [!Ref ExistingKmsKeyArn, '']
Enter fullscreen mode Exit fullscreen mode

Left empty, the stack is a self-contained lab: it stands up a topic equivalent to the production one, wires the schedule to it, and the teardown takes it back out. That is the mode to use while you are still shaping the stack.

With an ARN in it, the topic is outside the stack, which is the point. There is no AWS::SNS::Topic to adopt and no physical resource for delete-stack to remove, so the destructive path closes. The ARN then flows to the Topics and Resource of the topic policy, the rule target, the optional test subscription, the TopicName dimension of the SNS delivery alarm, and the TopicArn output. The alarm dimension is the one that needed thought: it takes a name, not an ARN, and !GetAtt CampaignTopic.TopicName does not exist on this branch, so it comes out of the ARN:

      Dimensions:
        # The AWS/SNS dimension is the topic name, which has to come out of the
        # ARN when the topic was not created here.
        - Name: TopicName
          Value: !If
            - CreateTopic
            - !GetAtt CampaignTopic.TopicName
            - !Select [5, !Split [':', !Ref ExistingTopicArn]]
Enter fullscreen mode Exit fullscreen mode

One thing the parameter does not make safe, and it has to be said rather than buried in a description: pointing at a production topic still replaces that topic's access policy, because AWS::SNS::TopicPolicy replaces instead of adding. Read the existing policy first, with aws sns get-topic-attributes, and carry anything you still need into this template. The parameter removes the deletion path, not the authorization path.

The test subscription deserves the same warning. It is not ignored in this mode, so an address left in TestSubscriptionEmail becomes a real subscription on the production topic, gets a confirmation mail, and from then on receives the actual campaign alongside everyone else. Leave the parameter empty once the topic is the live one.

Encryption at rest, and why the AWS managed key does not work here

My baseline for an SNS topic is encryption at rest with the AWS managed key, alias/aws/sns, with a parameter left open for a customer managed key when compliance asks for one. In this design that baseline does not work, and I found out on the first deploy attempt.

The SNS documentation is direct about it: for an AWS service to publish to an encrypted topic, you need a customer managed key, and you have to grant kms:GenerateDataKey* and kms:Decrypt to the service principal in the key policy. The table of principals lists events.amazonaws.com. The key policy of an AWS managed key cannot be edited, so there is nowhere to put that grant. With alias/aws/sns on the topic, the rule fires, the publish fails, and the error is KMSAccessDeniedException.

There is a second part to it, and it runs against habit: the same page records that aws:SourceAccount, aws:SourceArn and aws:SourceOrgID are not supported in a key policy on the EventBridge to encrypted topic path. The KMS statement stays without an origin condition. That does not open the topic, because who may publish is still controlled by the topic policy, but it is an exception to the reflex of conditioning every service grant.

  CampaignKey:
    Type: AWS::KMS::Key
    Condition: CreateKmsKey
    DeletionPolicy: Retain
    UpdateReplacePolicy: Retain
    Properties:
      Description: !Sub 'Encryption at rest for the campaign topic of stack ${AWS::StackName}'
      EnableKeyRotation: true
      KeyPolicy:
        Version: '2012-10-17'
        Statement:
          - Sid: AllowAccountIamPolicies
            Effect: Allow
            Principal:
              AWS: !Sub 'arn:${AWS::Partition}:iam::${AWS::AccountId}:root'
            Action: 'kms:*'
            Resource: '*'
          # No Condition here on purpose: the SNS documentation records that
          # aws:SourceArn, aws:SourceAccount and aws:SourceOrgID are not
          # supported in a key policy on the EventBridge to encrypted topic
          # path. Who may publish stays controlled by the topic policy below.
          - Sid: AllowEventBridgeToEncryptMessages
            Effect: Allow
            Principal:
              Service: events.amazonaws.com
            Action:
              - 'kms:GenerateDataKey*'
              - 'kms:Decrypt'
            Resource: '*'
Enter fullscreen mode Exit fullscreen mode

That grant is in the key policy of the key this stack creates, which means it covers one of the ways the parameter can be used and not the other. Pass ExistingKmsKeyArn and no key policy is written by the stack at all: the topic uses a key whose policy the template does not control and cannot read. The AllowedPattern rejects an alias ARN and says nothing about the contents of a key policy, so a customer managed key without the grant to events.amazonaws.com passes validation, the stack reaches CREATE_COMPLETE, and the first Saturday reproduces the same KMSAccessDeniedException this section is about. The diagnosis is in the dead letter queue, under ERROR_CODE, as ACTION_DECRYPTION_FAILURE. If you supply a key, read its policy before you supply it.

The key carries DeletionPolicy: Retain on purpose, and the teardown section says what that means in practice.

The topic policy, and what the origin conditions prevent

Authorization for EventBridge to publish is a resource policy on the topic, with principal events.amazonaws.com and action sns:Publish. The documentation example for this case comes with no condition at all, and a template that copies that example literally grants publish rights to any EventBridge rule in any AWS account. That is the confused deputy problem in its most direct form: the service holds the permission, and your resource does not check on whose behalf the service is acting.

The EventBridge documentation on the subject says which value to use: for a target of a rule on an event bus, aws:SourceArn has to be the ARN of the rule. I add aws:SourceAccount to tie it to this account.

The policy has one statement, and the decision to keep it at one is the subject of the paragraphs after the block.

  CampaignTopicPolicy:
    Type: AWS::SNS::TopicPolicy
    Properties:
      Topics:
        - !If [CreateTopic, !Ref CampaignTopic, !Ref ExistingTopicArn]
      PolicyDocument:
        Version: '2012-10-17'
        Statement:
          # Without the two conditions below, any EventBridge rule in any
          # account could publish here. aws:SourceArn ties the permission to
          # this rule; aws:SourceAccount ties it to this account. The SNS
          # access control documentation lists Amazon EventBridge among the
          # services that set SourceAccount when publishing to a topic, so both
          # keys are in the request context on this path.
          - Sid: PublishOnlyFromTheScheduledRule
            Effect: Allow
            Principal:
              Service: events.amazonaws.com
            Action: 'sns:Publish'
            Resource: !If [CreateTopic, !Ref CampaignTopic, !Ref ExistingTopicArn]
            Condition:
              ArnEquals:
                'aws:SourceArn': !GetAtt CampaignRule.Arn
              StringEquals:
                'aws:SourceAccount': !Ref AWS::AccountId
Enter fullscreen mode Exit fullscreen mode

Both condition keys have to be in the request context for that statement to grant anything, and that is worth checking rather than assuming. The SNS access control documentation lists the services that set SourceAccount when they publish to a topic, and Amazon EventBridge is on the list. The same page records that aws:SourceOwner, which appears in older examples for the same purpose, is deprecated, and that new services integrate through aws:SourceArn and aws:SourceAccount.

AWS::SNS::TopicPolicy replaces the topic policy rather than adding to it, which is the fact that makes the single statement a decision instead of an omission. The resulting document is exactly what is written above. There is no administrative statement, no statement for the operations team, and no statement for the account.

I wrote one in the first version of this template, and then took it out. The reasoning behind writing it goes like this: the policy replaces the default, the default grants the account owner the administrative actions, so the replacement should restore them. Two things are wrong with that chain. The fact in the middle goes first: the SNS documentation describes a new topic as created with no permissions, reachable by the account that created it, which is not a default that hands administration to the account through the topic policy, so there is nothing of that kind to restore. The decisive error is the assumption the conclusion rests on and never states. A resource policy grants access on top of whatever the caller identity policy already allows, not instead of it. An administrative statement with principal * and a condition tying it to this account grants those actions to every identity in the account, including a role whose identity policy carries no SNS permission at all. With sns:DeleteTopic, sns:SetTopicAttributes and sns:Subscribe in the list, any internal principal could drop the campaign topic, rewrite this policy or attach an endpoint of its choosing to a topic with millions of subscriptions. sns:AddPermission is the worst of them, because it is the action that adds a cross-account Allow for sns:Publish, which is the exact requirement the design was supposed to hold. No principal outside the account can publish today, and that statement let any principal inside the account change it.

Administration of a topic in the same account needs no statement in the resource policy. The identity policy of the caller authorizes it alone. Manual publishing works the same way, and the validation section comes back to that, because it changes how a failed test publish gets diagnosed.

There is a second trap in the vicinity, and it is about which condition key belongs on which statement. The IAM global condition key reference describes aws:SourceAccount as entering the request context when the call to the resource is made by an AWS service principal, carrying the account the service acts on behalf of. It describes aws:PrincipalAccount as present in every request, anonymous ones included, comparing the account the calling principal belongs to. Two keys with similar names, two different request contexts. The publish statement above is called by a service principal, which is where aws:SourceAccount applies. A statement written for a call made directly by an IAM principal is a different context and reaches for aws:PrincipalAccount instead.

What makes that easy to get backwards is the shape of the policy people copy from. The EventBridge documentation on resource-based policies reproduces a default SNS topic policy whose single statement has Principal as * and conditions the administrative actions on AWS:SourceAccount. Lifting that shape into a hand-written policy is how a statement ends up resting on a key nobody verified for that path. For the case here the SNS access control documentation closes the question from the other side: a new topic is created private, with no permissions, reachable by the account that created it. Administration inside the same account needs no statement on the topic. It needs an identity policy.

The input transformer and the execution identifier

The default scheduled event reaches the subscriber as the metadata envelope every EventBridge event carries, with the fields version, id, detail-type, source, account, time, region, resources and detail. For a scheduled rule the service fills it the way the events reference documents, with the ARN of the rule in resources and detail empty, because a firing has nothing to describe:

{
  "version": "0",
  "id": "7c4b21f0-9e3a-4b55-8f21-2c6d9a4f1b8e",
  "detail-type": "Scheduled Event",
  "source": "aws.events",
  "account": "123456789012",
  "time": "2026-10-10T12:00:00Z",
  "region": "us-east-1",
  "resources": ["arn:aws:events:us-east-1:123456789012:rule/weekly-campaigns-rule-saturday-trigger"],
  "detail": {}
}
Enter fullscreen mode Exit fullscreen mode

The time is the firing this rule produces: Saturday, October 10, 2026, 12:00 UTC, 09:00 in America/Sao_Paulo. The id is a UUID that EventBridge generates per event. Useful for debugging, useless as a campaign, since what the subscriber would read is the envelope rather than a message. The input transformer replaces the payload.

          InputTransformer:
            InputPathsMap:
              eventId: '$.id'
              scheduledTime: '$.time'
            InputTemplate: |
              {
                "campaign": "weekly-newsletter",
                "executionId": "<eventId>",
                "scheduledTime": "<scheduledTime>",
                "ingestionTime": "<aws.events.event.ingestion-time>",
                "rule": "<aws.events.rule-name>"
              }
Enter fullscreen mode Exit fullscreen mode

InputPathsMap declares variables from JSONPath over the original event. aws.events.event.ingestion-time and aws.events.rule-name are predefined variables: they do not need declaring, and the first one is generated by EventBridge as an ISO 8601 timestamp and cannot be overwritten. A variable pointing at a scalar value goes inside quotes in the template; a variable pointing at an object or array does not.

Against the event above, the template renders the body the topic publishes:

{
  "campaign": "weekly-newsletter",
  "executionId": "7c4b21f0-9e3a-4b55-8f21-2c6d9a4f1b8e",
  "scheduledTime": "2026-10-10T12:00:00Z",
  "ingestionTime": "2026-10-10T12:00:03Z",
  "rule": "weekly-campaigns-rule-saturday-trigger"
}
Enter fullscreen mode Exit fullscreen mode

The two timestamps are not the same instant, and the gap is the design rather than a defect: scheduledTime is the minute the schedule fired, ingestionTime is when EventBridge received the event, and the documentation is explicit that several seconds can pass between the scheduled time and the target running. A consumer that treats them as interchangeable will eventually file a firing under the wrong minute.

The executionId field carries the event id, and it exists because of the idempotency discussion further down. Retries of the same event keep the same id, while each weekly firing produces a new one. That is the key a consumer uses to recognize an occurrence it already processed.

One consequence has to be clear: plain email subscribers receive this JSON as the message body. For a campaign with formatted content, either the InputTemplate emits a string instead of JSON, or the topic feeds a consumer that renders the content. The JSON envelope serves the design where there is a consumer on the other side.

Explicit retry policy and dead letter queue

EventBridge defaults to retrying for 24 hours and up to 185 attempts, with exponential backoff and jitter. For a campaign with a fixed hour, that default has an unwanted effect: a long outage results in delivery the next day, which for marketing is worse than no delivery. I cut the window to one hour and send the rest to the dead letter queue.

          RetryPolicy:
            MaximumEventAgeInSeconds: 3600
            MaximumRetryAttempts: 10
          DeadLetterConfig:
            Arn: !GetAtt CampaignDlq.Arn
Enter fullscreen mode Exit fullscreen mode

The dead letter queue is not decoration. Some errors never go through a retry at all: missing permission on the target, a target that no longer exists, an address resolution failure. The documentation says those go straight to the dead letter queue when one is configured. Without it, the only clue would be the FailedInvocations metric, which tells you a failure happened and carries neither the payload nor the reason. Each message in the queue arrives with the attributes RULE_ARN, TARGET_ARN, ERROR_CODE, ERROR_MESSAGE, EXHAUSTED_RETRY_CONDITION and RETRY_ATTEMPTS.

Two documented constraints on an EventBridge dead letter queue: standard queues only, FIFO is not accepted, and the queue has to be in the same region as the rule.

Queue retention is the parameter I nearly left at the default. MessageRetentionPeriod accepts from 60 seconds to 1,209,600 seconds, which is 14 days, and the default is 345,600 seconds, 4 days. On a weekly campaign, 4 days is short: a Saturday failure expires on Wednesday, before anyone has a reason to look. The template uses the maximum.

The permission to write to the queue deserves a warning. When you configure a dead letter queue through the console, the console attaches the resource policy to the queue for you. Through the API or CloudFormation, it does not. It is a common and quiet deploy error: the stack comes up, the rule fires, the invocation fails, and the attempt to write to the dead letter queue fails too. The symptom shows up in InvocationsFailedToBeSentToDlq, and the queue depth alarm says nothing, because in that failure mode nothing ever reaches the queue.

  CampaignDlqPolicy:
    Type: AWS::SQS::QueuePolicy
    Properties:
      Queues:
        - !Ref CampaignDlq
      PolicyDocument:
        Version: '2012-10-17'
        Statement:
          # Only aws:SourceArn here, matching the target dead letter queue
          # example in the EventBridge documentation. The rule ARN already
          # carries the account ID, so the account pin is not lost, and this
          # path has no published confirmation that aws:SourceAccount reaches
          # the request context. An Allow conditioned on a key that is absent
          # grants nothing, and since AWS::SQS::QueuePolicy replaces the queue
          # policy, that would silently take the dead letter path offline.
          - Sid: AllowEventBridgeToWriteToTheDlq
            Effect: Allow
            Principal:
              Service: events.amazonaws.com
            Action: 'sqs:SendMessage'
            Resource: !GetAtt CampaignDlq.Arn
            Condition:
              ArnEquals:
                'aws:SourceArn': !GetAtt CampaignRule.Arn
Enter fullscreen mode Exit fullscreen mode

That statement carries one condition key, and the topic policy a few paragraphs up carries two. The asymmetry is deliberate. For the publish path the SNS documentation names EventBridge as a service that sets SourceAccount, so both keys are confirmed present. For a write to a target dead letter queue the official example in the EventBridge dead letter queue documentation uses only aws:SourceArn, and I did not find published confirmation that aws:SourceAccount reaches the request context on that path. Adding a key on a guess has an asymmetric cost here. An Allow whose condition cannot match grants nothing, AWS::SQS::QueuePolicy replaces the queue policy rather than adding to it, and the result would be the exact quiet failure described above, with the whole evidence trail gone. The rule ARN already contains the account ID, so pinning the account is not something the shorter statement gives up.

Four alarms and the TreatMissingData detail

  RuleFailedInvocationsAlarm:
    Type: AWS::CloudWatch::Alarm
    Properties:
      AlarmName: !Sub '${AWS::StackName}-failed-invocations'
      AlarmDescription: The scheduled rule could not publish to the SNS topic
      Namespace: AWS/Events
      MetricName: FailedInvocations
      Dimensions:
        - Name: RuleName
          Value: !Ref CampaignRule
      Statistic: Sum
      Period: 300
      EvaluationPeriods: 1
      Threshold: 0
      ComparisonOperator: GreaterThanThreshold
      TreatMissingData: notBreaching
Enter fullscreen mode Exit fullscreen mode

FailedInvocations is sent to CloudWatch only when it is non-zero. On a healthy weekly rule, the metric spends the whole week without a single data point. With the default TreatMissingData, the alarm lives in INSUFFICIENT_DATA and nobody trusts it. notBreaching reads the absence of data as success, which is the correct semantics for this metric. Every alarm in both templates sets it explicitly for the same reason.

The second alarm watches ApproximateNumberOfMessagesVisible on the dead letter queue, because a message sitting there is a firing that was lost with evidence attached.

The third one is there because the first two give the same answer to two different questions. FailedInvocations fires whether the evidence was preserved or discarded, and the depth alarm stays at zero in the case where the write to the queue was denied, since nothing arrived. The operator ends up with one signal covering both "it failed, go read the queue" and "it failed, there is nothing to read", and that difference decides whether the campaign can be reprocessed. InvocationsFailedToBeSentToDlq, in AWS/Events with the RuleName dimension, separates them:

  RuleDlqWriteFailureAlarm:
    Type: AWS::CloudWatch::Alarm
    Properties:
      AlarmName: !Sub '${AWS::StackName}-dlq-write-failed'
      AlarmDescription: EventBridge could not write a failed invocation to the dead letter queue
      Namespace: AWS/Events
      MetricName: InvocationsFailedToBeSentToDlq
      Dimensions:
        - Name: RuleName
          Value: !Ref CampaignRule
      Statistic: Sum
      Period: 300
      EvaluationPeriods: 1
      Threshold: 0
      ComparisonOperator: GreaterThanThreshold
      TreatMissingData: notBreaching
Enter fullscreen mode Exit fullscreen mode

Watch the capitalization. A metric name is case sensitive, the spelling in the metric table of the EventBridge monitoring page is InvocationsFailedToBeSentToDlq, and prose elsewhere tends to write DLQ in capitals. An alarm built on the wrong spelling never leaves INSUFFICIENT_DATA and nothing tells you why.

The fourth one exists because the first three only see the trigger. A successful publish with delivery failing at scale lights up none of them, and that was the blind spot I wanted closed. NumberOfNotificationsFailed in the AWS/SNS namespace, with dimension TopicName, covers the delivery side. Its threshold is a parameter, DeliveryFailureThreshold, rather than a hardcoded zero, because the metric counts differently per protocol: it increments by 1 per message when SNS gives up on SQS, email, SMS and push, but counts every failed attempt on HTTP and HTTPS, retries included. On a topic with HTTP or HTTPS subscriptions, a transient endpoint problem produces data points with no definitive loss, and a threshold of zero would page someone for nothing. The ceiling for a single undeliverable message is the numRetries of the delivery policy on that subscription, which defaults to 3 and accepts up to 100, so the threshold is a value you derive from the retry counts on the topic rather than pick by feel. The trade-offs section works through those policies per endpoint type.

One more parameter with a reason behind it: AlarmTopicArn. Pointing the alarm action at the campaign topic would turn every alarm into a notification to millions of subscribers. The alarm topic is an operations topic and has to be a different one. Left empty, the alarms are created with no action, which is what you want while the stack is still being shaped.

Deploying the stack

Example values used from here on: <STACK_NAME> is weekly-campaigns-rule, <SCHEDULER_STACK_NAME> is weekly-campaigns-scheduler, <REGION> is sa-east-1, <ACCOUNT_ID> is 123456789012, the account ID the AWS documentation uses in its own examples and the one every ARN below carries in place of a real account. Two more placeholders appear in the metric read later on, <START_TIME> and <END_TIME>, both in the format 2026-10-08T00:00:00Z, and they are not fixed values: they bound the window of the test you just ran, not the date of this post. The two stack names are deliberate: the templates are alternatives, and they compose physical names for the queue, the key alias and the alarms from the stack name, so they are meant to live under distinct names rather than replace each other.

Those two stack names resolve to the following physical names and ARNs. Having the table in front of you beats resolving a !Sub in your head while reading a command:

Resource Physical name ARN
Campaign topic, rule stack weekly-campaign-subscribers arn:aws:sns:sa-east-1:123456789012:weekly-campaign-subscribers
Scheduled rule weekly-campaigns-rule-saturday-trigger arn:aws:events:sa-east-1:123456789012:rule/weekly-campaigns-rule-saturday-trigger
Rule dead letter queue weekly-campaigns-rule-dlq arn:aws:sqs:sa-east-1:123456789012:weekly-campaigns-rule-dlq
Rule topic key alias alias/weekly-campaigns-rule-topic-key arn:aws:kms:sa-east-1:123456789012:alias/weekly-campaigns-rule-topic-key
Rule invocation failure alarm weekly-campaigns-rule-failed-invocations arn:aws:cloudwatch:sa-east-1:123456789012:alarm:weekly-campaigns-rule-failed-invocations
Campaign topic, Scheduler stack weekly-campaign-subscribers-scheduler arn:aws:sns:sa-east-1:123456789012:weekly-campaign-subscribers-scheduler
Schedule group weekly-campaigns-scheduler-group arn:aws:scheduler:sa-east-1:123456789012:schedule-group/weekly-campaigns-scheduler-group
Schedule weekly-campaigns-scheduler-saturday-trigger arn:aws:scheduler:sa-east-1:123456789012:schedule/weekly-campaigns-scheduler-group/weekly-campaigns-scheduler-saturday-trigger
Schedule dead letter queue weekly-campaigns-scheduler-dlq arn:aws:sqs:sa-east-1:123456789012:weekly-campaigns-scheduler-dlq

Three of those shapes are where a hand-written ARN goes wrong. A schedule ARN carries the group before the name, schedule/GROUP/NAME, while the group is a resource of its own, schedule-group/GROUP, and pinning the wrong one in the trust policy condition is the mistake the Scheduler section comes back to. An alarm ARN separates the name with a colon, alarm:NAME, not a slash. And a queue has two identifiers rather than one: the ARN above, which is what the policies and the DeadLetterConfig take, and the URL https://sqs.sa-east-1.amazonaws.com/123456789012/weekly-campaigns-rule-dlq, which is what receive-message takes and what the DlqUrl output returns.

The Scheduler execution role is the one name absent from that table, and that is deliberate in the template: it declares no RoleName, so CloudFormation composes the physical name from the stack name, the logical ID and a random suffix, giving something shaped like weekly-campaigns-scheduler-CampaignSchedulerRole-<SUFFIX>. Naming it would mean CAPABILITY_NAMED_IAM instead of CAPABILITY_IAM in every deploy below, and a collision the first time the same stack goes to a second region, because IAM is global. Read the ARN off the SchedulerRoleArn output rather than reconstructing it.

TopicName is the one physical name that does not derive from the stack name, and it is a parameter with a fixed default on purpose, because the topic ARN is what the existing subscriptions and the other stacks depend on. That makes it the parameter to check before a deploy, for the reason covered in the section on topic ownership: sns:CreateTopic is idempotent, so CloudFormation gives you no protection against reusing a name. Deploy the same template twice in the same region under two stack names and the second stack does not fail. It succeeds, adopts the same physical topic, replaces the topic policy the first stack wrote, overwrites the display name and the key, and then deletes that topic on whichever delete-stack runs first, while the other stack goes on reporting CREATE_COMPLETE for a resource that is gone.

The check is a command, not a hope for CREATE_FAILED. Call aws sns get-topic-attributes on the ARN the parameter would produce, and let a NotFound be the green light:

aws sns get-topic-attributes \
  --topic-arn arn:aws:sns:<REGION>:<ACCOUNT_ID>:weekly-campaign-subscribers \
  --region <REGION> \
  --profile <PROFILE>
Enter fullscreen mode Exit fullscreen mode

Expected result on a free name: NotFound. The code and the exception class differ, which matters if you script this check: the CLI prints the response code, NotFound, while the SDKs expose the same error as a class named NotFoundException. Match on the response code. Anything else means the name is taken, and the choice is then to pick another name, deploy to another region, or pass the ARN in ExistingTopicArn on purpose. The two templates in this post do not share a topic, and the reason is that their defaults differ, not that CloudFormation would have stopped them.

Lint first. If it is not installed:

pip install cfn-lint
Enter fullscreen mode Exit fullscreen mode
cfn-lint iac/eventbridge-sns-notifications.yaml
cfn-lint iac/eventbridge-scheduler-sns.yaml
Enter fullscreen mode Exit fullscreen mode

Expected result: empty output and return code 0 on both files, meaning no findings. Then the service-side check, which requires credentials:

aws cloudformation validate-template \
  --template-body file://iac/eventbridge-sns-notifications.yaml \
  --region <REGION> \
  --profile <PROFILE>
Enter fullscreen mode Exit fullscreen mode

Expected result: the service returns the parameter list, the template description and the capabilities it requires. Without credentials configured, this command fails on credentials rather than on the template, which is a different outcome from a template problem and should not be read as one.

Deploy. --capabilities CAPABILITY_IAM is required by the Scheduler template, which creates a role, and harmless on this one:

aws cloudformation deploy \
  --template-file iac/eventbridge-sns-notifications.yaml \
  --stack-name <STACK_NAME> \
  --parameter-overrides \
      TopicName=weekly-campaign-subscribers \
      ScheduleExpression='cron(0 12 ? * SAT *)' \
      RuleState=ENABLED \
      TestSubscriptionEmail=ops@example.com \
      ProjectTag=weekly-campaigns \
  --capabilities CAPABILITY_IAM \
  --region <REGION> \
  --profile <PROFILE>
Enter fullscreen mode Exit fullscreen mode

Outputs:

aws cloudformation describe-stacks \
  --stack-name <STACK_NAME> \
  --query 'Stacks[0].Outputs' \
  --output table \
  --region <REGION> \
  --profile <PROFILE>
Enter fullscreen mode Exit fullscreen mode

Expected result: TopicArn, RuleName, RuleArn, DlqUrl, DlqArn and KmsKeyArn. The TopicArn has the form arn:aws:sns:sa-east-1:123456789012:weekly-campaign-subscribers and is the <TOPIC_ARN> of the commands below, and DlqUrl is the <DLQ_URL>.

Inspecting what was created:

aws events describe-rule \
  --name weekly-campaigns-rule-saturday-trigger \
  --region <REGION> \
  --profile <PROFILE>

aws events list-targets-by-rule \
  --rule weekly-campaigns-rule-saturday-trigger \
  --region <REGION> \
  --profile <PROFILE>
Enter fullscreen mode Exit fullscreen mode

Expected result: the first confirms ScheduleExpression and State: ENABLED. The second is the one I read with more attention, because it shows the target Arn, the InputTransformer with the rendered template, and the DeadLetterConfig. If DeadLetterConfig is not there, the dead letter queue is not wired, whatever the template says.

Proving the schedule works without waiting for Saturday

Three checks, in increasing order of isolation.

The first separates delivery failure from scheduling failure. Publish to the topic by hand and confirm the message reaches the test endpoint:

aws sns publish \
  --topic-arn <TOPIC_ARN> \
  --message '{"campaign":"weekly-newsletter","executionId":"manual-test-001"}' \
  --subject 'Delivery test' \
  --region <REGION> \
  --profile <PROFILE>
Enter fullscreen mode Exit fullscreen mode

Expected result: a MessageId comes back. Two permissions decide this command, and both matter for diagnosis. The topic policy grants sns:Publish only to the EventBridge service principal, so an operator publishing by hand is authorized by the IAM policy of their own identity, not by the topic policy. On top of that, the topic is encrypted, so the same identity needs kms:GenerateDataKey* and kms:Decrypt on the topic key. A KMS error here points at the key policy or the caller KMS permissions; an SNS authorization error points at the caller IAM policy.

The second exercises the schedule itself by shortening the interval. This changes the stack and has to be reverted:

# WARNING: temporary change. Revert to cron(0 12 ? * SAT *) after the test.
aws cloudformation deploy \
  --template-file iac/eventbridge-sns-notifications.yaml \
  --stack-name <STACK_NAME> \
  --parameter-overrides ScheduleExpression='rate(5 minutes)' \
  --capabilities CAPABILITY_IAM \
  --region <REGION> \
  --profile <PROFILE>
Enter fullscreen mode Exit fullscreen mode

Expected result: within fifteen minutes there are enough data points for the metric read below. Reverting is a deploy with the original expression. Leaving rate(5 minutes) forgotten on a production topic is the kind of accident that turns into a real incident, so the revert is part of the test and not a follow-up.

The third is the metric read. Invocations in the AWS/Events namespace counts the times the target was invoked. Set the window to the minutes you spent on the rate(5 minutes) test, because a window that misses it returns an empty list for the wrong reason, and an empty list is also what the healthy path returns on the failure metric below:

aws cloudwatch get-metric-statistics \
  --namespace AWS/Events \
  --metric-name Invocations \
  --dimensions Name=RuleName,Value=weekly-campaigns-rule-saturday-trigger \
  --start-time <START_TIME> \
  --end-time <END_TIME> \
  --period 300 \
  --statistics Sum \
  --region <REGION> \
  --profile <PROFILE>
Enter fullscreen mode Exit fullscreen mode

Expected result: one data point with Sum: 1 per firing inside the window. Swapping --metric-name for FailedInvocations, the expected result is Datapoints: [], because that metric is published only when it is non-zero. An empty list there is the good outcome.

If something did fail, the dead letter queue holds the detail:

aws sqs receive-message \
  --queue-url <DLQ_URL> \
  --message-attribute-names All \
  --max-number-of-messages 10 \
  --region <REGION> \
  --profile <PROFILE>
Enter fullscreen mode Exit fullscreen mode

Expected result: the ERROR_CODE attribute directs the diagnosis. NO_PERMISSIONS points at the topic policy or the key policy. ACTION_DECRYPTION_FAILURE points at KMS.

Teardown, which is never optional:

aws cloudformation delete-stack --stack-name <STACK_NAME> --region <REGION> --profile <PROFILE>
aws cloudformation wait stack-delete-complete --stack-name <STACK_NAME> --region <REGION> --profile <PROFILE>
Enter fullscreen mode Exit fullscreen mode

That removes the topic only when the stack created it. With ExistingTopicArn set there is no topic in the stack, so the teardown leaves the topic and the subscriptions it already had in place and takes away the rule, the queue, the alarms and the topic policy. One subscription does go, if TestSubscriptionEmail was filled in on that deploy: the stack owns the test subscription even when it does not own the topic, and that is the only subscription of an external topic the teardown touches. The policy is the part to plan for: deleting the stack removes the statement that authorized the rule, and it does not restore whatever policy the topic had before the stack replaced it.

The KMS key carries DeletionPolicy: Retain, so it survives the delete-stack and has to be scheduled for deletion separately. That is deliberate: a key deleted along with the stack makes anything encrypted under it unreadable. The alias does not survive, which is what lets you recreate the stack under the same name without a collision.

The same thing happens on an update, and it surprises people, so it is worth tracing. Supply ExistingKmsKeyArn or ExistingTopicArn to a stack that was created with the defaults and CreateKmsKey turns false, which takes both the key and its alias out of the stack. The key is retained and keeps being billed as a key, with rotation still enabled; the alias has no DeletionPolicy and is deleted. So the key you have to clean up afterwards no longer has a name pointing at it, and aws kms schedule-key-deletion needs the key ID. Read it off the KmsKeyArn output before the update, not after.

ExistingTopicArn takes a second resource out of the stack on that same update, and this one is the intended path rather than an accident. Passing it turns CreateTopic false, and CampaignTopic carries no DeletionPolicy, so the topic the stack had created is deleted, taking its subscriptions with it. That is the transition from the self-contained lab to the production topic, and discarding the lab topic is the cleanup you want at that moment. Clear TestSubscriptionEmail in the same update, though. Left filled, the test subscription is not deleted but recreated against the ARN you just supplied, which is the one way these stacks subscribe an address to the production topic. If you would rather keep the lab topic after the switch, DeletionPolicy: Retain on CampaignTopic is the edit, and then that topic also survives the teardown and joins the KMS key on the list of things to clean up by hand.

The EventBridge Scheduler version, with a native time zone

The EventBridge documentation classifies scheduled rules as a legacy feature and recommends EventBridge Scheduler for invoking targets on a schedule. For this case the difference that matters is one property: ScheduleExpressionTimezone.

Full file in iac/eventbridge-scheduler-sns.yaml.

  CampaignSchedule:
    Type: AWS::Scheduler::Schedule
    Properties:
      Name: !Sub '${AWS::StackName}-saturday-trigger'
      Description: Weekly Saturday campaign, evaluated in the local time zone
      GroupName: !Ref CampaignScheduleGroup
      State: !Ref ScheduleState
      ScheduleExpression: !Ref ScheduleExpression
      ScheduleExpressionTimezone: !Ref ScheduleTimezone
      FlexibleTimeWindow: !If
        - UseFlexibleWindow
        - Mode: FLEXIBLE
          MaximumWindowInMinutes: !Ref FlexibleWindowMinutes
        - Mode: 'OFF'
      Target:
        # Templated Scheduler target for Amazon SNS Publish: the topic ARN is
        # enough, with no service specific parameter block.
        Arn: !If [CreateTopic, !GetAtt CampaignTopic.TopicArn, !Ref ExistingTopicArn]
        RoleArn: !GetAtt CampaignSchedulerRole.Arn
        # Scheduler context attributes. execution-id is assigned per invocation
        # attempt, so it does not work as a deduplication key: whoever
        # deduplicates uses scheduled-time, which is stable across the attempts
        # of one occurrence. An attempt-number above 1 identifies a retry.
        Input: |
          {
            "campaign": "weekly-newsletter",
            "executionId": "<aws.scheduler.execution-id>",
            "attempt": "<aws.scheduler.attempt-number>",
            "scheduledTime": "<aws.scheduler.scheduled-time>",
            "schedule": "<aws.scheduler.schedule-arn>"
          }
        RetryPolicy:
          MaximumEventAgeInSeconds: 3600
          MaximumRetryAttempts: 10
        DeadLetterConfig:
          Arn: !GetAtt CampaignDlq.Arn
Enter fullscreen mode Exit fullscreen mode

The expression becomes cron(0 9 ? * SAT *) with ScheduleExpressionTimezone: America/Sao_Paulo, and the 9 means nine in the morning local time. The conversion leaves the operator's head and becomes the service's responsibility. For a client firing campaigns in several countries, that means one schedule per time zone instead of a conversion spreadsheet.

The same Saturday firing, with the context attributes resolved by Scheduler instead of by an input transformer, reaches the topic like this:

{
  "campaign": "weekly-newsletter",
  "executionId": "c7a4k91fe2db5a08",
  "attempt": "1",
  "scheduledTime": "2026-10-10T12:00:00Z",
  "schedule": "arn:aws:scheduler:sa-east-1:123456789012:schedule/weekly-campaigns-scheduler-group/weekly-campaigns-scheduler-saturday-trigger"
}
Enter fullscreen mode Exit fullscreen mode

Two details in there are worth more than the shape. The executionId is not a UUID: the documented example of aws.scheduler.execution-id is a short alphanumeric string, assigned per invocation attempt, which is the second reason the idempotency section refuses it as a deduplication key. And scheduledTime is written as the UTC instant, which is the documented example format for the attribute, with the Z suffix, rather than something I read off a capture. The schedule fires at 09:00 local and that is the same moment as 12:00 UTC, so a consumer that formats the field for a human has to apply the time zone back. If your pipeline branches on that value, confirm it on your own schedule before trusting either reading.

Mode: 'OFF' needs the quotes in YAML, otherwise the parser reads OFF as boolean false. MaximumWindowInMinutes accepts 1 to 1440. I left FLEXIBLE as the default with 5 minutes, because spreading the publish inside a window smooths the spike on the consumer side. A campaign with a contractual cut-off time uses OFF.

Three structural differences between the two templates.

Authorization moves. The rule publishes as a service principal, authorized by the topic resource policy. Scheduler assumes a role and publishes as that role. That takes the KMS complication out of the path: the key policy can delegate to IAM in the ordinary way, and the grant of kms:GenerateDataKey* and kms:Decrypt goes in the role policy with the key ARN as the resource. The same role gets sqs:SendMessage on the dead letter queue, which is why that template has no AWS::SQS::QueuePolicy. One permission path instead of two.

That also lifts the constraint that forced a customer managed key in the other template. The SNS documentation puts the role publishing to an encrypted topic under the ordinary case, where the publisher carries kms:GenerateDataKey* and kms:Decrypt in its own identity policy, so alias/aws/sns would work here and my usual baseline would apply. This template creates a key anyway, for two reasons worth naming because they are choices and not requirements: an editable key policy is what makes the delegation below visible as a statement you can read, and keeping the key parameter identical across the two stacks means the teardown and the retained-key caveat read the same way in both. Swap it for the AWS managed key and the only thing you lose is that discussion.

The delegation is doing load-bearing work in that sentence, and it only holds for the key the stack creates. KMS evaluates the key policy and the identity policy together, so an IAM grant on a key whose policy neither delegates to the account root nor names this role authorizes nothing. The key this template creates carries the delegation statement; a key supplied through ExistingKmsKeyArn may not. That is also why supplying an existing topic does not make the key parameter pointless here, unlike in the rule template: with an external encrypted topic, ExistingKmsKeyArn is how the role gets access to the key that topic already uses, and the statement is dropped from the role policy altogether when there is no key to name.

The trust policy gains origin conditions. Scheduler supports aws:SourceArn pointing at the schedule group, and that is why the template creates a dedicated AWS::Scheduler::ScheduleGroup instead of using the default group.

      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: scheduler.amazonaws.com
            Action: 'sts:AssumeRole'
            # Without these conditions the role can be assumed on behalf of any
            # schedule in the service, including one in another account, which
            # is the confused deputy problem.
            Condition:
              StringEquals:
                'aws:SourceAccount': !Ref AWS::AccountId
              ArnEquals:
                'aws:SourceArn': !Sub 'arn:${AWS::Partition}:scheduler:${AWS::Region}:${AWS::AccountId}:schedule-group/${CampaignScheduleGroup}'
Enter fullscreen mode Exit fullscreen mode

The metrics lose granularity, and that is the price. The AWS/Scheduler namespace has a single dimension, ScheduleGroup. There is no per-schedule dimension. A group with thirty schedules produces alarms that say something in the group failed without saying what. The dedicated group per campaign is what keeps the alarm specific, and it is a modeling cost the rule does not charge, since AWS/Events has a RuleName dimension. This template carries five alarms instead of four, because the trigger side reports TargetErrorCount and InvocationDroppedCount as separate signals: the first is the target returning an error on the API call, the second is Scheduler exhausting the retry policy and dropping the invocation. The dead letter queue pair is the same idea as in the rule template, with the names Scheduler uses: InvocationsFailedToBeSentToDeadLetterCount for the denied write, and queue depth for the write that worked.

Deploy and inspection, under the scheduler stack name:

aws cloudformation deploy \
  --template-file iac/eventbridge-scheduler-sns.yaml \
  --stack-name <SCHEDULER_STACK_NAME> \
  --parameter-overrides \
      ScheduleExpression='cron(0 9 ? * SAT *)' \
      ScheduleTimezone=America/Sao_Paulo \
      ProjectTag=weekly-campaigns \
  --capabilities CAPABILITY_IAM \
  --region <REGION> \
  --profile <PROFILE>

aws scheduler get-schedule \
  --name weekly-campaigns-scheduler-saturday-trigger \
  --group-name weekly-campaigns-scheduler-group \
  --region <REGION> \
  --profile <PROFILE>
Enter fullscreen mode Exit fullscreen mode

Expected result: the schedule comes back with ScheduleExpressionTimezone set, the FlexibleTimeWindow block, and the target carrying RoleArn, Input, RetryPolicy and DeadLetterConfig.

This stack gets torn down the same way, under its own name:

aws cloudformation delete-stack --stack-name <SCHEDULER_STACK_NAME> --region <REGION> --profile <PROFILE>
aws cloudformation wait stack-delete-complete --stack-name <SCHEDULER_STACK_NAME> --region <REGION> --profile <PROFILE>
Enter fullscreen mode Exit fullscreen mode

That removes the queue, the schedule and the schedule group that contains it, the execution role and the five alarms, plus the topic when the stack created it. One subscription goes with them, if TestSubscriptionEmail was filled in on that deploy: the stack owns the test subscription even when it does not own the topic, and that is the only subscription of an external topic the teardown touches. The KMS key of this stack carries DeletionPolicy: Retain as well, so it survives here too and needs its own aws kms schedule-key-deletion, by key ID once the alias is gone. Recreating the stack under the same name creates a new key, and the retained ones accumulate per cycle, each one billed as a key.

Trade-offs, quotas and production concerns

Delivering to millions of subscribers

The schedule publishes one message a week. All the scale lives on the SNS side, so the useful conversation is about delivery, not about publishing.

A standard topic accepts 12,500,000 subscriptions, which covers this client's order of magnitude with room to spare. That number does not hold for a FIFO topic, which accepts 100 subscriptions per topic. Anyone considering a FIFO topic for the campaign to gain deduplication meets that limit before anything else.

Publish throughput per account and region varies: 30,000 messages per second in one group, 9,000 in another, 1,500 in a third, and 300 in the remaining supported regions, sa-east-1 included. For a weekly publish that is irrelevant, and I mention it because it is the number everyone looks up first and it is not the number that matters here.

What matters is the delivery rate per protocol, and those differ by orders of magnitude.

SMS has a delivery rate of 20 messages per second, for both promotional and transactional messages. For one million recipients that is 50,000 seconds of delivery, roughly 14 hours. A Saturday 9 AM campaign with a million SMS messages finishes in the early hours of Sunday. That calculation belongs in the conversation before anyone promises a delivery window to the marketing team.

Before volume there is the spend limit. The monthly SMS spending quota starts at USD 1.00 on a new account, which is a test budget. Production requires a Support case describing the use case, the opt-in process and the message template. The SNS pricing page also records that some countries require dedicated origination identities for sending to work at all, and that since November 1, 2024 SMS charges appear on the bill under AWS End User Messaging. None of those three gets resolved in the week of the campaign.

Email has a quota of 10 messages per second per email or email-json subscription, documented as a hard limit that cannot be raised. Watch the unit: per subscription, not per topic. It does not cap the aggregate fanout across millions of distinct subscriptions. The SNS quota documentation does not publish an aggregate email delivery rate, and I am not going to estimate one.

Message size: the default is 262,144 bytes, 256 KiB, and it can go up to 1 MiB through the MaximumMessageSize topic attribute. Each 64 KB chunk published counts as one request for billing.

How long SNS keeps retrying a delivery, by endpoint type

The rates above describe deliveries that work. The deliveries that fail follow a schedule SNS sets per protocol, and the alarm threshold in the template comes out of it.

SNS defines a delivery policy for each delivery protocol, and that policy governs how a server-side failure is retried. The spread between policies is wide. For an AWS managed endpoint, meaning Lambda or SQS, SNS retries up to 100,015 times over 23 days. For a customer managed endpoint, which is where SMTP, SMS and mobile push sit, it is 50 attempts over 6 hours. Same topic, same publish, and the window in which a broken receiver can come back and still get the message differs by orders of magnitude between the two. This campaign lives on the second number: with email and SMS subscriptions, a destination that is down for an afternoon has lost that week's message. HTTP/S is the one protocol where the policy is yours to write, through healthyRetryPolicy at the topic or the subscription level, and even there the total policy retry time cannot be greater than 3,600 seconds, which the documentation gives as a hard limit that cannot be raised.

That is also where the DeliveryFailureThreshold number comes from. numRetries covers the immediate, pre-backoff, backoff and post-backoff phases together, defaults to 3 and accepts up to 100, so an HTTP/S subscription can turn one undeliverable message into as many as 100 counts on NumberOfNotificationsFailed. Set the threshold above the retry count of the noisiest subscription on the topic, or the alarm reports a retry storm as a loss.

Retries are not the default outcome either. The delivery policy governs server-side failure, and for an HTTP/S endpoint the documentation is specific about which responses enter it: 5XX and 429 are retryable, and everything else is treated as a permanent failure. On the other protocols the division the documentation draws is between a server-side error, which the policy covers, and a client-side error, which it does not. A client-side error, which is the category covering a deleted endpoint or an endpoint policy that now refuses SNS, is attempted once and never retried.

The part that decides whether a failed delivery leaves anything behind is what happens after the policy runs out. SNS stops retrying and discards the message, unless a dead-letter queue is attached to the subscription. That queue is a redrive policy on AWS::SNS::Subscription, it attaches per subscription rather than per topic, and for a standard topic it points at a standard SQS queue in the same account and region. The dead letter queue in these templates is not that queue. It belongs to the trigger, and it catches a firing that never became a publish. After a successful publish the trigger side has nothing left to catch, so the recoverable evidence requirement is met for the firing and covers nothing past it: the fourth alarm counts the failed deliveries, and the messages behind that count are already gone.

Subscription dead-letter queues stayed out of both templates, for a reason of ownership rather than cost. The only subscription either stack creates is the test one, and a queue behind a test address records nothing anybody will read. The millions of production subscriptions are not created here and are not in scope for these stacks, so their redrive policies belong to whatever process creates subscriptions, sized per subscription class instead of one queue for the whole topic. Closing the delivery side is real work, and it is work on the subscription, not on the schedule.

At-least-once delivery and duplicate campaigns

EventBridge delivers with at-least-once semantics. Its own troubleshooting documentation has a section for the case of a rule running more than once in response to a single event, and describes it as rare rather than impossible. EventBridge Scheduler lists at-least-once delivery among the characteristics of the service.

For a marketing campaign, at-least-once means a risk of the subscriber getting the same email twice. That was exactly the complaint that opened this work, and replacing the human finger with a schedule lowers the probability without eliminating it.

How it materializes: one occurrence of the schedule produces two target invocations, two sns:Publish calls, two messages on the topic, two deliveries per subscription. Nothing on the path compares the two.

The mitigation depends on how strict the requirement is. The identifier in the payload, which is why executionId exists in the InputTransformer, makes the duplicate detectable by a consumer: retries of the same occurrence carry the same event id, and each weekly firing carries a new one. In the Scheduler template there is a difference worth knowing, and it sent me back to the documentation: aws.scheduler.execution-id is assigned per invocation attempt, not per occurrence. It does not work as a deduplication key. The key that is stable across attempts of the same occurrence is aws.scheduler.scheduled-time, and an aws.scheduler.attempt-number above 1 identifies a retry.

The uncomfortable part: detectable is not the same as deduplicated. With SNS delivering directly to email and SMS, there is no control point between the publish and the subscriber's inbox. To guarantee one delivery per occurrence, the design needs an intermediate step. The topic feeds a queue or a function, that consumer records the occurrence key conditionally, and only then triggers the send. That is a new component to maintain, which is what the minimum-operational-effort requirement was trying to avoid. The choice was made with open eyes: the client accepted a residual risk of rare duplicates in exchange for not having another service on the path, after I showed that the cause of both incidents was human and not infrastructural.

Metrics, alarms and what is opt-in

On the trigger side, in the AWS/Events namespace with dimension RuleName: TriggeredRules and Invocations for the success path, FailedInvocations for permanent failure, RetryInvocationAttempts to see instability that has not become failure yet, ThrottledRules for runs deferred by throttling, InvocationsSentToDlq and InvocationsFailedToBeSentToDlq for the dead letter path. Several of those are published only when non-zero, which changes how you build an alarm and a dashboard on them.

On the delivery side, in the AWS/SNS namespace: NumberOfMessagesPublished, NumberOfNotificationsDelivered, NumberOfNotificationsFailed and NumberOfNotificationsFilteredOut. The failure count behaves differently per protocol, as described above, so comparing that metric between topics with a different protocol mix leads to the wrong conclusion. SNS metrics are collected at 1-minute intervals, a topic counts as active for up to six hours after the last API call on it, and there is no charge for these metrics.

What a healthy week looks like on those metrics is worth writing down before building a dashboard, because the numbers are small and an empty graph is the expected state most of the time. Per Saturday, over the 5 minute period that contains the firing:

Namespace Metric Dimension Healthy week
AWS/Events TriggeredRules RuleName = weekly-campaigns-rule-saturday-trigger one data point, Sum of 1
AWS/Events Invocations same one data point, Sum of 1
AWS/Events FailedInvocations same no data point
AWS/Events InvocationsSentToDlq same no data point
AWS/SQS ApproximateNumberOfMessagesVisible QueueName = weekly-campaigns-rule-dlq Maximum of 0
AWS/SNS NumberOfMessagesPublished TopicName = weekly-campaign-subscribers one data point, Sum of 1
AWS/SNS NumberOfNotificationsDelivered same Sum in the order of the confirmed subscriptions
AWS/SNS NumberOfNotificationsFailed same no data point, or a Sum below the alarm threshold

Those are the values this design predicts, not readings I took: no stack was deployed for this post. The useful part is the shape. A single-digit Sum on the trigger side and a seven-digit Sum on the delivery side belong to the same firing, and a Sum of 2 on Invocations inside one Saturday period is the duplicate the at-least-once section is about.

A caveat that applies to both sides: the EventBridge documentation states that metric delivery to CloudWatch is best effort, and that a data point may be delayed or not delivered. Metrics serve trend and alarm, not a complete accounting of operations.

SNS delivery status logging is opt-in, requires an IAM role, and is configured per protocol. It carries a limit that matters for this case: the documentation lists Amazon Data Firehose, Amazon SQS, AWS Lambda, HTTPS and platform application endpoint. Email is not on the list. For a topic whose subscriptions are mostly email, the per-subscription trace that exists is the aggregate metric, not a per-delivery log.

On Scheduler the metrics live in the AWS/Scheduler namespace: InvocationAttemptCount, TargetErrorCount, TargetErrorThrottledCount, InvocationThrottleCount, InvocationDroppedCount, and, when a dead letter queue is configured, InvocationsSentToDeadLetterCount and InvocationsFailedToBeSentToDeadLetterCount. They are kept for 15 months, which covers more than a year of campaigns.

What dominates the bill

Prices and quotas consulted on October 8, 2026, with the URLs in the references.

Scheduling costs close to nothing in both designs, for different reasons. The EventBridge pricing page does not list a charge for scheduled rule invocations on the default event bus, and event delivery to a service in the same account appears as USD 0.00 per million in the calculation examples. Scheduler has a free tier of 14 million invocations per month and charges USD 1.00 per million above that. A weekly campaign produces 52 invocations a year, five orders of magnitude below the free tier.

The EC2 instance is the only alternative with a relevant fixed cost, and the comparison does not need a price table to be settled: a resource billed by uptime, available 168 hours a week, to run a task of less than a second once a week. Any instance price multiplied by that utilization rate loses to a schedule that is not billed per invocation.

What dominates the bill is SNS delivery, not scheduling. The pricing structure of a standard topic has two components, API requests and deliveries, and the delivery cost depends on the protocol. Each 64 KB chunk published counts as a request and, except for SMS, each 64 KB chunk delivered counts as a delivery. Data leaving SNS for SQS or Lambda is billed as internet data transfer, regardless of the transfer happening inside the same region. SMS is priced per destination and varies by country, and it tends to be the line item that dominates the bill when it is in the mix. I am not reproducing unit prices, because they are not available in verifiable form on the pricing page without JavaScript rendering, and a wrong price in a post is worse than an absent one.

Least privilege, encryption and the account boundary

Who can publish to this topic is the whole security question, because a single sns:Publish call reaches every confirmed subscription with no approval step in between. There is no staging, no preview and no recall. The blast radius of that permission is the subscriber list.

The two designs put the permission in different places, and that changes what you audit. In the rule template the publish path lives in the topic resource policy, pinned to the EventBridge service principal, to the ARN of this rule, and to this account, and nothing else in that document grants anything. To answer who can publish, you read one statement. In the Scheduler template the topic has no resource policy at all: the publish is done by the execution role, pinned by its trust policy to this account and to this schedule group, with sns:Publish scoped to one topic ARN. To answer the same question there, you read one role. Both are narrow, and the operational difference is which artifact a reviewer has to open. What neither of them narrows is the identity policy side: any principal in the account whose IAM policy allows sns:Publish on this topic can publish, and finding those principals means reading identity policies, not this template.

Encryption at rest with a customer managed key is in the path because the publisher is a service principal, which is the requirement from the SNS documentation rather than a choice. It protects the message while SNS holds it and it gives the key policy as a second place where the publish can be denied. It stops at the point of delivery. The copy that lands in an inbox and the copy that lands on a phone are readable at the destination, and that is the nature of email and SMS, not a gap in the configuration. For a campaign the content is public by design, so this is not a finding here. For a notification carrying anything about an individual subscriber, the protocol choice is the control, and the encryption of the topic says nothing about it. The absence of delivery status logging for email, covered above, belongs to the same conversation: for most of these subscriptions there is no per-delivery record to audit.

The account boundary is where this design would need real work. Today the condition on the publish statement is !GetAtt CampaignRule.Arn, a reference resolved by CloudFormation inside the same stack. A rule in another account has an ARN this template cannot resolve, so the pin becomes a value you maintain by hand, and every account added to the campaign is a change to the topic policy rather than a change in the other account. The encrypted topic adds a second edit in the same move, because the key policy also has to admit the external caller, and the key policy is not written by the stack that owns the rule. On the Scheduler side the problem shifts rather than disappears: the execution role lives with the schedule, so a schedule in another account means a role in that account, and authorizing it is again a statement in this topic policy. The point to carry out of this section is that the single narrow statement that holds today holds because everything sits in one account, and that going cross-account is not a parameter.

Where this design stops serving

The design solves reliable scheduling for one message that is the same for everybody. It has no notion of a person.

When the requirement grows to include behavioral segmentation, content templates with per-recipient variables, granular opt-out by campaign type, or open and click reporting, none of that exists in SNS. A topic has subscriptions, and a subscription has a filter policy by attribute or by payload. You can separate audiences with subscription filters up to a point, and past "who opened last week's campaign" you cannot.

That is where the conversation used to turn to Amazon Pinpoint, and that recommendation no longer holds. AWS set the end of support for Pinpoint on October 30, 2026, two weeks after the date of this post. The service stopped accepting new customers on May 20, 2025, and after the cut-off date the console and the engagement features (endpoints, segments, campaigns, journeys and analytics) become inaccessible. The SMS, voice, push, OTP and phone number validation APIs are not affected: they were renamed to AWS End User Messaging in the third quarter of 2024 and continue to operate.

The official migration guide points to two paths. For engagement with segmentation, content templates and campaign orchestration, the proactive engagement solutions of Amazon Connect, including outbound campaigns and Customer Profiles. For email sending, Amazon Simple Email Service, with the Virtual Deliverability Manager replacing the deliverability dashboard. For mobile event collection and analytics, Amazon Kinesis. The guide also says when to stay on End User Messaging with SES instead of moving to Connect: a throughput requirement above the outbound campaign limits, and no need for a human agent or contact orchestration.

The honest limit of this post: it delivers the trigger, and the guarantee that the trigger leaves a trace. Lifecycle marketing is a different problem, with a different budget.

What the client ended up with

The Saturday campaign now fires on its own, at 09:00 in Sao Paulo rather than whenever someone got to it on Friday night, described in versioned YAML instead of a shared document with the topic ARN in bold. Over the following three months there was no duplicate firing, no missed firing and no firing at the wrong hour, and the dead letter queue stayed empty. The Friday night procedure stopped existing.

References

Top comments (0)