Runbook: sending through AWS End User Messaging SMS
aws_sms is a second SmsProvider beside orange_cm and mtn_cm. It sends through AWS End User Messaging SMS (the pinpoint-sms-voice-v2 API, SendTextMessage) and gets its delivery receipts back through Amazon SNS. It exists so the system can carry real traffic while the Orange and MTN contracts are pending, and it stays useful afterwards as a second route for the same numbers. The crate’s own description is in backends/crates/sms-provider-aws-sms/src/lib.md; the design-doc summary is §6.2. This page is the operator’s half: what to create in AWS, what to set in vsms, how to check it, and how to move traffic between providers.
Important
Read the data-residency section first. Sending through AWS means every recipient number and every message body leaves Cameroon. That may need an authorisation vsms cannot grant you, and it is not a detail to sort out after go-live.
Data residency: a blocker, not a footnote
§13 of the design doc (item 1; also AGENTS.md, "Open questions") records that Cameroon’s Law No. 2024/017 requires prior authorisation for cross-border transfers of personal data, and that "legitimate interest" is not a lawful basis for one. A recipient’s phone number is personal data, and an OTP or notification body usually is as well. Calling SendTextMessage hands both to AWS in whichever Region you configure, which is outside Cameroon in every case: AWS has no Cameroon Region, and the nearest one, af-south-1, is in South Africa.
vsms does not make this question go away and cannot answer it:
providers[code=aws_sms].settings.regiononly chooses which foreign Region receives the data.The 90-day purge (
purge_retention) clears vsms’s own copy of the number and body. It does nothing about what AWS retains. Check AWS’s own retention for End User Messaging in the Region you pick.AWS keeps a separate opt-out list. A number that texts
STOPto an AWS origination identity is blocked by AWS, and vsms’s ownOptOuttable does not know. See Known limits.
Before you send production traffic, someone with the authority to decide must have cleared the transfer (or decided the deployment is out of that law’s scope, for example because the recipients are not in Cameroon). Record that decision somewhere the next operator can find it. Traffic to Orange or MTN through their own APIs has a different, simpler position; that is one reason to move traffic back to them as soon as they are available (see Routing, now and later).
What gets built, and who talks to whom
sms-worker --SendTextMessage (SigV4)--> AWS End User Messaging SMS --> carrier --> handset
|
configuration set (event destination)
|
v
Amazon SNS topic
|
signed HTTPS POST, SignatureVersion 2
v
https://<gateway>/dlr/aws_smsThe worker sends. It needs AWS credentials and nothing about the topic.
The gateway receives. It needs the topic ARN and no AWS credentials, so a compromised gateway cannot send an SMS on your account.
/dlr/aws_smsis public and unauthenticated by design (a provider webhook carries no bearer token). The gateway authenticates every call itself: theTopicArnmust equal the configured one,SignatureVersionmust be2, the signing-certificate URL must behttps://sns.<topic-region>.amazonaws.com/SimpleNotificationService-<32 hex>.pemwith no query or fragment, and the RSA-SHA256 signature must verify. Certificate fetches that are not already cached are rate-limited, so a forged request can cost the gateway at most an occasional fetch. A refused call is answered403; a check that could not run (certificate fetch failed or was rate-limited) is answered503so SNS retries. Seebackends/crates/sms-provider-aws-sms/src/sns.md.A subscription is confirmed by you, not by the gateway (step 6). The gateway logs the exact command to run.
Why End User Messaging and not plain SNS Publish: SNS can send an SMS directly, but it reports what happened to it only through CloudWatch Logs. Every message sent that way would sit in submitted until expire_stale marked it expired, firing message.expired for an OTP that arrived. End User Messaging is the same SMS backend with an API that can publish per-message events to an SNS topic.
Prerequisites
An AWS account, and an operator with permission to create IAM users, SNS topics and End User Messaging resources.
The AWS CLI v2 (
aws), configured for that account. Every command below takes--region; use the same one throughout.A public HTTPS URL for the gateway that SNS can reach, with a certificate a public CA signed. SNS will not post to a plain-HTTP or self-signed endpoint, so a laptop behind NAT cannot receive receipts. The Caddy edge in
deploy/is that URL in a normal deployment (Deployment).A running
sms-gatewayandsms-workerwith a migrated database (Deployment, or Getting started for a scratch setup).The data-residency decision above.
1. Choose a Region
End User Messaging SMS is available in af-south-1 (Africa, Cape Town) and in eu-west-1 (Europe, Ireland), among many others; the full list is the "AWS End User Messaging SMS and Voice V2 API" table on https://docs.aws.amazon.com/general/latest/gr/end-user-messaging.html.
Two differences that matter in practice:
af-south-1is an opt-in Region: it is disabled by default and must be enabled in the account (Account page, then AWS Regions, oraws account enable-region --region-name af-south-1; the operation is asynchronous and can take from minutes to hours).eu-west-1is enabled by default. Source: https://docs.aws.amazon.com/accounts/latest/reference/manage-acct-regions.html.Everything is per Region: the sandbox, the spend limit, the sender ID, the configuration set, the SNS topic and the IAM policy ARNs. The sandbox exit request below has to be filed for each Region you send from, and the SNS topic must be in the same Region as the configuration set.
Which Region is better for Cameroon traffic is not something this page can decide: it depends on the transfer decision above and on AWS’s own pricing for the destination from each Region, which you should read on the AWS pricing page rather than take from here.
The rest of this page writes $REGION, $ACCOUNT (the 12-digit account id) and uses the names vsms-sms-events (topic), vsms-sms (configuration set) and vsms-sms-sender (IAM user). Rename freely.
export REGION=eu-west-1
export ACCOUNT=1234567890122. Leave the SMS sandbox and set the spend limit
New accounts start in the SMS sandbox. In it, the monthly spend limit is USD 1.00 and you can send only to verified destination numbers (up to 10). Source: https://docs.aws.amazon.com/sms-voice/latest/userguide/sandbox.html.
To leave it, open an AWS Support case (Account and Billing, service Service Quotas, category AWS End User Messaging SMS (Pinpoint), quota SMS Production Access, new value 1) and fill in the use-case fields: the site sending, the message types (an OTP is One Time Password), the destination country, how recipients opt in, and the message template. AWS says it answers within 24 hours and may ask for more. File one case per Region.
Until that is approved you can still test: add your own phone as a verified destination number in the console (Verified destination numbers), which AWS verifies with a code.
What the sandbox looks like from vsms. A send to a number that is not a verified destination is refused by AWS with a ConflictException whose reason is DESTINATION_PHONE_NUMBER_NOT_VERIFIED. vsms treats that as a problem with this route, not with the message (Permanent): the message fails over to the next route. If aws_sms is the only route there is nothing to fail over to and the message ends failed with a "no alternate route available" reason. So a deployment that sends to real customers while the account is still in the sandbox shows failed messages, not an outage and not an open circuit. A recipient who opted out at AWS is a different ConflictException and is Rejected: the message fails with no failover.
The monthly spend limit is separate and has two parts: the account’s ceiling (set by AWS, raised through the same Support route) and the limit you enforce under it.
# View the limits the account currently has
aws pinpoint-sms-voice-v2 describe-spend-limits --region $REGION
# Enforce a monthly ceiling for text messages (must not exceed the account maximum)
aws pinpoint-sms-voice-v2 set-text-message-spend-limit-override \
--monthly-limit 200 --region $REGION(set-text-message-spend-limit-override is documented at https://docs.aws.amazon.com/cli/latest/reference/pinpoint-sms-voice-v2/set-text-message-spend-limit-override.html; it says the value must not exceed the maximum AWS sets and does not state the unit on that page. The quotas table lists the default threshold as USD 1.00 per account.) Pick the figure on purpose: when the limit is reached AWS refuses further sends, and vsms reacts to that as an outage of the provider (see Troubleshooting).
3. Sender ID for Cameroon
AWS’s country table (https://docs.aws.amazon.com/sms-voice/latest/userguide/phone-numbers-sms-by-country.html) lists Cameroon (CM, dialling code 237) as: short codes Yes, long codes No, sender IDs Yes (with no "registration required" note, unlike, for example, Kenya or the UK), two-way SMS Yes. So a sender ID works for Cameroon without a regulatory registration.
A sender ID is 1 to 11 characters, letters, digits and hyphens, at least one letter, no spaces, not starting or ending with a hyphen (https://docs.aws.amazon.com/sms-voice/latest/userguide/sender-id.html).
The display rule for a country that supports but does not require registration: if the message specifies a sender ID, it is sent from that ID. If it specifies none and the account has no dedicated number for that country, AWS sends it from the word
NOTICE. Always set an origination identity (below) so recipients do not seeNOTICE.The sender ID is case-sensitive at the carrier. Use exactly the casing you intend to display, and do not copy the console’s upper-cased listing.
The same page’s section Request a sender ID describes requesting one in the console (Sender ID, Request originator). The pages read for this runbook do not say whether a sender ID in a no-registration country must be requested in the account before it can be used. If the first test send fails with
ResourceNotFoundExceptionor a sender-identityValidationException, request it there and retry.
Then choose what vsms passes as the origination identity:
settings.origination_identityset (recommended): that value is used for every message. It can be the sender ID, a phone number, a pool, or one of their ARNs.Not set: the message’s own approved vsms sender ID is passed instead, which then has to exist in the AWS account too.
A pool (Pools in the console) groups origination identities so you can add or remove a problem identity without redeploying. AWS recommends this for business-critical traffic and notes that SMS numbers in each country are provisioned through a single carrier partner, which is a single point of failure (same country-table page). That is a second reason to keep another provider configured.
4. Create the SNS topic
aws sns create-topic --name vsms-sms-events --region $REGION
# note the TopicArn, then:
export TOPIC_ARN=arn:aws:sns:$REGION:$ACCOUNT:vsms-sms-eventsSet the signature version to 2. SNS topics default to SignatureVersion 1 (SHA1). The gateway refuses version 1 outright, and if you skip this step every notification and the subscription confirmation will be answered 403. SNS treats a 403 as a permanent failure and does not retry it, so those receipts are not delayed, they are lost; the gateway logs each one at error with the reason. Messages sent in that window end expired.
aws sns set-topic-attributes --region $REGION \
--topic-arn $TOPIC_ARN \
--attribute-name SignatureVersion --attribute-value 2Source: https://docs.aws.amazon.com/sns/latest/dg/sns-verify-signature-of-message-configure-message-signature.html (it lists versions 1 and 2, defaults to 1, and gives this command). Attribute SignatureVersion is not among the attributes aws sns create-topic --attributes documents, so set it afterwards as above.
Let End User Messaging publish to the topic. The topic’s access policy must allow the service principal sms-voice.amazonaws.com to call sns:Publish, scoped to your account and to the configuration set. This is AWS’s own statement (https://docs.aws.amazon.com/sms-voice/latest/userguide/configuration-sets-sns-creating-role.html), with the placeholders filled in:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": { "Service": "sms-voice.amazonaws.com" },
"Action": "sns:Publish",
"Resource": "arn:aws:sns:REGION:ACCOUNT:vsms-sms-events",
"Condition": {
"StringEquals": { "aws:SourceAccount": "ACCOUNT" },
"ArnLike": {
"aws:SourceArn": "arn:aws:sms-voice:REGION:ACCOUNT:configuration-set/vsms-sms"
}
}
}
]
}Save it as topic-policy.json with REGION and ACCOUNT replaced, then:
aws sns set-topic-attributes --region $REGION --topic-arn $TOPIC_ARN \
--attribute-name Policy --attribute-value file://topic-policy.jsonset-topic-attributes replaces the whole Policy attribute. If the topic already has statements you want to keep (a freshly created topic carries a default owner statement), fetch them with aws sns get-topic-attributes and merge rather than overwrite. The console’s New Amazon SNS topic option for an event destination creates a topic "with all of the required permissions" (per the AWS page for creating the destination) and is an alternative to writing the policy by hand; you still have to set SignatureVersion on it afterwards.
If you enable server-side encryption with a customer-managed KMS key on the topic, AWS says the key policy also needs sms-voice.amazonaws.com allowed to kms:GenerateDataKey and kms:Decrypt. vsms does not need encryption on the topic and the simplest working setup leaves it off.
5. Configuration set and the SNS event destination
aws pinpoint-sms-voice-v2 create-configuration-set \
--configuration-set-name vsms-sms --region $REGION
aws pinpoint-sms-voice-v2 create-event-destination \
--configuration-set-name vsms-sms \
--event-destination-name vsms-sns \
--matching-event-types TEXT_ALL \
--sns-destination TopicArn=$TOPIC_ARN \
--region $REGION(create-event-destination flags and values: https://docs.aws.amazon.com/cli/latest/reference/pinpoint-sms-voice-v2/create-event-destination.html; the console route is Configuration sets, then Add destination event, type Amazon SNS, event types All SMS events, per https://docs.aws.amazon.com/sms-voice/latest/userguide/configuration-sets-sns-add.html.)
Which event types. Use TEXT_ALL. vsms maps every TEXT_* event it knows (backends/crates/sms-provider-aws-sms/src/events.md), and an event type you leave out is a receipt it never sees. Picking a narrow list such as only TEXT_DELIVERED leaves every message whose carrier never sends a handset receipt submitted until it expires. Two facts about AWS’s list:
TEXT_SENTappears in the CLI’s list of accepted values but AWS’s documentation states "TheTEXT_SENTevent type is not supported", so do not rely on it.TEXT_ALLworks without it.A message can take up to 72 hours to get a final event; after 72 hours without one AWS sends
TEXT_UNKNOWNitself (https://docs.aws.amazon.com/sms-voice/latest/userguide/configuration-sets-event-format.html). vsms maps that touncertain.
A configuration set accepts up to 5 event destinations. If you add another (for example CloudWatch Logs, for your own audit), leave this one alone.
The configuration set name must match settings.configuration_set below. Without a configuration set that has an event destination, AWS emits no events at all and every message ends expired.
6. Subscribe the gateway URL to the topic
The gateway has to know the topic ARN before you subscribe, because it checks TopicArn on the confirmation message too. So set settings.events_topic_arn on the gateway first (step 8), after seeding the aws_sms Provider row (step 9, first command): the gateway refuses to start if that variable is set and no Provider row keyed aws_sms exists. Then restart the gateway and subscribe:
aws sns subscribe --region $REGION \
--topic-arn $TOPIC_ARN \
--protocol https \
--notification-endpoint https://GATEWAY_HOST/dlr/aws_sms \
--return-subscription-arnDo not set RawMessageDelivery. It defaults to off and must stay off: the signature SNS makes lives in the JSON envelope, and raw delivery strips the envelope (the AWS subscribe page describes it as removing the JSON formatting).
Confirm the subscription yourself
SNS posts a SubscriptionConfirmation to that URL. The gateway verifies it exactly as it verifies a notification, but it does not confirm the subscription. It logs, at warn, the topic ARN, the token and the command to run, and you run it:
aws sns confirm-subscription --region $REGION \
--topic-arn <topic arn from the log> \
--token <token from the log> \
--authenticate-on-unsubscribe trueWhy it is manual: the gateway could confirm by fetching the message’s SubscribeURL, but a subscription confirmed that way has AuthenticateOnUnsubscribe set to false. Every notification SNS sends carries an UnsubscribeURL, and vsms stores each receipt’s raw payload (DeliveryReceipt.rawPayload), so anyone able to read one could unsubscribe the endpoint with a plain request and silence every future receipt. With --authenticate-on-unsubscribe true, AWS’s confirm-subscription page says an authenticated request is required, and then only the topic owner and the subscription owner can unsubscribe (https://docs.aws.amazon.com/cli/latest/reference/sns/confirm-subscription.html).
The pages read for this runbook do not state how long a confirmation token is valid. An older version of this page said two days; treat that as unverified. If the token has stopped working, delete the subscription and subscribe again.
Check that it worked:
aws sns list-subscriptions-by-topic --region $REGION --topic-arn $TOPIC_ARN
# SubscriptionArn is a real arn:aws:sns:... value once confirmed,
# and the literal "PendingConfirmation" until then.Keep the confirmed SubscriptionArn; the next section needs it. Every later notification either ingests quietly or logs a warn that names the refusal (see Troubleshooting). If the subscription is ever removed (by anyone, including AWS), SNS posts an UnsubscribeConfirmation; the gateway logs it at error and does nothing else. Receipts stop until you subscribe and confirm again.
Survive a gateway outage: delivery policy and dead-letter queue
SNS’s default for an HTTP/S endpoint is three retries about 20 seconds apart, and anything other than a 5xx or 429 reply is a permanent failure that is never retried. A gateway restart or deploy longer than a minute can therefore lose receipts, and a lost receipt leaves its message submitted until it expires. Two settings on the subscription reduce that. Both are subscription attributes, set with aws sns set-subscription-attributes; DeliveryPolicy and RedrivePolicy are among the attribute names that command accepts (https://docs.aws.amazon.com/cli/latest/reference/sns/set-subscription-attributes.html).
A longer retry policy. Save as delivery-policy.json. This is the retry block from AWS’s own HTTP/S example (50 retries: 3 immediate, 2 one second apart, 10 exponential from 1 to 60 seconds, 35 at 60 seconds); the retry phases there add up to well under the one-hour cap on total retry time for an HTTP/S policy (https://docs.aws.amazon.com/sns/latest/dg/sns-message-delivery-retries.html):
{
"healthyRetryPolicy": {
"minDelayTarget": 1,
"maxDelayTarget": 60,
"numRetries": 50,
"numNoDelayRetries": 3,
"numMinDelayRetries": 2,
"numMaxDelayRetries": 35,
"backoffFunction": "exponential"
}
}aws sns set-subscription-attributes --region $REGION \
--subscription-arn <the confirmed SubscriptionArn> \
--attribute-name DeliveryPolicy \
--attribute-value file://delivery-policy.jsonAWS states that the total retry time cannot exceed 3,600 seconds and that this is a hard limit, so a retry policy alone cannot cover an outage longer than an hour.
A dead-letter queue. After retries are exhausted, SNS discards the message unless the subscription has a dead-letter queue, which is an ordinary SQS queue in the same account and Region (https://docs.aws.amazon.com/sns/latest/dg/sns-dead-letter-queues.html). Create a standard queue, let SNS write to it, then attach it:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": { "Service": "sns.amazonaws.com" },
"Action": "SQS:SendMessage",
"Resource": "arn:aws:sqs:REGION:ACCOUNT:vsms-sms-events-dlq",
"Condition": {
"ArnEquals": { "aws:SourceArn": "arn:aws:sns:REGION:ACCOUNT:vsms-sms-events" }
}
}
]
}aws sqs create-queue --queue-name vsms-sms-events-dlq --region $REGION
# set the queue policy above on it (aws sqs set-queue-attributes, attribute Policy), then:
aws sns set-subscription-attributes --region $REGION \
--subscription-arn <the confirmed SubscriptionArn> \
--attribute-name RedrivePolicy \
--attribute-value '{"deadLetterTargetArn":"arn:aws:sqs:REGION:ACCOUNT:vsms-sms-events-dlq"}'(Queue policy and RedrivePolicy shape: https://docs.aws.amazon.com/sns/latest/dg/sns-configure-dead-letter-queue.html.) AWS recommends a 14-day retention period on the dead-letter queue, and an alarm on the SQS metric ApproximateNumberOfMessagesVisible so you hear about anything that lands there.
What is not settled by the pages read: the form of a dead-lettered message body for an HTTP/S subscription, and whether a reply of 403 from the gateway sends the message to the queue. Inspect a real dead-lettered message before you build any replay. Nothing in vsms replays them; a receipt that was lost is not recovered, and its message resolves by expiry.
7. IAM user for the worker
The worker signs its own requests with a long-lived access key (no instance profile, IRSA or STS session credentials; see Known limits). Create a dedicated IAM user with nothing else attached.
AWS documents sms-voice:SendTextMessage with resource ARNs of three types (https://docs.aws.amazon.com/sms-voice/latest/userguide/permissions-actions.html):
arn:aws:sms-voice:REGION:ACCOUNT:phone-number/PHONE_NUMBER_ID
arn:aws:sms-voice:REGION:ACCOUNT:pool/POOL_ID
arn:aws:sms-voice:REGION:ACCOUNT:sender-id/SENDER_ID/ISO_COUNTRY_CODEand the configuration set’s ARN, which the SNS policy page uses, is arn:aws:sms-voice:REGION:ACCOUNT:configuration-set/NAME. A least-privilege policy for a sender ID MYBRAND in Cameroon and the configuration set above:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "sms-voice:SendTextMessage",
"Resource": [
"arn:aws:sms-voice:REGION:ACCOUNT:sender-id/MYBRAND/CM",
"arn:aws:sms-voice:REGION:ACCOUNT:configuration-set/vsms-sms"
]
}
]
}If you send from a pool or a phone number instead, list that ARN in place of the sender-id one. If you pass no origination identity at all, you must also allow whatever identity AWS picks, which is the reason to always set one.
aws iam create-user --user-name vsms-sms-sender
aws iam put-user-policy --user-name vsms-sms-sender \
--policy-name vsms-send-text --policy-document file://send-policy.json
aws iam create-access-key --user-name vsms-sms-sender
# prints AccessKeyId and SecretAccessKey -- the only time the secret is shownThe pages read for this runbook do not state whether the configuration-set ARN is required in Resource for SendTextMessage (the permissions page lists only the three origination resource types for that action). It is harmless to list it. If the first send returns AccessDeniedException, read the message, which names the ARN that was refused, and add it. Verify the policy with a real send (step 9) before you rely on it.
Rotate the key on a schedule: create a second key, put it in the secret store, restart the worker, delete the old one. There is no hot reload.
8. Configure vsms
Two processes, two different halves of one aws_sms entry in the YAML configuration (VSC-1; docs/architecture.md, section 9.4). Both binaries read the same document, so the entry is written once; compose keeps it in config/application-prod.yml (the AWS block is a commented example there), and Helm keeps it in config.overlay. The secret is the one thing that is not written in the file: credentials.secret_access_key is a ${AWS_SMS_SECRET_ACCESS_KEY} placeholder, delivered through the environment, and livemode refuses it as a literal.
providers:
- code: aws_sms
credentials:
access_key_id: <the IAM user's key id>
secret_access_key: ${AWS_SMS_SECRET_ACCESS_KEY}
settings:
region: eu-west-1
configuration_set: vsms-sms
tps_ceiling: 5
cost_per_segment_xaf: "30"
time_to_live_seconds: 900
events_topic_arn: arn:aws:sns:eu-west-1:123456789012:vsms-sms-eventssms-worker (sends). The sending half is all-or-none: naming any of the first six makes the rest required, and an entry that is missing one is refused at start, naming it, so a half-filled block is loud:
| Key | Meaning |
|---|---|
| The Region from step 1. |
| The IAM user’s key from step 7. The secret is a |
|
|
| Message parts per second the account may send. An account quota, so it has no default. Take it from AWS (it varies by destination country and origination identity) and, if unsure, start low. |
| What one segment costs, converted to XAF by you. Write it as a quoted string ( |
Optional:
| Key | Default | Meaning |
|---|---|---|
| (unset) | Sender ID, number, pool or ARN used for every message. Strongly recommended (step 3). |
|
|
|
|
| Whether the account sends under alphanumeric sender IDs to your destinations. Informational (it feeds the adapter’s advertised capabilities); it does not change what is sent. |
| (unset) | How long AWS keeps trying to deliver one message, 5 to 259200 seconds. Unset means AWS’s default of 72 hours, so an OTP can reach a handset days after vsms expired it (an OTP’s default validity in vsms is 15 minutes). For OTP traffic set |
| (unset) | Overrides the AWS endpoint. For tests against a fake only; under |
sms-gateway (receives):
| Key | Meaning |
|---|---|
| The topic ARN from step 4. Setting it enables |
An entry with the topic and none of the sending keys is a receive-only entry, which the worker reads as "nothing to send through"; an entry with neither is refused. Quote values that look like numbers but are text; an unquoted +2370000 is a YAML integer, and the typed model refuses it.
The old AWS_SMS_* variables and --aws-sms-* flags are gone and are refused, not ignored: a process that still sets one stops at start and names the key above that replaced it.
Order: seed the Provider row (step 9, first command), then restart the gateway (so it knows the topic before you subscribe), then the worker.
9. Seed the provider and the route, then send a test
The worker builds the adapter from the YAML entry, but the routing layer and the database need a Provider row with the same key. seed-dispatch creates it, activates it, and creates a catch-all Route pointing at it. Run it before you start the gateway with settings.events_topic_arn set (step 8): it needs only the database.
docker compose --env-file .env run --rm sms-gateway seed-dispatch \
--key aws_sms \
--kind aggregator_http \
--display-name "AWS End User Messaging SMS" \
--credential-ref env:AWS_SMS_SECRET_ACCESS_KEY \
--max-tps 5 \
--cost-per-segment-xaf 30(docker compose run --rm sms-gateway … is how deployment.adoc runs the other one-shot commands; use your own wrapper if you deploy differently. --max-tps and --cost-per-segment-xaf here should match the entry’s tps_ceiling and cost_per_segment_xaf; the row is a record, the adapter’s own values are the ones the worker uses. Flags and defaults are in backends/apps/sms-gateway/src/commands/seed_dispatch.rs; --max-daily-submissions defaults to 100000.)
AWS-only go-live: do not seed Orange by accident. sms-gateway bootstrap seeds an orange_cm Provider and a catch-all Route as one of its steps, and so does a bare seed-dispatch. In a deployment where the worker has no Orange credentials that Route is live, and it shares priority 0 and weight 1 with the AWS catch-all, so about half of all messages are routed to a provider the worker cannot send through. Those messages are not failed: the worker finds no adapter for them and retries with backoff indefinitely (see the warning in Routing, now and later). The fix is to never create that Route:
# first run on a fresh database: everything bootstrap does, except the orange_cm seed
# (add --console-redirect-uri / --owner-email / --owner-display-name if you run the console)
docker compose --env-file .env run --rm sms-gateway bootstrap --skip-seed-dispatch
# then the AWS provider and its catch-all route, with the seed-dispatch command aboveHow each deployment path avoids it (read from the files, not run):
Compose (
deploy/docker-compose.yml): use the two commands above, and notbootstrapwithout the flag or a bareseed-dispatch. The file’s own comment next to the one-shot commands says the same.Helm (
deploy/charts/vsms): the chart does not runbootstrap; it uses granular pre-install and pre-upgrade hooks. TheseedDispatchhook (Orange) is switched off by the chart whenorange.clientIdis empty, and the separateseedDispatchAwshook is switched on whenawsSms.regionorawsSms.eventsTopicArnis set. An AWS-only values file therefore leavesorange.clientIdandmtn.clientIdempty and sets theawsSmsblock. If you already seeded Orange by mistake, disable theorange_cmroute and provider in the console (Routes, Providers) rather than leaving it.
Two things about the seed-dispatch command are deliberate and worth knowing:
There is no
aws_smsProviderKind.--kindtakes one oforange_cm_http,mtn_http,aggregator_http,smpp, and the row usesaggregator_http. Adding a variant would be a schema change and a migration for a field nothing reads (the adapter is chosen bykey, notkind), so it was avoided on purpose. If you want a real distinction, that is a future schema change.The command is idempotent and always ensures a catch-all route. It creates a
Routenamedcatch-all (seed-dispatch)at priority 0, weight 1, everymatch*empty, pointing at this provider, and re-enables it if it was disabled. If you already run another provider with its own catch-all, you now have two wildcard routes at equal priority and weight, and traffic splits between them. That is the starting point for the split described below, but make it deliberate, and only once the other provider’s adapter is configured in the worker: see Routing, now and later.
Now send a message to a number you control (while in the sandbox, a verified destination number) and watch it:
Send through the API or the console composer (
just demo-style flows are in Getting started). Note themessageIdthat comes back.GET /messages/{id}, or open the message in the console, until it leavesaccepted. The expected sequence isaccepted,queued,routed,submitted, thendelivered.Confirm a receipt arrived: the console’s message detail shows the
DeliveryReceiptrows, and in SQL:sqlSELECT received_at, outcome, raw_status FROM delivery_receipts WHERE message_id = '<messageId>' ORDER BY received_at;You should see
TEXT_QUEUED/TEXT_PENDINGrows asin_flightand a finalTEXT_SUCCESSFULorTEXT_DELIVERED.raw_statuskeeps AWS’s own event name, so you can always tell a carrier acceptance from a handset confirmation (see Known limits).On the gateway, a
warnline withprovider_key="aws_sms"means a callback was refused or failed, and anerrormeans receipts are being lost (topic onSignatureVersion1, or the subscription was removed); none means every call passed.
Routing, now and later
Routing is data: rows of Route (priority, weight, matchOperator, matchClass, matchAppId, matchPrefix, providerId, enabled), evaluated by the pure engine in backends/crates/sms-routing (§6.3).
How a decision is made, in order:
Disabled routes and routes whose provider is unavailable are dropped. A provider is unavailable if it is not
activeor its circuit is open.Routes whose predicates do not match are dropped. A
NULLpredicate is a wildcard ("matches anything"), not "matches only a NULL value".Of what remains, the highest
prioritywins as a band. Priority is the only cross-band ordering; there is no "more specific route wins". A route with three predicates set does not beat a wildcard at the same priority.Inside the winning band a weighted draw picks one route: each route gets a share of
[0, 1)proportional to itsweight(all weights0falls back to an even split).If the chosen provider’s submit fails in a way that means nothing was accepted, the message fails over: the engine runs again over the whole route set with the failed route excluded, so the other routes in the same band, or the next band down, get their turn. At most two hops.
What fails over and what does not (from backends/crates/sms-provider/src/error.rs and dispatch.rs):
| Provider outcome | Effect |
|---|---|
| Fail over to the next route. |
| Count towards that provider’s circuit breaker; fail over. Five consecutive opens it for 60 seconds and routing then skips that provider for all new messages. |
| Retry on the same provider after a short delay. No failover. |
| Fail the message. No failover. |
|
|
Now: AWS is the only provider
One wildcard route. This is exactly what seed-dispatch --key aws_sms leaves you with:
| name | priority | weight | matchOperator | matchClass | provider |
|---|---|---|---|---|---|
catch-all (seed-dispatch) | 0 | 1 | (any) | (any) |
|
Every message goes to AWS. If AWS’s circuit opens (for example the spend limit is hit) and there is no other route, accepted messages find no eligible route and go to rejected with a stateReason naming why, rather than queue up behind an outage. That is by design; see Troubleshooting.
Later: MTN-or-AWS, Orange-or-AWS, AWS alone elsewhere
Once the MTN and Orange adapters are configured and their Provider rows exist (mtn_cm, orange_cm), the routes become:
| name | priority | weight | matchOperator | provider | purpose |
|---|---|---|---|---|---|
mtn direct | 100 | 70 |
|
| most MTN numbers go direct |
mtn via aws | 100 | 30 |
|
| a standing share on AWS, and the failover target for the row above |
orange direct | 100 | 70 |
|
| most Orange numbers go direct |
orange via aws | 100 | 30 |
|
| the same for Orange |
everything else | 0 | 1 | (any) |
| Camtel, Nexttel, unknown and foreign numbers, and the fallback |
Reading it:
An MTN number matches the two
mtnrows (priority 100) and the wildcard (priority 0). Priority 100 is the winning band, so the wildcard is never drawn for it; weights 70 and 30 split MTN traffic roughly 70/30 between MTN direct and AWS.If the chosen route fails with
PermanentorUnavailable, the engine re-runs with that route excluded. If MTN direct failed, "mtn via aws" is the only route left in that band and wins; if that fails too, the priority-0 wildcard (also AWS) is next, then the two-hop cap ends the attempts.A number classified
unknown,camtelornexttelmatches nomtnororangerow and only the wildcard, so it goes to AWS alone.To send all MTN traffic direct and use AWS only as a failover, there is no "failover only" flag. Give "mtn via aws"
weight0 in the same band as "mtn direct": a weight-0 route is never drawn while a heavier route is eligible, but it is still a candidate once the direct route is excluded. Simulate it before you trust it (below).To drain AWS entirely, set the
aws_smsroutes'enabledto false; to change the split, edit weights. Both take effect on the next claimed message.matchClassandmatchAppIdnarrow a route further, for example to send onlyotptraffic of one app through a particular provider.
Create and edit them in the admin console (Routes), or over REST (POST /routes, PATCH /routes/{id} with If-Match). Both require an owner or admin session; see Getting started for how a console session is obtained. Before changing live routes, use the console’s Simulator screen (or the simulateRoute procedure) with an MSISDN, class and app: it runs the same engine with a chosen or random draw and shows, per route, why it matched, was dropped, or won, and the exact tie break ranges, without sending anything.
Warning
Never create a Route to a provider whose credentials the worker does not have. dispatch resolves the adapter by Provider.key at submit time. If the winning route’s provider has no registered adapter in this worker process (you created the mtn_cm route but the worker has no MTN_* variables), the message is not failed: it is retried with backoff, indefinitely, until someone fixes the mismatch. Configure the adapter in the worker first, restart it, then add the route.
Warning
Operator classification is a hint. matchOperator matches the operator vsms guessed from the number’s prefix (OperatorPrefixRule), and number portability means a prefix stops identifying a network once a subscriber ports; some prefix rows are marked contested or unverified. A mis-classified number goes to the wrong provider’s band. That is safe when both bands include AWS (as above), because every band can still reach a working provider, but do not build a route set that depends on the guess being right for correctness. Foreign numbers have no operator at all (multi-country design, decision 3), which is why the final wildcard matters.
Validate each route with real handsets on each network before trusting it, and re-validate monthly (Monthly handset validation). That applies to aws_sms routes too: AWS routes through a carrier partner per country, and a sender ID that is rewritten downstream is invisible to every delivery receipt.
Troubleshooting
| Symptom | Likely cause | What to do |
|---|---|---|
|
| Set it, restart the gateway. |
| The log’s | Compare the ARN in the reason with the env value byte for byte. Re-run step 4’s |
| The gateway could not complete the check: it could not fetch the signing certificate from | Fix outbound HTTPS from the gateway to that host. The rate limit only matters under a burst of uncached certificate URLs (normally a flood of forged requests, since genuine traffic reuses one cached certificate); it clears by itself. SNS retries |
Gateway exits at startup, "no Provider row has key aws_sms" |
| Run the |
Subscription stays | The confirmation was refused (see the | Look for the gateway |
Receipts stopped arriving; the gateway logged an | The subscription was removed, by someone with the topic, or by anyone holding an |
|
Messages reach | No receipts are arriving: configuration set has no SNS event destination (AWS then emits no events at all), the destination’s event types are too narrow, the topic policy does not allow | Walk the chain in order: the set exists, its destination exists ( |
Every send fails and the provider’s circuit opens (new messages route elsewhere, or are | AWS refused with a quota or access error, which vsms treats as the provider being down: | Read the worker log for AWS’s message. Raise the spend limit and the account maximum (step 2), fix the policy, or correct the names. The breaker reopens after 60 seconds on its own; there is nothing to reset. |
A message is | Zero eligible routes: the only route’s provider is not | Check |
Messages | The account is still in the SMS sandbox and the destination is not a verified number ( | Leave the sandbox (step 2), or send only to verified destination numbers meanwhile. |
An OTP arrived on a handset long after it should have expired |
| Set it (step 8); |
A message is |
| Check the origination identity exists and is allowed for Cameroon in this Region (step 3). With another route available the message fails over to it first. |
A recipient never gets messages and vsms shows | The number opted out on AWS’s own list (AWS returns a conflict) | Check AWS’s opt-out list ( |
| A send timed out after connecting; AWS may have sent it | Expected, and a deliberate trade (a possibly lost message over a duplicate). It is not retried. Check AWS’s own logs if you need to know. |
Without an AWS account: sms-fake-aws
backends/crates/sms-fake-aws stands in for AWS the way sms-fake-orange stands in for Orange. It answers SendTextMessage (checking the worker’s Signature Version 4 signature against its own implementation, held to AWS’s published get-vanilla vector), and plays the SNS topic: for every accepted message it publishes signed Notification`s to `/dlr/aws_sms, retrying until acknowledged, with an RSA key and certificate it generates at start-up.
just demo runs it. compose.dev.yaml seeds an aws_sms provider and catch-all route beside Orange’s, at the same priority and weight, so messages split between the two fakes and each message’s detail page names its provider. The worker gets the fake’s fixed demo account (FAKE-AWS-SMS-DEMO-KEY, configuration set vsms-fake), and the gateway gets settings.events_topic_arn plus settings.fake_sns_cert_base (both in config/application-dev.yml).
That second key is honoured only by an sms-gateway built with the off-by-default fake-aws cargo feature. It changes where the signing certificate is fetched from and nothing else: the topic, the signature version, the certificate URL’s shape (https://sns.<region>.amazonaws.com/SimpleNotificationService-<hex>.pem, which is what the fake’s notifications carry) and the signature are checked exactly as in production. Every published image is built without the feature, so it cannot be told to trust the fake: it refuses the key at start, as it once refused the flag, which did not parse.
sms-fake-aws --fault-mode | What every submit gets |
|---|---|
| Accepted; |
| Accepted; a final |
| Accepted, never reported: the message ends |
| Refused with |
| Accepted; a final |
docker compose -f compose.dev.yaml stop sms-fake-aws
docker compose -f compose.dev.yaml run --rm --service-ports sms-fake-aws \
--bind-addr 0.0.0.0:8095 \
--dlr-endpoint http://sms-gateway:8080/dlr/aws_sms \
--fault-mode sandboxThe same fake backs backends/crates/sms-fake-aws/tests/round_trip.rs (the real adapter against the fake over HTTP, no database: signing, every error class, tampering, redelivery, a subscription confirmation) and backends/crates/sms-worker/tests/aws_sms_fake_live_postgres.rs (dispatch to a signed receipt and delivered; the sandbox refusal failing over to Orange; a response without a MessageId held uncertain and never resent; a tampered receipt refused on the route).
Both sides were written from the same reading of AWS’s documentation, so all of this proves the code agrees with that reading, over real HTTP and through the real signature code. It does not prove AWS behaves that way; step 9 with a real account does.
Known limits
From backends/crates/sms-provider-aws-sms/src/lib.md and the notes above:
Long-lived IAM user keys only. No instance profile, IRSA or STS session credentials, because refreshing them is the AWS SDK’s credential chain, which this crate avoids (the SDK’s HTTP stack is what would bring
aws-lc-rsback). Scope the user to the one policy above and rotate the key.A timed-out send is never rescued by a later receipt. Its
MessageIdwas never learned, so any event that arrives matches nothing andexpire_staleresolves the message. Same trade-off as Orange (AGENTS.md, #119).A final
TEXT_SUCCESSFULcounts asDelivered. Many carriers never send a handset receipt to AWS; for those AWS sendsTEXT_SUCCESSFULwithisFinal: trueand nothing after it. Treating that as in-flight would expire every such message, so it is recorded as delivered. It is an overstatement: it means the network took it, not that the handset confirmed.DeliveryReceipt.rawStatuskeepsTEXT_SUCCESSFUL.A non-final event never moves a message toward a retry. Any event with
isFinalfalse other thanTEXT_DELIVEREDis recorded as in-flight, even a failure-looking one, because the carrier may still deliver the original and acting on it would send a duplicate. The cost is that a message AWS will later report as failed waits for that final event (or for expiry) instead of being retried early.No delivering-network field. Events carry
mcc/mnc, but they are not mapped onto vsms’s operator codes, so grey-route divergence analysis gets nothing per-operator from AWS receipts.The SNS certificate’s own chain is not walked. The gateway trusts the signing certificate because it was fetched over HTTPS from the SNS host of the topic’s Region (redirects disabled), the same trust model the AWS SDK validators use. AWS’s published guidance also lists confirming the chain of trust; this check is not implemented separately.
Timestamp freshness is not checked. Replaying a genuine receipt is harmless: ingestion is idempotent and an out-of-order transition is refused by the state machine.
A receipt SNS gives up on is lost. SNS drops a message after its retry policy is exhausted (or on the first reply other than
5xxor429) unless a dead-letter queue holds it, and nothing in vsms reads the queue. Its message endsexpired. The delivery policy and dead-letter queue in step 6 shrink the window; they do not close it.The subscription’s protection depends on a command you run. Confirmed without
--authenticate-on-unsubscribe true, anyone with a notification’sUnsubscribeURLcan unsubscribe the endpoint. vsms cannot check how a subscription was confirmed.AWS keeps its own opt-out list, separate from vsms’s
OptOut.One provider, one carrier partner per country. AWS says SMS numbers are provisioned through a single carrier partner in each Region and country. Keep another provider configured for traffic that matters.
Nothing here has run against a live AWS account. The request and event shapes come from AWS’s published documentation and botocore’s service model; the SigV4 signer is checked against two independent references. The first real send is the first real verification, so do step 9 with a phone you hold before pointing production traffic at it.
Sources
AWS documentation relied on for this page (read when it was written; AWS changes these, so check the page if a step disagrees with what you see):
Endpoints and quotas: https://docs.aws.amazon.com/general/latest/gr/end-user-messaging.html
Opt-in Regions: https://docs.aws.amazon.com/accounts/latest/reference/manage-acct-regions.html
Country capabilities (Cameroon row): https://docs.aws.amazon.com/sms-voice/latest/userguide/phone-numbers-sms-by-country.html
Sender IDs: https://docs.aws.amazon.com/sms-voice/latest/userguide/sender-id.html and https://docs.aws.amazon.com/sms-voice/latest/userguide/sender-id-request.html
Sandbox: https://docs.aws.amazon.com/sms-voice/latest/userguide/sandbox.html
Spend limit CLI: https://docs.aws.amazon.com/cli/latest/reference/pinpoint-sms-voice-v2/set-text-message-spend-limit-override.html
Configuration sets and SNS destinations: https://docs.aws.amazon.com/sms-voice/latest/userguide/configuration-sets.html, https://docs.aws.amazon.com/sms-voice/latest/userguide/configuration-sets-sns-add.html, https://docs.aws.amazon.com/sms-voice/latest/userguide/configuration-sets-sns-creating-role.html
Event types and format: https://docs.aws.amazon.com/sms-voice/latest/userguide/configuration-sets-event-types.html, https://docs.aws.amazon.com/sms-voice/latest/userguide/configuration-sets-event-format.html, https://docs.aws.amazon.com/cli/latest/reference/pinpoint-sms-voice-v2/create-event-destination.html
SNS signature version and verification: https://docs.aws.amazon.com/sns/latest/dg/sns-verify-signature-of-message-configure-message-signature.html, https://docs.aws.amazon.com/sns/latest/dg/sns-verify-signature-of-message.html
SNS CLI: https://docs.aws.amazon.com/cli/latest/reference/sns/subscribe.html, https://docs.aws.amazon.com/cli/latest/reference/sns/set-topic-attributes.html, https://docs.aws.amazon.com/cli/latest/reference/sns/confirm-subscription.html, https://docs.aws.amazon.com/cli/latest/reference/sns/set-subscription-attributes.html
SNS HTTP/S retries, delivery policy and dead-letter queues: https://docs.aws.amazon.com/sns/latest/dg/sns-message-delivery-retries.html, https://docs.aws.amazon.com/sns/latest/dg/sns-dead-letter-queues.html, https://docs.aws.amazon.com/sns/latest/dg/sns-configure-dead-letter-queue.html
IAM actions and resource ARNs: https://docs.aws.amazon.com/sms-voice/latest/userguide/permissions-actions.html
SendTextMessage: https://docs.aws.amazon.com/pinpoint/latest/apireference_smsvoicev2/API_SendTextMessage.html