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-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 attaches it as the AWS.SNS.SMS.SenderID attribute 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:

  1. Per-IP rate limit (600/min — caps replay-window abuse).
  2. verifySnsSignature — SignatureVersion 2 only (SHA-1 rejected), sns.*.amazonaws.com cert host, 5-min replay window on Timestamp, 5s cert fetch timeout.
  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:
    • SUCCESS / DELIVEREDmessage.delivered
    • BLOCKED / CARRIER_BLOCKED / TTL_EXPIRED / EXPIRED / INVALID_NUMBER / OPTED_OUTmessage.bounced + auto-suppression (source: "sns", reason: "bounce")
    • everything else → message.failed
  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 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.