← Back to all posts

Engineering

We built a fake carrier so you never have to test against a real one

Test keys on Handset run a complete simulated carrier behind the same interface as the real one: delivery receipts, replies, ringing phones, even raw call audio. Here is how it is built and why it is a product feature, not a test helper.

Jose Zamudio · Founder, Handset

·7 min read

Telephony has a testing problem that most APIs do not. A real carrier costs money per action, answers asynchronously, needs registration before it will carry your texts, and you cannot make it fail on demand. You find out how your code handles a rejected message when a real message gets rejected.

Most providers solve this with a sandbox that stubs the request and drops the webhook. Your send returns 200. Nothing ever arrives. The part you actually needed to test, the part where a delivery receipt shows up twenty seconds later and your code has to do the right thing with it, is the part the sandbox skips.

So on Handset, every account has two keys, hs_live_… and hs_test_…, and the test key does not stub the carrier. It talks to a carrier. The carrier just happens to be software we wrote.

One interface, two carriers

Everything in the platform that touches a carrier goes through one Go interface:

type Carrier interface {
	Numbers     // search, purchase, release
	Messaging   // send; delivery arrives later as events
	Voice       // answer, transfer, play, record, dial, gather, stream
	Compliance  // brands, campaigns, E911
	Porting
	WebRTC
}

There are two implementations. One speaks Telnyx. The other, internal/carrier/simulated, speaks to nobody; it lives inside the API process. A selector picks one per request based on the key's mode:

carriers := &carrier.Selector{Live: telnyx.New(cfg), Test: simulated.New()}
// everywhere else:
ref, err := carriers.ForMode(auth.Mode).SendMessage(ctx, msg)

That is the whole trick, and it is worth being precise about what it means. The messaging service, the voice state machine, the compliance registration code, the usage ledger: none of them know which carrier they are talking to. There is no if testMode anywhere in the business logic. The simulator is not a mock injected by tests. It is a carrier.

Reactions, not responses

A carrier is not a function that returns. It is a thing that reacts, later, on its own schedule. A send returns a reference immediately; whether the message was delivered arrives as a webhook some seconds after. A dial returns a call reference; whether anyone picked up arrives as an event. If the simulator only did the first half it would be the sandbox I complained about above.

So the simulator has an outbound channel of carrier events, and the API process drains it into the exact same ingest pipeline that receives live webhooks:

go func() {
	for ev := range sim.Emit {
		ingestSvc.IngestEvent(ctx, "simulated", ev)
	}
}()

When you send a test message, SendMessage records it, returns a ref, and pushes a message.delivered event onto that channel. Ingest stores it, the worker processes it, your conversation thread updates, the usage ledger writes a row, and your webhook endpoint receives message.delivered signed exactly as it would in production. Nothing is skipped. The only difference from live is that the event came from a goroutine instead of a Telnyx POST.

The practical result is the thing we care about: partners build their webhook handlers against test keys and they work the first time on live keys, because the handler never saw a difference.

Magic numbers are the failure vocabulary

Delivering every message successfully would make the simulator pleasant and useless. The interesting code paths are the failures, and you need a way to ask for them. We borrowed Stripe's test-card idea: certain destination numbers trigger scripted behavior.

NumberBehavior
+15005550001Send accepted, then delivery fails: message.failed with carrier_rejected
+15005550002Recipient replies STOP after first delivery, so you can rehearse opt-out handling
+15005550003Dialed party never answers (click-to-call and ring targets)
+15005550004Not portable; port-in checks fail with a reason
+15005550005Port-in goes to action_needed after submission
+15005550007Gathers time out; the party never presses anything
+15005550008Media streams fail to start: call.stream.failed
+15005550009The carrier rejects the send itself; after retries the message ends failed
Any other numberDelivers instantly with a receipt; dialed parties answer within a second or two

Two rules made this table useful rather than cute.

First, each number maps to one carrier behavior, documented as the event you will receive, not as an internal state. You are not testing "error mode 3", you are testing "what does my app do when message.failed arrives with carrier_rejected".

Second, numbers that look similar exercise different code. 0001 and 0009 both end with a failed message, but 0001 is a send the carrier accepted and later could not deliver (a webhook path), while 0009 is the carrier API refusing the send (a retry path in our queue that eventually gives up and emits message.failed with send_failed). Those are different bugs waiting to happen in a partner's code, so they are different numbers.

The table also includes one real number: +16054551234 is a genuine high-cost exchange, and dialing it in test mode returns the same destination_not_supported it would live. Our block list for access-stimulation prefixes applies in both modes, because a partner should learn about it from a test, not an invoice.

Voice is where it gets hard

Messaging is request and receipt. Voice is a conversation between your code and the carrier: you answer, the carrier tells you the call is up, you play a prompt, you ask for a digit, the carrier tells you what was pressed, you transfer, the carrier tells you the other leg rang, answered, hung up. A simulator that only records commands cannot drive that; it has to play the other party.

So the simulated party has habits. It answers a dialed leg about a second after the dial. When you start a gather, it presses 1 after a moment, unless the leg is talking to +15005550007, in which case it never presses anything and you get the timeout you asked for. If you dial +15005550003 it rings forever and you get a hangup with cause no-answer. Every call-control command you issue is recorded, so our own tests can assert "the platform answered, played TTS, started recording, then hung up" against the simulator rather than against a phone.

The part I am proudest of is media streaming. Live, when you start a stream on a call, the carrier opens a WebSocket to our media gateway and pushes raw G.711 audio. The simulator does exactly that. It dials the gateway's carrier-facing endpoint like a real provider would, signed URL and all, and pushes synthesized audio: a 440 Hz tone pulsing one second on, one second off. If the stream is bidirectional it echoes back whatever you play into it, so you can verify your playback path end to end. The media gateway cannot tell the difference between the simulator and Telnyx, which is the point. It means test mode gives you actual bytes of call audio over a WebSocket, for free, before you have registered anything with anyone.

What we learned building it

References must be globally unique, even in the fake. Receipt correlation keys on the carrier's ref, and real carriers never reuse one. A per-process counter would be fine until two API processes both start at one, so every simulated ref carries a random nonce plus a sequence. The fake has to honor the same invariants the real thing does, or it teaches your code the wrong lesson.

Never block the send path on the event channel. The emit is a non-blocking send. If the buffer is somehow full the receipt is dropped rather than the API call hanging. Dropped receipts show up in tests; hung sends show up as outages.

The simulator finds bugs first. Our public demo creates a fresh tenant and buys a number for every visitor. It ran out of numbers on day one because the simulator's inventory was five fixed entries. Randomizing the synthetic inventory was a one-line fix, and a reminder that a good fake has to be generous in the places reality is generous.

Live behavior flows back into the fake. Every time a real carrier teaches us something, the lesson gets encoded as a magic number or a habit. The stream-failure number exists because we built the gateway and asked "what does a partner do when the carrier cannot start the stream?" The answer needed to be testable the same afternoon.

Why this is a product feature

I keep calling it the simulator, but from the outside it is just what test mode is. It shows up in three places that matter to us.

Partners integrate before a phone rings. 10DLC registration takes days; test mode takes zero. By the time a campaign is approved the integration is already done, against the same events, with the failures already rehearsed.

The live demo runs entirely on it. demo.handset.dev is a real application on the real API with a test key; every click goes through the same pipeline as production, and nobody's phone rings. That is why we can give it away without a signup.

And AI agents default to it. Our MCP server refuses live keys unless you explicitly allow them, so an agent that has just discovered Handset can text and call and read transcripts with no possibility of reaching a human by accident. Safe by default only works if the default is also useful, and it is only useful because the fake carrier is complete.

The test-mode reference, including the full magic-number table, is at docs.handset.dev/test-mode.

Share:XLinkedIn

More from Handset