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.
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.
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.
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.
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
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.
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.
| Twilio test credentials | Mockbird | |
|---|---|---|
| Account required | Yes (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 send | always queued | any 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.
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.