Docs

The three send APIs, the life of a message, the failures you can stage on purpose, the webhooks, and every setting of the SMS mock.

Send APIs

The mock speaks three send APIs. All of them create the same message, run the same lifecycle and read the same in-memory store, so an application under test can keep its own client and only change the base URL.

Native — POST /v1/messages

A JSON body with to and body, and optionally from and callbackUrl. The number is normalized to E.164 and the sender falls back to the default sender of the settings.

curl -X POST http://localhost:8899/v1/messages \
  -H 'content-type: application/json' \
  -d '{"to":"+5511912340010","body":"Your code is 482913","from":"AMA4DEVS"}'

The answer is 201 with the message as it was stored, still queued:

{
  "id": "SM3f8c1a94b2e4d7f8a0b6c5d4e3f2a1b0",
  "direction": "outbound",
  "from": "AMA4DEVS",
  "to": "+5511912340010",
  "body": "Your code is 482913",
  "status": "queued",
  "api": "native",
  "encoding": "GSM-7",
  "segments": 1,
  "codes": ["482913"],
  "errorCode": null,
  "errorMessage": null,
  "callbackUrl": null,
  "createdAt": "2026-02-03T10:15:00.000Z",
  "updatedAt": "2026-02-03T10:15:00.000Z",
  "sentAt": null,
  "deliveredAt": null,
  "history": [{ "status": "queued", "at": "2026-02-03T10:15:00.000Z" }]
}

Listing the messages

GET /v1/messages answers { "messages": [...], "count": 2 }, oldest first. Every filter narrows the result and can be combined:

Parameter Description
to Keeps the messages sent to this number.
from Keeps the messages sent from this number or sender id.
number Keeps the messages with this number on either side.
status Keeps one status: queued, sent, delivered, undelivered, failed or received. An unknown status is ignored.
direction Keeps outbound (app to handset) or inbound (handset to app). An unknown direction is ignored.
q Keeps the messages whose body, numbers or codes contain this text, ignoring the case.
after Keeps only the messages created after the one with this id: the cursor of a polling loop.
limit Keeps the newest messages, oldest first among them.
wait Long-polls for the first message that answers the filters, up to 30000 ms.

GET /v1/messages/{id} answers one message (404 not_found when the id is unknown) and DELETE /v1/messages/{id} drops it (204, or 404 when the id is unknown).

Twilio-compatible — POST /2010-04-01/Accounts/{AccountSid}/Messages.json

The paths and the answers a Twilio SDK expects, with the form fields To, From, Body and StatusCallback (a MessagingServiceSid with no From uses the default sender). Authorization is ignored: the mock has no key.

curl -X POST 'http://localhost:8899/2010-04-01/Accounts/ACdemo/Messages.json' \
  -d 'To=+5511912340010' \
  -d 'From=AMA4DEVS' \
  -d 'Body=Your code is 482913'

The answer is 201 with the message in the Twilio shape (dates in RFC 2822, the segment count as a string):

{
  "sid": "SM3f8c1a94b2e4d7f8a0b6c5d4e3f2a1b0",
  "account_sid": "ACdemo",
  "api_version": "2010-04-01",
  "body": "Your code is 482913",
  "date_created": "Tue, 03 Feb 2026 10:15:00 +0000",
  "date_updated": "Tue, 03 Feb 2026 10:15:00 +0000",
  "date_sent": null,
  "direction": "outbound-api",
  "error_code": null,
  "error_message": null,
  "from": "AMA4DEVS",
  "to": "+5511912340010",
  "num_segments": "1",
  "num_media": "0",
  "price": null,
  "price_unit": "USD",
  "status": "queued",
  "messaging_service_sid": null,
  "uri": "/2010-04-01/Accounts/ACdemo/Messages/SM3f8c1a94b2e4d7f8a0b6c5d4e3f2a1b0.json",
  "subresource_uris": {
    "media": "/2010-04-01/Accounts/ACdemo/Messages/SM3f8c1a94b2e4d7f8a0b6c5d4e3f2a1b0/Media.json"
  }
}

GET /2010-04-01/Accounts/{AccountSid}/Messages.json lists the messages newest first, narrowed by To and From and cut to PageSize (50 by default, 1000 at most), with the paging fields a Twilio client reads. GET .../Messages/{sid}.json answers one message and DELETE .../Messages/{sid}.json drops it.

Simple — GET|POST /sms/send

The shortest way to send a message: to, message (aliases text and body), sender (alias from) and callbackUrl, in the query of a GET or in the form body of a POST.

curl 'http://localhost:8899/sms/send?to=%2B5511912340010&message=Your+code+is+482913'
{ "status": "ok", "id": "SM3f8c1a94b2e4d7f8a0b6c5d4e3f2a1b0", "to": "+5511912340010", "segments": 1 }

Errors

The native API refuses a request with { "error": { "code", "message", "field" } } and HTTP 400; the simple API uses its own short shape, { "status": "error", "code", "message" }, with the same codes:

Code HTTP When it happens
invalid_json 400 The request body is not valid JSON.
invalid_to 400 The number is not E.164 and cannot be normalized.
rejected_number 400 The number ends in the magic suffix 0003: the API refuses it and no message is stored.
empty_body 400 The body is empty.
body_too_long 400 The body is longer than 1600 characters.
invalid_from 400 The sender is neither E.164 nor an alphanumeric sender id.
method_not_allowed 405 The route does not answer that method.
not_found 404 The message id is unknown.

The Twilio API refuses with its own shape, { "code", "message", "more_info", "status" }, always with HTTP 400: 21211 for an invalid To (and for the magic suffix 0003), 21212 for an invalid From, 21602 when the body is missing and 21617 when the body is too long. A missing resource answers 20404 with HTTP 404.

Lifecycle

A message is created queued, leaves the queue as sent and ends in delivered, undelivered or failed. An inbound message is always received and has no life of its own.

queued  ──▶  sent  ──▶  delivered
   │          │
   │          ├──▶  undelivered  (30005)
   │          └──▶  failed       (30003, 30007)

The life takes SMS_DELIVERY_DELAY_MS milliseconds (1500 by default): the message is sent at a third of the delay and reaches its final status at the whole delay. With a delay of zero the whole life happens synchronously, inside the request.

  • Manual delivery: with SMS_AUTO_DELIVER=0 (or autoDeliver: false in the settings) the message waits in queued until a status is set by hand.
  • History: every status change appends { "status", "at" } to history; sentAt is written the first time the message is sent and deliveredAt when it is delivered.
  • Manual wins: a message that already reached delivered, failed or undelivered is not moved again by the timer, and a deleted message is not resurrected by it.
  • The error of a failure: failed carries 30003 (Unreachable destination handset), undelivered carries 30005 (Unknown destination handset) and the filtered number carries 30007 (Message filtered by the carrier). A status set by hand takes the code of its kind; a code the mock does not know keeps a plain Delivery error message.

Magic numbers

Five endings stage a failure on purpose. The mock looks at the last four digits of the normalized number, so +5511912340001 and 11912340001 are the same number for the mock.

Last four digits What happens Final status Error code
0001 The handset is unreachable. failed 30003
0002 The handset is unknown. undelivered 30005
0003 The API refuses the number; the request answers HTTP 400 and nothing is stored. — 21211
0004 The message is sent and the handset never answers. sent —
0005 The carrier filters the message. failed 30007

Every other number delivers normally.

Webhooks

Two events leave the mock: a status event for every status change (the first one is the queued message itself) and an inbound event when the handset writes a reply. A status event goes to the callbackUrl of the message or to the webhook URL of the settings; an inbound event always goes to the webhook URL of the settings.

The body

A message that did not come through the Twilio API is posted as JSON:

{ "event": "message.status", "message": { "id": "SM3f8c...", "status": "delivered", ... } }

A message that came through the Twilio API is posted as a form, the way a Twilio status callback arrives:

MessageSid=SM3f8c...&SmsSid=SM3f8c...&AccountSid=ACmock00000000000000000000000000&MessageStatus=delivered&SmsStatus=delivered&From=AMA4DEVS&To=%2B5511912340010&NumSegments=1

The form of an inbound event carries Body as well, and a failure carries ErrorCode and ErrorMessage.

The signature

When SMS_WEBHOOK_SECRET is set, the exact body that goes out is signed with HMAC-SHA256 and the digest is sent in the header x-sms-signature: sha256=<hex>. The header is absent when no secret is set. To check it, hash the raw body with the same secret:

printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SMS_WEBHOOK_SECRET" | sed 's/^.* //'

Every request also carries x-sms-event (status or inbound) and user-agent: sms-mock.

The loopback rewrite

When WEBHOOK_LOCALHOST_REWRITE is set, a webhook URL that points at localhost, 127.0.0.1 or ::1 is rewritten to that host: a mock running in a container reaches the machine of the developer, not itself.

What happens when the target fails

  • A non-2xx answer or a network error is recorded with ok: false and never breaks the mock.
  • A webhook request takes at most 5 seconds.
  • The last 200 attempts are kept and can be read at GET /_mock/sms/webhooks/deliveries.

Inbound replies

The panel is the handset. What the application sends is what the handset receives; what the handset writes is what the application receives. The reply is stored as an inbound message and fires the inbound webhook.

curl -X POST http://localhost:8899/_mock/sms/inbound \
  -H 'content-type: application/json' \
  -d '{"from":"+5511912340010","to":"AMA4DEVS","body":"Received, thank you!"}'

The answer is 201 with the message: direction is inbound, status is received and api is mock. The to is optional and falls back to the default sender; the refusals are the same as the ones of the send API (invalid_from, invalid_to, empty_body, body_too_long).

Testing

Long polling

GET /v1/messages with wait holds the request until a message answers the filters: it answers at once when one already does, and with an empty list when the wait ends. The longest wait is 30000 ms.

curl 'http://localhost:8899/v1/messages?to=%2B5511912340010&wait=5000'

The after parameter is the cursor of a polling loop: it lists only the messages created after the one with that id, so a test never reads the same message twice.

A clean store between tests

POST /_mock/sms/reset answers 204 and drops every message and every webhook attempt, so each test starts from an empty store.

A test in CI

curl -s -X POST http://localhost:8899/_mock/sms/reset

curl -s -X POST http://localhost:8899/v1/messages \
  -H 'content-type: application/json' \
  -d '{"to":"+5511912340010","body":"Your code is 482913"}'

curl -s 'http://localhost:8899/v1/messages?to=%2B5511912340010&wait=5000'

The answer of the long poll carries the message: status is delivered once the delay has passed, codes holds 482913 and segments is the number of SMS parts the body costs.

Segments & OTP

How many SMS parts a body costs

Encoding One segment Each extra segment When it applies
GSM-7 160 septets 153 septets Every character is in the GSM 03.38 table.
UCS-2 70 UTF-16 units 67 UTF-16 units Any character is outside that table; an emoji is two UTF-16 units.

The nine characters of the extension table — ^ { } \ [ ] ~ | € — cost two septets each in a GSM-7 message. Every message carries its encoding and its segments.

The codes the reader has to type back

The mock reads the body of every message and puts the codes it finds in the codes array:

  • A run of four to eight digits that does not stick to other digits: Your code is 482913 gives 482913.
  • Two groups of three or four digits the reader joins: 123-456 or 123 456 give 123456.
  • The same code is listed once, and the codes keep the order of the text.

The panel

Open http://localhost:8899: the panel is the handset of the test. It opens in Brazilian Portuguese; the PT|EN switch in the header changes the language, and the names below are given in both.

The inbox

  • The header: the SMS mark, the live status, the language switch, an icon for a new test message and an icon for the control panel.
  • The sidebar: a sender selector, a search field, the filters All / Tudo, No reply / Sem resposta and Failed / Com falha, and one conversation per phone number.
  • The thread: the SMS the application sent are the bubbles on the left, with the sender id, the chip that copies the verification code, the segment badge (1 SMS · UCS-2) and the status (queued, sent, delivered, undelivered, failed). On hover, each message can be delivered (Entregar), failed (Falhar), left undelivered (Não entregue) or deleted (Apagar) by hand.
  • The composer: what is typed there is what the handset sends: it arrives as the bubble on the right and fires the inbound webhook.
  • The details drawer: the info icon, or a click on a bubble, opens the fields of the message, its JSON and its status history.
  • The empty state: New test message / Nova mensagem de teste, Load samples / Carregar exemplos and Open the control panel / Abrir painel de controle.

The control panel

The icon in the header opens the control panel, with a tab per subject:

Tab What it does
Delivery / Entrega Automatic or manual delivery, the delay until delivery in milliseconds, and the button that starts from zero.
Webhook The URL the events go to, the secret that signs them, the default sender, the shape of the status and inbound payloads, the x-sms-signature header and a verification snippet in Node.
Events / Eventos Every webhook attempt, with its body, its answer code and the time.
Magic numbers / Números mágicos The endings that stage a failure, with the rules of the segments and of the OTP.
API The catalog of the API: 20 operations, with the parameters, the body fields, a curl, every documented answer and a Testar button that sends the request for real and shows the answer.

Control routes

The routes the panel uses, under the guard of the next section. They drive the mock from a test or from a script:

Route Method What it does
/_mock/sms/settings GET, PUT Reads the settings and changes the ones the body carries.
/_mock/sms/inbound POST Stores the reply the handset wrote and fires the inbound event.
/_mock/sms/messages/{id}/status POST Moves an outbound message to a status by hand.
/_mock/sms/reset POST Drops every message and every webhook attempt.
/_mock/sms/webhooks/deliveries GET, DELETE Reads the attempts of the webhooks and empties the log.
/_mock/sms/demo POST Seeds a sample inbox: an OTP and its reply, a delivery notice, a long promo and a message to the number that always fails.

The settings at runtime

curl -X PUT http://localhost:8899/_mock/sms/settings \
  -H 'content-type: application/json' \
  -d '{"deliveryDelayMs":0,"autoDeliver":false,"webhookUrl":"http://localhost:4000/webhooks/sms"}'

The answer is the whole settings, and the change is told to the panel at once.

A status by hand

curl -X POST http://localhost:8899/_mock/sms/messages/SM3f8c1a94b2e4d7f8a0b6c5d4e3f2a1b0/status \
  -H 'content-type: application/json' \
  -d '{"status":"delivered"}'

The status is one of sent, delivered, undelivered or failed, and an errorCode may be sent with it. The answer is the changed message; an unknown id answers 404 and an inbound message answers 409 (not_outbound).

Health and the live panel

GET /health answers { "status": "ok", "service": "sms", "version": "0.2.0" } and is the only route the guard does not watch. The panel listens on the WebSocket /ws: the first frame is a snapshot (the messages, the settings, the webhook attempts and the version) and the frames after it are the live events (message.created, message.updated, message.deleted, reset, settings.updated, delivery, deliveries.cleared).

Configuration

The server reads its configuration from the environment when it starts; the settings of the mock (the delay, the automatic delivery, the webhook URL and secret, the default sender) can also be changed at runtime through /_mock/sms/settings.

Variable Default Description
PORT 8899 Port the HTTP server listens on.
MOCK_ALLOWED_HOSTS "" Comma-separated list of extra hosts the local guard admits.
SMS_WEBHOOK_URL "" Where the status and inbound events are posted. Empty posts nothing.
SMS_WEBHOOK_SECRET "" Signs the webhook body with HMAC-SHA256. Empty sends the events unsigned.
SMS_DELIVERY_DELAY_MS 1500 Time from queued to the final status. Zero progresses the message synchronously.
SMS_AUTO_DELIVER 1 Set it to 0 to keep the messages in queued until a status is set by hand.
SMS_DEFAULT_COUNTRY_CODE 55 Country code assumed for the numbers written without one.
SMS_DEFAULT_FROM AMA4DEVS Sender used when a request names none.
SMS_MAX_MESSAGES 5000 Messages kept in memory; the oldest are dropped beyond it.
WEBHOOK_LOCALHOST_REWRITE (unset) Rewrites a loopback webhook host to this one, so a mock in a container reaches the host machine.

The repository's compose file publishes the service on the host port SMS_PORT (8899), the port the container itself listens on is PORT.

With the Asaas mock

In this repository the SMS mock starts with the Asaas mock: the root make up (the asaas profile) brings up the SMS mock, the Asaas mock, the generator and Mailpit, and SMS_ENABLED=0 in .env keeps the SMS mock down. make up MOCKS=sms starts only the SMS mock.

The Asaas mock finds the SMS mock by itself: with SMS_MOCK_URL unset it tries the compose service name sms (then sms-mock), this machine and the Docker host, always on port 8899. It posts the payer SMS to POST /v1/messages with the sender ASAAS, for the same charge events as its e-mails and its WhatsApp messages, and only for the customers that have a mobile phone. Set SMS_MOCK_URL to point it elsewhere, or set it empty to turn the payer SMS off. SMS_UI_URL is the address of the SMS panel as the browser reaches it (http://localhost:8899 by default); the Asaas mock reports it in GET /_admin/notify/status (sms.uiUrl).

Local guard

Every route but GET /health carries no key, so it is bound to this machine:

  • Host check: every request must carry a Host header the guard admits.
  • Origin check: on the state-changing methods (POST, PUT, PATCH, DELETE) the Origin, when the request carries one, must be admitted too.
  • Feed and GET send: the /ws handshake and GET /sms/send also refuse a foreign Origin, or a cross-site browser request (Sec-Fetch-Site: cross-site), so a page you visit cannot read the live feed or trigger a send.

The admitted hosts are localhost, 127.0.0.1, ::1, host.docker.internal, sms and sms-mock; MOCK_ALLOWED_HOSTS adds more, comma separated. A refused request answers HTTP 403 with { "error": { "code": "forbidden_host" } } or { "error": { "code": "forbidden_origin" } }.

CORS

When the Origin is admitted, it is reflected in the answer: access-control-allow-origin carries the origin and vary: Origin says that the answer depends on it. A preflight (OPTIONS) answers 204 with access-control-allow-methods: GET,POST,PUT,DELETE,OPTIONS, the headers the browser asked for echoed in access-control-allow-headers (authorization,content-type when it asks for none) and access-control-max-age: 600. An origin that is not admitted is refused with HTTP 403 (forbidden_origin).

Limits

Limit Value
Body 1600 characters (ten concatenated segments).
Segment GSM-7: 160 septets in the first, 153 in each extra one. UCS-2: 70 UTF-16 units in the first, 67 in each extra one.
Long poll wait of at most 30000 ms.
Store SMS_MAX_MESSAGES messages (5000 by default); the oldest are dropped beyond it.
Webhook log The last 200 attempts.
Webhook request 5 seconds.
Twilio page PageSize 50 by default, 1000 at most.
Number E.164 with 8 to 15 digits; without a country code, 10 or 11 digits gain the default one and 12 to 15 count as international.
Sender An E.164 number or an alphanumeric sender id of up to 11 characters.

Everything lives in memory: a restart empties the store, the settings and the webhook log.