AWS SNS (SMS)
SNS used for SMS publish + the inbound transport for SES event notifications (see aws-ses.md). The SMS delivery + STOP receipt path is wired through SNS as well.
Client
src/lib/providers/sms.ts:
import { SNSClient, PublishCommand } from "@aws-sdk/client-sns";
const sns = new SNSClient({
region: process.env.AWS_REGION || "us-east-1",
credentials: { accessKeyId: ..., secretAccessKey: ... },
requestHandler: {
requestTimeout: 8_000, // SES/SNS p99 ~1-2s
connectionTimeout: 3_000,
},
});
Bounded request timeout so a stalled SNS call can't hold a batch-concurrency slot for the whole function window.
Publish
sendSms():
new PublishCommand({
PhoneNumber: params.to, // E.164 (or omitted for sender-ID-only sends)
Message: params.body,
MessageAttributes: {
"AWS.SNS.SMS.SMSType": { DataType: "String", StringValue: "Transactional" },
// SenderID attribute is only attached when `from` is non-E.164. For US/CA
// E.164 numbers, AWS rejects the attribute outright; for alphanumeric
// originators (UK/AU/EU) it's required.
...(E164.test(params.from)
? {}
: { "AWS.SNS.SMS.SenderID": { DataType: "String", StringValue: params.from } }),
...(params.mediaUrls?.length
? { "AWS.MM.SMS.MediaUrls": { DataType: "String", StringValue: params.mediaUrls.join(",") } }
: {}),
},
});
Returns { providerMessageId, providerResponse }.
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)
SMSType
Direct Publish calls hardcode 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 attaches it as theAWS.SNS.SMS.SenderIDattribute at publish time. No carrier registration step exists for these countries via AWS End User Messaging; carriers may still drop unrecognized brands. - IN (DLT) — not yet supported. India requires Telco DLT registration, a different scheme from AWS 10DLC.
Delivery callbacks
Wired. Configure SNS SMS delivery logging to publish to an SNS topic that fans out to /api/webhooks/sns-sms. The handler:
- Per-IP rate limit (600/min — caps replay-window abuse).
verifySnsSignature— SignatureVersion 2 only (SHA-1 rejected),sns.*.amazonaws.comcert host, 5-min replay window onTimestamp, 5s cert fetch timeout.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:
SUCCESS/DELIVERED→message.deliveredBLOCKED/CARRIER_BLOCKED/TTL_EXPIRED/EXPIRED/INVALID_NUMBER/OPTED_OUT→message.bounced+ auto-suppression (source: "sns",reason: "bounce")- everything else →
message.failed
deliveredAtprefers the provider's own timestamp; falls back tonow()only when AWS doesn't supply one.
Spurious notifications (no matching providerMessageId) return 200 without running UPDATEs.
Inbound replies (STOP / HELP)
Same /api/webhooks/sns-sms handler. STOP keywords (STOP, STOPALL, UNSUBSCRIBE, CANCEL, END, QUIT) are matched as whole words anywhere in the inbound body (CTIA-compliant).
- 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.