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(orautoDeliver: falsein the settings) the message waits inqueueduntil a status is set by hand. - History: every status change appends
{ "status", "at" }tohistory;sentAtis written the first time the message is sent anddeliveredAtwhen it is delivered. - Manual wins: a message that already reached
delivered,failedorundeliveredis not moved again by the timer, and a deleted message is not resurrected by it. - The error of a failure:
failedcarries 30003 (Unreachable destination handset),undeliveredcarries 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=1The 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: falseand 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-456or123 456give123456. - 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
Hostheader the guard admits. - Origin check: on the state-changing methods (
POST,PUT,PATCH,DELETE) theOrigin, when the request carries one, must be admitted too. - Feed and GET send: the
/wshandshake andGET /sms/sendalso refuse a foreignOrigin, 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.