AWS SNS (SMS)

SNS is the inbound transport for SES + SMS event notifications (see aws-ses.md) — the topics the webhook routes subscribe to. Outbound SMS no longer publishes through SNS: both text and MMS go out through AWS End User Messaging (Pinpoint SMS v2), which is what lets a send name a configuration set and therefore produce delivery-status events at all.

Client

src/lib/providers/sms.ts:

import { PinpointSMSVoiceV2Client, SendTextMessageCommand, SendMediaMessageCommand } from "@aws-sdk/client-pinpoint-sms-voice-v2";
const client = new PinpointSMSVoiceV2Client({
  region: process.env.AWS_REGION || "us-east-1",
  credentials: { accessKeyId: ..., secretAccessKey: ... },
  requestHandler: {
    requestTimeout: 8_000,      // p99 ~1-2s
    connectionTimeout: 3_000,
  },
});

Bounded request timeout so a stalled call can't hold a batch-concurrency slot for the whole function window.

Send

sendSms() (text path):

new SendTextMessageCommand({
  DestinationPhoneNumber: params.to,   // E.164
  MessageBody: params.body,
  // One field for every originator kind: E.164 number, pool id, or
  // alphanumeric sender ID — the SNS-era OriginationNumber/SenderID
  // attribute split collapses into OriginationIdentity.
  OriginationIdentity: params.from,
  MessageType: "TRANSACTIONAL",
  // Optional: unset means the send still goes out, it just produces no
  // delivery events (see the event-destination section below).
  ConfigurationSetName: process.env.SMS_CONFIGURATION_SET || undefined,
  DestinationCountryParameters: ...,   // IN_ENTITY_ID / IN_TEMPLATE_ID for DLT
});

MMS rides SendMediaMessageCommand with the same ConfigurationSetName handling. Returns { providerMessageId, providerResponse }; the MessageId is what the EUM event destination echoes back as messageId, which is how the webhook correlates status events to messages.provider_message_id.

The runtime policy grants sms-voice:SendTextMessage (scripts/sendoka-app-runtime-policy.json) — the grant must be applied to the sendoka-app runtime identity in AWS before this code deploys, or every live text send 403s.

Note which identity: this is a send permission, so it belongs on the sendoka-app runtime user under its existing SendokaAppRuntime inline policy — not on SendokaMessagingProvisioner, which exists to perform provisioning actions and carries no iam:* permissions at all, so it cannot apply this grant. The "do it from the provisioner role" rule elsewhere in these docs is about where provisioning permissions live; it does not apply to a runtime send permission.

Applying it is the same operation as recovering wiped runtime permissions below — list the inline policies first, then put the complete scripts/sendoka-app-runtime-policy.json back under SendokaAppRuntime. Follow that procedure rather than hand-writing a partial document: put-user-policy replaces a named policy wholesale instead of merging, which is exactly how a partial document under an existing name wiped the runtime SES permissions and took production email down on 2026-08-18.

Phone number format

Enforced by sendSmsSchema regex: ^\+[1-9]\d{1,14}$ (E.164) for the recipient. The from accepts either:

  • An E.164 number that's registered in phone_numbers (kind=number)
  • An alphanumeric sender ID that's registered in phone_numbers (kind=alphanumeric, 1–11 chars)

MessageType

Direct sends hardcode MessageType: "TRANSACTIONAL". Marketing-class traffic should route through a 10DLC campaign provisioned with MessageType: PROMOTIONAL — see features/sms-registration.md for the brand → campaign → number flow.

Origination identity

  • US / CA E.164 — 10DLC long codes + toll-free numbers go through AWS End User Messaging registration (/api/v1/phone-numbers with kind: "number"). 10DLC requires a verified brand + verified campaign before the number is usable.
  • Alphanumeric (UK / AU / EU)kind: "alphanumeric" skips AWS provisioning entirely. Sendoka records the sender ID locally and passes it as the OriginationIdentity at send time. End User Messaging errors (rather than silently falling back) when the sender ID isn't registered/available for the sending region; carriers may still drop unrecognized brands.
  • IN (DLT) — not yet supported. India requires Telco DLT registration, a different scheme from AWS 10DLC.

Provisioning the topics

The handlers below have never received a production notification. The first provisioning run created the topics but left them at SignatureVersion 1, so every subscription stalled at PendingConfirmation (see below) and nothing was ever delivered. Until that is finished, delivery statuses never advance past sent, SES bounces and complaints are never auto-suppressed, SMS STOP is never recorded, and inbound is dropped. Tracked as issue #72.

scripts/setup-sns.sh creates the three topics per region and subscribes each webhook route. It is idempotent, so re-running is how you add a region.

./scripts/setup-sns.sh --base-url https://www.sendoka.com --env prod
./scripts/setup-sns.sh --base-url https://www.sendoka.com --env prod --dry-run   # show only

Things the script does NOT do, each of which leaves the pipeline silent:

  1. Set SNS_TOPIC_ARN_ALLOWLIST. It prints the value; you set it and deploy in the same change as the topics. isAllowedTopic fails closed in production, so topics without the allowlist means every notification is 403'd — the same silence you started with, harder to diagnose.

  2. Point the producers at the topics. Three different systems, and one of them is not the obvious one:

    • SES events — now handled. The script creates the --config-set configuration set per region with an SNS event destination on the SES topic; set SES_CONFIGURATION_SET and new domain identities are pointed at it on creation (providers/domain.ts). Dedicated IP pools create their OWN config set at runtime and a pool-bound send pins it, overriding the identity default — providers/ses-ip-pool.ts gives that set the same destination, derived per-region from SES_EVENT_TOPIC_NAME + AWS_ACCOUNT_ID. Both attachments are best-effort: a missing config set or topic is logged, never fatal, because losing events must not start failing domain creation or pool provisioning. Identities that already exist are not repointed — do those by hand.

    • SMS status + inboundAWS End User Messaging (Pinpoint SMS v2) event destinations, not SNS SMS delivery logging. That distinction matters: SNS SMS delivery logging writes to CloudWatch Logs and never reaches a topic, so wiring it produces nothing and looks configured. The handler parses originationNumber / messageBody, which is the EUM payload shape. Two-way must also be enabled on each origination number, pointed at the SMS topic.

      Inbound, MMS and text status are all reachable this way now. An EUM event destination hangs off an EUM configuration set, and it only fires for sends that resolve to it. Both send paths — SendTextMessageCommand and SendMediaMessageCommand (providers/sms.ts) — name SMS_CONFIGURATION_SET when it is set, so SMS status works as soon as the EUM configuration set + event destination exist and the sms-voice:SendTextMessage grant (already in scripts/sendoka-app-runtime-policy.json) is applied to the runtime identity in AWS. An unset SMS_CONFIGURATION_SET still sends — it just produces no events, so wiring the event destination without setting the env var (or vice versa) is a silent misconfiguration.

    • Inbound email — an SES receipt rule with an SNS action.

  3. Grant the producers publish rights ON the topics. The script now does this. It is kept here because the reasoning still governs the document it writes: an identity policy is only half of SNS authorisation. A newly created topic's access policy admits only the owning account's own IAM principals, so ses.amazonaws.com (event destinations, receipt rules) and sms-voice.amazonaws.com (End User Messaging event destinations) are denied at publish until each topic's Policy attribute names them, conditioned on aws:SourceAccount — and the symptom is identical to having no topics at all. The provisioning policy grants sns:SetTopicAttributes but not sns:AddPermission, so the document is written out in full and applied with set-topic-attributes — which replaces rather than merges, so the script also reproduces the default owner statement. Run with --dry-run to read the exact document before applying it.

  4. Confirm the subscriptions. The handlers auto-confirm, but only for a SubscribeURL passing the SNS host regex, so the route must be deployed and reachable when Subscribe runs. The script prints the command to check for PendingConfirmation.

SignatureVersion 2 is required

Set SignatureVersion=2 on every topic. SNS topics default to version 1 (SHA-1), and lib/api/sns-verify.ts refuses anything that is not "2" because SHA-1 is collision-broken.

Get this wrong and the symptom is maximally confusing: topics exist, the allowlist is set, the routes are deployed — and every subscription sits at PendingConfirmation forever. (Do not try to confirm reachability with a browser: all three routes export POST only, so a GET returns 405 whether or not the deploy is healthy.) The SubscriptionConfirmation message is itself signed, so it fails verification with a 403 and the subscription can never confirm. Nothing in the SNS console says why.

scripts/setup-sns.sh passes --attributes SignatureVersion=2 to create-topic so a fresh topic is never even briefly at v1, then re-applies it with set-topic-attributes (because CreateTopic returns an existing topic's ARN without applying attributes — that second call is what repairs topics already created at v1), then reads it back and refuses to subscribe unless it is 2. To fix a topic by hand:

aws sns set-topic-attributes --region <region> --topic-arn <arn> \
  --attribute-name SignatureVersion --attribute-value 2

Then re-run the script for that region. Each Subscribe call sends a fresh SubscriptionConfirmation, so re-subscribing is what recovers a subscription that never confirmed. A pending subscription cannot be removed with Unsubscribe — its SubscriptionArn is the literal string pending confirmation, so there is no ARN to pass — and its token expires on its own after two days.

RawMessageDelivery must stay false: the handlers verify the SNS envelope signature, and raw delivery strips the envelope.

Credentials

Two principals, and the separation is not bureaucracy — collapsing it took down production email on 2026-08-18.

principal holds when it is used
sendoka-app (IAM user) SendokaAppRuntimescripts/sendoka-app-runtime-policy.json every send, continuously
SendokaMessagingProvisioner (IAM role) SendokaMessagingProvisioningscripts/messaging-provisioning-policy.json assumed by a human, for wiring

The runtime credential cannot create topics, subscriptions or event destinations, by design — it returns AuthorizationError on sns:CreateTopic and sns:ListTopics, and AccessDeniedException (the SESv2 shape, not the SNS one) on ses:ListConfigurationSets. That is correct and should stay true.

The two policies do intersect on four actions, deliberately — ses:CreateConfigurationSet, ses:GetEmailIdentity, ses:ListEmailIdentities, sms-voice:DescribePhoneNumbers. ses:CreateConfigurationSet is the load-bearing one: providers/ses-ip-pool.ts creates a configuration set per dedicated IP pool at request time. Do not "restore the separation" by deleting it from the runtime policy — pool creation then fails with AccessDeniedException, and the recovery command below would make that permanent.

Why a role, not a second user policy

A role cannot overwrite a runtime credential. put-user-policy REPLACES the policy of that name rather than merging, so attaching provisioning access to sendoka-app under a name its runtime policy already used wiped ses:SendEmail and stopped every outbound email — signup codes, password resets, OTPs and all customer sends. AWS gave no warning; the app logged email_code.send_failed with an AccessDeniedException and users saw "We couldn't send that email."

A role's permissions live on the role. There is no sequence of provisioning changes that can strip a runtime credential, because the two never share an object.

Assumed credentials expire. "Detach it afterwards" stops being a step anyone has to remember. A provisioning policy parked on a long-lived user is one leaked key away from someone deleting the topics your delivery events depend on.

Create the role

Set TRUSTED to the specific human or SSO principals that should be able to assume the role — not account root. This role can sns:DeleteTopic and sns:Unsubscribe on *; trusting root means every principal in the account that IAM also permits, which includes any CI role holding a wildcard sts:AssumeRole. The MFA condition is not optional either.

ACCOUNT="$(aws sts get-caller-identity --query Account --output text)"
TRUSTED="arn:aws:iam::${ACCOUNT}:user/your-admin"   # or an SSO role ARN

aws iam create-role --role-name SendokaMessagingProvisioner \
  --max-session-duration 3600 \
  --description "Wires SNS/SES/EUM. Assumed for provisioning; never attached to a runtime user." \
  --assume-role-policy-document "{
    \"Version\": \"2012-10-17\",
    \"Statement\": [{
      \"Effect\": \"Allow\",
      \"Principal\": { \"AWS\": \"${TRUSTED}\" },
      \"Action\": \"sts:AssumeRole\",
      \"Condition\": { \"Bool\": { \"aws:MultiFactorAuthPresent\": \"true\" } }
    }]
  }"

aws iam put-role-policy --role-name SendokaMessagingProvisioner \
  --policy-name SendokaMessagingProvisioning \
  --policy-document file://scripts/messaging-provisioning-policy.json

Assume it

The tidiest way is a profile — put this in ~/.aws/config once and the CLI handles the assume, the refresh and the expiry for you:

[profile sendoka-provisioning]
role_arn       = arn:aws:iam::<account-id>:role/SendokaMessagingProvisioner
source_profile = <your-normal-profile>
region         = ap-southeast-2
AWS_PROFILE=sendoka-provisioning aws sts get-caller-identity   # confirm you are the ROLE
AWS_PROFILE=sendoka-provisioning ./scripts/setup-sns.sh --base-url https://www.sendoka.com --env prod

Scoping it per-command like that, rather than exporting it, means a stray later command cannot run with provisioning rights by accident.

If you would rather assume it explicitly:

Capture into a variable first and bail on failure. read ... <<<"$(cmd)" returns 0 even when cmd fails — the here-string always supplies a newline — so the naive one-liner exports three empty strings, silently falls through to your normal long-lived identity, and provisions as whatever that is. If that identity is sendoka-app, you have just recreated the collapsed-principal condition this whole section exists to prevent.

ACCOUNT="$(aws sts get-caller-identity --query Account --output text)"

CREDS="$(aws sts assume-role \
  --role-arn "arn:aws:iam::${ACCOUNT}:role/SendokaMessagingProvisioner" \
  --role-session-name sendoka-wiring \
  --query 'Credentials.[AccessKeyId,SecretAccessKey,SessionToken]' \
  --output text)" || { echo "assume-role failed" >&2; return 1 2>/dev/null || exit 1; }

read -r AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN <<<"$CREDS"
export AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN

aws sts get-caller-identity   # must print the ROLE, not your user

Credentials expire in an hour. Open a new shell to drop them — unset of the three variables also works, but a new shell is harder to get wrong.

Recovery, if runtime permissions are ever wiped

Symptom: email_code.send_failed with AccessDeniedException, users see "We couldn't send that email." List the inline policies firstput-user-policy adds rather than replaces across different names, so putting SendokaAppRuntime back while a wrongly-named provisioning policy is still attached restores sending but leaves provisioning rights parked on a long-lived key:

aws iam list-user-policies --user-name sendoka-app

aws iam put-user-policy --user-name sendoka-app \
  --policy-name SendokaAppRuntime \
  --policy-document file://scripts/sendoka-app-runtime-policy.json

# Anything else the list returned is not supposed to be there:
# aws iam delete-user-policy --user-name sendoka-app --policy-name <other>

scripts/sendoka-app-runtime-policy.json covers every action the runtime makes — SES send, domain verification, dedicated IP pools, SNS publish, and End User Messaging registration and sending (sms-voice:SendTextMessage / SendMediaMessage — text SMS moved onto the former, so without it every live text send 403s). It is derived from the AWS SDK commands the codebase imports; regenerate the mapping with grep -rhoE '\b[A-Z][A-Za-z0-9]*Command\b' src --include='*.ts' | sort -u.

If you must use a user instead

Prefer the role. If you cannot, use a policy name that shares no stem with the runtime one — SendokaAppRuntime versus SendokaMessagingProvisioning. Two confusable names on one principal is the whole failure mode, and a provisioning policy on a long-lived user is one leaked key away from someone deleting the topics your delivery events depend on.

What the provisioning policy grants

Named for what it grants, not for SNS: most of its statements — and most of its actions — are SES and End User Messaging, not SNS. A name that undersells the scope is how someone later attaches it believing it only touches topics.

It carries no comments — IAM rejects any element it does not recognise, so a Comment key makes the put call fail outright.

Sid Why
TopicsAndSubscriptions Create the topics and subscribe the routes. Unsubscribe and DeleteTopic are for CLEANUP, not creation — a mis-subscribed endpoint cannot be removed without them, and duplicate subscriptions deliver every event twice, which double-fires customer webhooks.
SesEventDestinations Delivery, bounce and complaint events reach SNS through a configuration-set event destination. Dedicated IP pools create their own config sets at runtime (providers/ses-ip-pool.ts), so every set needs one — not just the default. PutEmailIdentityConfigurationSetAttributes is what makes it apply to ordinary sends, which otherwise pass no config set at all.
InboundEmailReceiptRules Inbound email arrives via an SES receipt rule with an SNS action. SES v1 APIs — receipt rules have no v2 equivalent.
EndUserMessagingEventDestinations SMS status and inbound come from End User Messaging, not SNS SMS delivery logging.

Resource is * because several of these actions have no resource type and only accept *sns:ListTopics, sns:ListSubscriptions, ses:ListConfigurationSets, ses:ListEmailIdentities, ses:ListReceiptRuleSets, sms-voice:DescribeAccountAttributes. Narrowing the whole document to the printed topic ARNs breaks them, including the list-subscriptions check the script prints at the end and the ses:ListConfigurationSets probe used above as the runtime-vs-provisioning canary. (IAM Resource entries are ARN patterns evaluated per request, not references to existing objects, so the create-before-it-exists concern does not arise — the addressable statements can be scoped to arn:aws:sns:*:<account>:sendoka-* whenever you want to.)

The runtime policy carries the mirror-image restriction: sns:Publish has to stay on * because publishing an SMS targets a phone number and names no topic, so an explicit Deny on sendoka-* topic ARNs is what stops the long-lived runtime key from injecting into the notification pipeline. Without it a leaked runtime key could publish a forged event into an allowlisted topic — SNS would sign it with a genuine cert and stamp the allowlisted TopicArn, so both verifySnsSignature and isAllowedTopic would pass it straight through to the handlers.

Delivery callbacks

Wired in code, dark in production (see above). The producer is an AWS End User Messaging event destination pointed at the SMS topic — not SNS SMS delivery logging, which writes to CloudWatch Logs and never reaches a topic. The handler:

  1. Per-IP rate limit (600/min — caps replay-window abuse).
  2. verifySnsSignature — SignatureVersion 2 only (SHA-1 rejected), sns.*.amazonaws.com cert host, one-hour replay window on Timestamp, 5s cert fetch timeout. The window is sized to the SNS retry policy, not to freshness: SNS re-delivers the identical signed envelope with its original Timestamp, and these handlers answer 503/502 specifically to ask for that. At five minutes the two designs cancelled out — every retry past the window 403'd, silently losing whatever it carried. Replays are stopped by handler idempotency instead (terminal statuses are never re-updated; inbound rows dedupe on (org_id, provider_message_id), falling back to the SNS MessageId so the key is never NULL).
  3. SNS_TOPIC_ARN_ALLOWLIST (comma-separated env var) — rejects notifications from un-allowlisted topics. Required in production: AWS signature alone proves "some AWS account signed this," not "Sendoka owns this topic."
  4. SubscriptionConfirmationSubscribeURL must pass the same SNS host regex before the auto-fetch (defends against attacker-supplied private-IP SubscribeURL).
  5. Status mapping. The outcome token is normalized across both AWS shapes — classic SNS delivery status reports status, End User Messaging reports eventType / messageStatus:
    • SUCCESS / DELIVERED / TEXT_DELIVERED / TEXT_SUCCESSFUL / MMS_DELIVERED / MMS_SUCCESSFULmessage.delivered
    • BLOCKED / CARRIER_BLOCKED / INVALID_NUMBER / INVALID / OPTED_OUT / SPAM, plus the TEXT_* and MMS_* twins of each → message.bounced + auto-suppression (source: "sns", reason: "bounce")
    • FAILURE, the TTL/expiry and unreachable tokens, *_INVALID_MESSAGE and the MMS_FILE_* faults → message.failed, with no suppression. TTL/unreachable mean the handset was off or out of coverage, not that the number is permanently blocked — suppressing on them once blocked a valid recipient for good. The MMS_FILE_* tokens (inaccessible, unsupported type, size exceeded) are a sender-side fault: suppressing the recipient over our own attachment would 422 every later send to them. Do not add either group to HARD_BLOCK_STATUSES.
    • In-flight tokens (TEXT_PENDING, TEXT_QUEUED, TEXT_SENT, and the MMS_* twins) return ignored_non_terminal and touch nothing. They previously landed in failed, which PROVIDER_UPDATABLE_STATUSES treats as terminal — so the correct terminal event that followed could never repair the row. This is the single largest reason a wired MMS pipeline would still have shown wrong statuses on day one.
    • Anything else logs webhook.sns_sms.unmapped_status and leaves the row alone. An unmapped token is a gap in the table, not a delivery outcome.
    • Classic SNS collapses every permanent block into a generic FAILURE, so the free-text delivery.providerResponse is also scanned for opt-out/block wording — otherwise carrier opt-outs would never suppress (TCPA/CTIA exposure).
  6. deliveredAt prefers the provider's own timestamp; falls back to now() only when AWS doesn't supply one.

Spurious notifications (no matching providerMessageId) return 503, not 200, and run no UPDATEs. The provider_message_id → message mapping is written after the SNS publish returns, so a fast DLR can arrive before that row commits; SNS treats 2xx as delivered, so 200-acking dropped those permanently. A retryable status lets SNS re-deliver and the race resolve. Genuinely-unknown ids exhaust the retry policy and drop.

Inbound replies (STOP / HELP)

Same /api/webhooks/sns-sms handler. STOP opts out as the first word of the body (STOP, stop., stop texting me); STOPALL, UNSUBSCRIBE, CANCEL, END and QUIT opt out only as the whole message, so Can I cancel my 3pm? is a conversation and reaches inbound.sms. Full rule in api/sms.md.

  • STOP → adds a suppression scoped to the tenant of the matched prior send (via (fromNumber, toNumber) lookup within a 90-day window). Without the tenant scope, one tenant's STOP would block every other tenant sharing the from-number.
  • HELP / INFO → logged for ops visibility; AWS End User Messaging emits the registered help text out-of-band.

Account spend limits

SNS enforces an account-level SMS spend cap — raise via AWS Service Quotas. Not surfaced in-app; 429s from /v1/sms are Sendoka's quota, not AWS's.