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-numberswithkind: "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 theOriginationIdentityat 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:
Set
SNS_TOPIC_ARN_ALLOWLIST. It prints the value; you set it and deploy in the same change as the topics.isAllowedTopicfails closed in production, so topics without the allowlist means every notification is 403'd — the same silence you started with, harder to diagnose.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-setconfiguration set per region with an SNS event destination on the SES topic; setSES_CONFIGURATION_SETand 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.tsgives that set the same destination, derived per-region fromSES_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 + inbound — AWS 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 —
SendTextMessageCommandandSendMediaMessageCommand(providers/sms.ts) — nameSMS_CONFIGURATION_SETwhen it is set, so SMS status works as soon as the EUM configuration set + event destination exist and thesms-voice:SendTextMessagegrant (already inscripts/sendoka-app-runtime-policy.json) is applied to the runtime identity in AWS. An unsetSMS_CONFIGURATION_SETstill 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.
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, soses.amazonaws.com(event destinations, receipt rules) andsms-voice.amazonaws.com(End User Messaging event destinations) are denied at publish until each topic'sPolicyattribute names them, conditioned onaws:SourceAccount— and the symptom is identical to having no topics at all. The provisioning policy grantssns:SetTopicAttributesbut notsns:AddPermission, so the document is written out in full and applied withset-topic-attributes— which replaces rather than merges, so the script also reproduces the default owner statement. Run with--dry-runto read the exact document before applying it.Confirm the subscriptions. The handlers auto-confirm, but only for a
SubscribeURLpassing the SNS host regex, so the route must be deployed and reachable whenSubscriberuns. The script prints the command to check forPendingConfirmation.
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) |
SendokaAppRuntime — scripts/sendoka-app-runtime-policy.json |
every send, continuously |
SendokaMessagingProvisioner (IAM role) |
SendokaMessagingProvisioning — scripts/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 first — put-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:
- Per-IP rate limit (600/min — caps replay-window abuse).
verifySnsSignature— SignatureVersion 2 only (SHA-1 rejected),sns.*.amazonaws.comcert host, one-hour replay window onTimestamp, 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 originalTimestamp, 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 SNSMessageIdso the key is never NULL).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."SubscriptionConfirmation—SubscribeURLmust pass the same SNS host regex before the auto-fetch (defends against attacker-supplied private-IPSubscribeURL).- Status mapping. The outcome token is normalized across both AWS shapes — classic SNS delivery status reports
status, End User Messaging reportseventType/messageStatus:SUCCESS/DELIVERED/TEXT_DELIVERED/TEXT_SUCCESSFUL/MMS_DELIVERED/MMS_SUCCESSFUL→message.deliveredBLOCKED/CARRIER_BLOCKED/INVALID_NUMBER/INVALID/OPTED_OUT/SPAM, plus theTEXT_*andMMS_*twins of each →message.bounced+ auto-suppression (source: "sns",reason: "bounce")FAILURE, the TTL/expiry and unreachable tokens,*_INVALID_MESSAGEand theMMS_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. TheMMS_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 toHARD_BLOCK_STATUSES.- In-flight tokens (
TEXT_PENDING,TEXT_QUEUED,TEXT_SENT, and theMMS_*twins) returnignored_non_terminaland touch nothing. They previously landed infailed, whichPROVIDER_UPDATABLE_STATUSEStreats 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_statusand 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-textdelivery.providerResponseis also scanned for opt-out/block wording — otherwise carrier opt-outs would never suppress (TCPA/CTIA exposure).
deliveredAtprefers the provider's own timestamp; falls back tonow()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.