Mock the Twilio SMS API β€” test OTP flows without sending texts

Credit where due: Twilio's testing story is one of the better ones. Every account gets separate test credentials that never bill you, never touch real phones, and support magic numbers β€” send from +15005550006 and it succeeds, send to +15005550001 and you get error 21211 on cue. If you have a Twilio account and only need "does my happy path parse a Message", use them.

But the gaps are stated in Twilio's own docs, and they're exactly where OTP and notification testing gets hard: test credentials never fire status callbacks β€” your delivery-webhook handler can't be exercised at all. Messages are born "queued" and stay there forever, so a poll-until-delivered loop has nothing to poll. Only four API resources work at all (everything else returns 403). The magic-input menu is fixed β€” there's no "429 on the third call" or "two seconds of latency". And it all requires a Twilio account with the SID and token wired into every CI job. This guide builds a Twilio-shaped mock in about a minute that covers those gaps β€” and we verified the official twilio-node SDK (v6.0.2) talks to it natively, including throwing a real RestException with the right error code. No signup, no keys.

1. What your client has to parse

Twilio's success response is the Message resource; its errors are a four-field JSON body with an error code that's more specific than the HTTP status. Both shapes below are from Twilio's official API reference:

# Success β€” HTTP 201, the Message resource (abridged):
{"sid":"SMxxxxxxxx...","status":"queued","direction":"outbound-api",
 "to":"+15551234567","from":"+15005550006","body":"Your code is 424242",
 "error_code":null,"error_message":null,"num_segments":"1", ...}

# Error β€” e.g. invalid To number, HTTP 400:
{"code":21211,"message":"The 'To' number abc is not a valid phone number.",
 "more_info":"https://www.twilio.com/docs/errors/21211","status":400}

If your error handling switches on e.code === 21211 vs 21610 (blocked) vs 21614 (can't receive SMS) and it has only ever seen happy-path responses, it's untested.

2. Build the mock (one paste)

Two custom routes β€” the real Messages endpoint path, plus a "magic account SID" that always fails with a Twilio-shaped error β€” and an messages outbox collection for assertions:

BASE=https://mockbird.mockbird.workers.dev
P=$(curl -s -X POST $BASE/api/projects -H 'content-type: application/json' \
     -d '{"name":"twilio-mock","blank":true}')
PID=$(echo $P | sed 's/.*"id": *"\([^"]*\)".*/\1/')
KEY=$(echo $P | sed 's/.*"adminKey": *"\([^"]*\)".*/\1/')

# The real endpoint path the SDK posts to (form-encoded β€” templating handles it):
curl -s -X POST $BASE/api/projects/$PID/routes -H "x-admin-key: $KEY" \
  -H 'content-type: application/json' -d '{
  "method":"POST","path":"/2010-04-01/Accounts/:sid/Messages.json","status":201,
  "contentType":"application/json",
  "body":"{\"sid\":\"SM{{uuid}}\",\"account_sid\":\"{{params.sid}}\",\"api_version\":\"2010-04-01\",\"body\":\"{{body.Body}}\",\"date_created\":\"{{now}}\",\"date_sent\":null,\"date_updated\":\"{{now}}\",\"direction\":\"outbound-api\",\"error_code\":null,\"error_message\":null,\"from\":\"{{body.From}}\",\"num_media\":\"0\",\"num_segments\":\"1\",\"price\":null,\"price_unit\":null,\"status\":\"queued\",\"to\":\"{{body.To}}\",\"uri\":\"/2010-04-01/Accounts/{{params.sid}}/Messages/SMmock.json\"}"}'

# Magic account SID: any send through ACfail21211 gets the real 21211 error shape.
# A static path segment beats the :sid parameter, so both routes coexist.
curl -s -X POST $BASE/api/projects/$PID/routes -H "x-admin-key: $KEY" \
  -H 'content-type: application/json' -d '{
  "method":"POST","path":"/2010-04-01/Accounts/ACfail21211/Messages.json","status":400,
  "contentType":"application/json",
  "body":"{\"code\":21211,\"message\":\"The @To@ number {{body.To}} is not a valid phone number.\",\"more_info\":\"https://www.twilio.com/docs/errors/21211\",\"status\":400}"}'

# Outbox collection for assertions and lifecycle drills:
curl -s -X POST $BASE/api/projects/$PID/resources -H "x-admin-key: $KEY" \
  -H 'content-type: application/json' -d '{"name":"messages","seed":0,
  "fields":[{"name":"to","type":"phone"},{"name":"from","type":"phone"},
            {"name":"body","type":"sentence"},
            {"name":"status","type":"oneOf","values":["queued","sent","delivered","undelivered"]}]}'

Replace @To@ with 'To' if you want Twilio's exact apostrophes β€” kept out of the paste block so the shell quoting stays copy-safe. Everything in this guide was run against production before publishing.

3. The official SDK works against it natively (verified)

twilio-node exposes the API base URL β€” one line and zero code changes elsewhere:

import twilio from "twilio";

const client = twilio("ACtest00000000000000000000000000", "fake_auth_token");
client.api.baseUrl = "https://mockbird.mockbird.workers.dev/m/YOUR_PROJECT";

const msg = await client.messages.create({
  to: "+15551234567", from: "+15005550006", body: "Your code is 424242" });
// msg.sid   -> "SM…"        msg.status -> "queued"
// msg.to / msg.from / msg.body echo exactly what you sent

// Point the client at the magic failing account SID and you get a real exception:
const bad = twilio("ACfail21211", "fake_auth_token");
bad.api.baseUrl = "https://mockbird.mockbird.workers.dev/m/YOUR_PROJECT";
try { await bad.messages.create({ to: "abc", from: "+15005550006", body: "x" }); }
catch (e) { /* e.status === 400, e.code === 21211 β€” a genuine RestException */ }

We ran exactly this against a fresh project while writing the guide: the success call parses into a full MessageInstance (SDK does its own camelCasing), and the failure throws RestException with code 21211 β€” the same catch-block behavior your production code sees. The SDK sends its usual form-encoded body; {{body.To}} templating reads form fields as well as JSON. One honest caveat: our sid is SM + a UUID, not Twilio's 32-hex format β€” the SDK doesn't care, but don't regex-validate sid formats against the mock.

4. Assert what your code actually sent (the OTP case)

The classic integration test: your signup flow "sends" an OTP β€” did it send the right code to the right number? Two ways, no email-your-QA-a-phone required:

# The request inspector captured every send, body included:
curl -s $BASE/api/projects/$PID/requests -H "x-admin-key: $KEY"
# β†’ [{"method":"POST","path":"/2010-04-01/Accounts/.../Messages.json",
#     "body":"To=%2B15551234567&From=%2B15005550006&Body=Your+code+is+424242", ...}]

# Or have your app write to the outbox collection in test mode, then query it:
curl -s "$BASE/m/$PID/messages?body_like=424242"
# β†’ exactly one match, with to/from/status β€” substring + _gte/_ne filters all work

5. The status-callback drill test credentials can't do

Twilio's docs are explicit: messages sent with test credentials don't trigger status callbacks. With the outbox collection you can stage the whole delivery lifecycle yourself:

# Message starts queued...
curl -s -X POST $BASE/m/$PID/messages -H 'content-type: application/json' \
  -d '{"to":"+15551234567","from":"+15005550006","body":"Your code is 424242","status":"queued"}'

# ...then walk it through the lifecycle your handler needs to survive:
curl -s -X PATCH $BASE/m/$PID/messages/1 -H 'content-type: application/json' -d '{"status":"sent"}'
curl -s -X PATCH $BASE/m/$PID/messages/1 -H 'content-type: application/json' -d '{"status":"delivered"}'

Attach a webhook to the project and each of those writes fires a signed POST to your callback handler — messages.created, then two messages.updated — so the queued→sent→delivered choreography (and the undelivered sad path) gets exercised for real. See sending test webhooks for signature verification. Your poll-until-delivered loop gets something to poll, too: GET /messages/1 reflects every transition.

6. Failure & retry drills

Twilio's magic numbers are a fixed menu. Here the menu is yours β€” and it composes with the simulation params:

# Deterministic retry drill: exactly two 429s, then success (per-key sequence):
curl -X POST "$BASE/m/$PID/2010-04-01/Accounts/ACtest/Messages.json?mock_seq=429,429,201&mock_seq_key=ci-run-1" \
  -d "To=%2B15551234567&From=%2B15005550006&Body=retry me"
# β†’ 429, 429, 201 on three successive calls (we verified this exact sequence)

# Slow carrier: 2s latency on the send
curl -X POST "$BASE/m/$PID/2010-04-01/Accounts/ACtest/Messages.json?mock_delay=2000" -d "..."

# Random failure injection for soak tests: ?mock_chaos=0.3

Add more magic account SIDs for the error codes your code handles β€” ACfail21610 (blocked recipient), ACfail20429 (concurrency limit) β€” each is one more static route with the real body shape. That's the same pattern Twilio chose for magic inputs, except you define the menu.

7. Honest comparison

Twilio test credentialsMockbird
Account requiredYes (SID + token in every CI job)No
Real parameter validationβœ” their actual validators✘ shapes only
Magic failure inputsβœ” fixed menu, maintained by Twilioβœ” you define the menu
Status callbacks / delivery lifecycle✘ never fireβœ” staged via outbox + webhooks
Message status after sendalways queuedany transition you stage
429 / latency / chaos on cueβœ˜βœ” query params
Assert the sent payload✘ (no logs for test sends)βœ” inspector + outbox queries
Voice / Lookup / number purchaseβœ” (4 resources)routes for shapes only

Straight answer: if you have a Twilio account, keep test credentials in the loop β€” they run Twilio's real parameter validation, which no mock can honestly claim. Use Mockbird for what they can't stage: callback-handler tests, delivery-lifecycle polling, deterministic retry drills, payload assertions, and CI jobs that shouldn't hold provider credentials. They compose.

Notes & limits

Create yours

The paste block in section 2 is the entire setup β€” or open the dashboard and click the routes together. The same project also gives you OpenAPI + TypeScript exports, a GraphQL endpoint, and snapshotted fixtures.