Every telephony integration I have seen starts the same way. Import the Twilio SDK, ship the feature, move on.
Then a second use case needs a provider with better rates in one region. Or an outage takes out every call path you have at once. Or procurement wants leverage in a renewal. And you find out the vendor SDK has spread through your product code like ivy.
At Spyne I built and ran the telephony layer for our voice agents across Twilio, Vonage and Telnyx, and later distilled the pattern into a standalone NestJS service of my own. This is the design, and more usefully, the places where it earns its keep and the places where it does not.
Capabilities, not vendors
The whole thing rests on one rule: product code talks to a capability, never to a provider.
SMS, voice, verification, number lookup, number provisioning, messaging services. Each one is a provider-neutral interface with its own controller. Nothing in product code imports a vendor SDK. It asks the SMS capability to send a message and does not know who delivers it.
Providers become adapters that implement some subset of those interfaces, and a registry resolves which adapter serves which capability at request time, per capability. That last part matters more than it looks. In my own service the choice comes from environment configuration; resolving it per request does not make it an automatic health-based failover system. Real deployments are mixed. You might route voice through whichever carrier sounds best into your market, SMS through whoever holds your A2P registration, and verification through a third provider entirely, all from one deployment, chosen by configuration.
If this sounds like what I wrote about swapping voice-agent engines under a stable control plane, it should. Same idea. Define your own contract, make vendors satisfy it, and vendors become replaceable. Telephony is just where I learned it first.
The trap
The classic way provider abstraction goes wrong is designing the interface around what every provider has in common. You end up unable to use the reasons you picked a provider in the first place. Twilio’s A2P 10DLC registration, TrustHub, TaskRouter and subaccounts have provider-specific lifecycles. A deployment that uses them needs to preserve those details rather than force them into a generic send method.
So the layer is unapologetically two-tier. The capability layer covers the traffic that is genuinely portable, which is most of it: send this message, place this call, verify this number. Alongside it, provider-specific routes stay first-class, namespaced as such, with no pretence of portability. In my service Twilio is the first complete adapter and also keeps its own routes for A2P, TrustHub, TaskRouter, recordings, usage and the rest.
Pretending those routes do not exist does not make you portable. It makes you wrap the SDK in a worse SDK. What keeps the two tiers honest is that when a workflow uses a provider-specific route, that dependency is visible in one place, priced in, and chosen on purpose, instead of being smeared invisibly through the codebase.
The hard part is inbound
Sending through many providers is the easy half. Each SDK’s send call maps cleanly onto a capability method.
The hard half is everything the providers send you. Message status webhooks, inbound SMS, call events, each with its own payload shape, retry behavior and signature scheme. The layer has to fold all of it into internal events with one vocabulary, because a delivery failure should look identical to product code regardless of which carrier reported it.
Three rules that survived production:
Verify every webhook signature at the edge, using the exact URL, body or parsed parameters required by that provider’s scheme, before triggering business logic. Reverse proxies and body parsing can change what a verifier sees.
Make handlers safe for duplicate and out-of-order webhooks. Do not assume every event is guaranteed to arrive: Twilio’s retry behavior depends on the webhook and connection settings. Persist the event before acknowledging it, then reconcile missing terminal states.
Map status vocabularies explicitly, including the statuses one provider has and others do not. Preserve the raw provider status alongside the normalized event. Route unfamiliar values to an explicit unknown state and alert on them, so you can add a mapping and replay the stored event without losing evidence.
That last rule, unknown things fail explicitly instead of silently falling back, is the same rule our voice-agent runtime profiles enforce for providers and models. Silent fallbacks are how multi-vendor systems rot.
What it buys you
Outage failover, within real constraints. A common API makes changing the route easier, but the alternate provider still needs suitable numbers, credentials, capacity and registrations. It cannot move an active call, port an inbound number instantly or transfer a messaging campaign’s approval by configuration.
Leverage and regional fit. The ability to actually move traffic is the difference between a negotiation and a renewal. It also lets you pick per-market winners, because carrier quality and pricing are regional in ways a single-vendor setup flattens.
One place for policy. Once every message and call goes through one layer, that layer is where compliance controls live as code: banned-number and do-not-call filtering, quiet hours, per-tenant rate limits, the calling-reputation hygiene I wrote about last time. With direct SDK usage every team reimplements those individually, or forgets to.
Cheap extension. Adding a provider means implementing the capability interfaces you need and registering the adapter. In my own service, Vonage, Plivo and MessageBird exist as explicit stubs for exactly that reason: an unimplemented capability on a provider is a stated fact, not a runtime surprise.
A timeout is not permission to send twice
The dangerous case is an outbound request whose response never arrives. The carrier may already have accepted the call or message. Sending it through a second provider can create two real calls for one product action.
Give each product action a stable operation ID and persist its provider, attempt and provider reference. Distinguish a confirmed rejection from an unknown acceptance state. Reconcile the latter through status queries or callbacks before retrying; use provider idempotency support where available, without assuming every API has it.
The same distinction belongs in the contract:
| Result | Meaning | Routing consequence |
|---|---|---|
| Unsupported capability | This adapter cannot perform the operation | Reject before making a provider request |
| Confirmed rejection | The provider did not accept the operation | Consider an eligible fallback after checking the error |
| Accepted | The provider has an operation reference | Track this attempt through its lifecycle |
| Acceptance unknown | A timeout left the outcome ambiguous | Reconcile before issuing another operation |
This is where a useful abstraction earns its complexity. A shared method name is easy. A shared meaning for “did we place the call?” is the hard part.
When not to do this
If you have one provider, one use case and no volume, this is ceremony. Use the SDK and ship.
The layer starts paying rent when the second provider shows up, or the second region, or the first serious outage. By then it is much cheaper to have built the contract early. Which is, I notice, the same lesson as every other post in this series. The contract you define before you need it is the one that saves you the rewrite when you do.