Migrate SMS from Bandwidth
This page maps Bandwidth's Messages API, Applications, and message callbacks to Bird. Follow the main migration guide in order and use these mappings for steps 3, 4, and 5.
Two differences shape the whole port. Bandwidth splits the channel across two hosts: sending lives on the messaging host under your account path, authenticated over HTTP Basic, while 10DLC registration lives on the main API host. Bird puts sending, registration and delivery events under one base URL and one bearer key. And the applicationId on every Bandwidth send carries the callback configuration; Bird has no equivalent object, because callbacks are a workspace subscription rather than a property of the message.
Hand this to your agent
Paste this into Claude Code, Cursor, or Codex. The agent works through this page against your own repository, using whichever Bird surface it already has: the MCP server if one is connected, the CLI if it is installed and signed in.
Code example
I am moving an SMS integration from Bandwidth to Bird. Route through it with me.
1. Check what you already have before setting anything up. If Bird's MCP server is connected, use its tools. If the Bird CLI is installed and signed in, use that. Either one is enough, and every step below is an action you take with whichever you have. Only if neither is present, follow https://bird.com/docs/ai/set-up-your-agent.md to set one up and sign me in. Every Bird docs page serves Markdown at its own URL with `.md` appended, so fetch that rather than the HTML.
2. Read https://bird.com/docs/guides/sms/migrate/bandwidth.md for the field, status and opt-out mapping, and https://bird.com/docs/guides/sms/migrate.md for the order the steps go in.
3. Find and list my Bandwidth usage in this repository before you change anything: the createMessage call sites and any SDK wrappers around them, every applicationId and account ID they name, the callback handler, and anywhere I hold opt-out state myself. That last one matters more here than on other providers, so look for it specifically: a suppression table, an opted_out flag on a contact, or a check before sending. Show me the list before you touch any of it.
4. Tell me early where my opt-out list actually lives, because Bandwidth does not keep one for me outside toll-free. It is in my own database or my own code, so the migration reads from my system rather than exporting from theirs, and I need to tell you which table or field is authoritative before you import anything.
5. Then take only the paths that apply to me.
- Countries and senders: https://bird.com/docs/guides/sms/migrate.md covers enabling destinations and setting up a sender. Ask me which countries I actually send to rather than enabling everything, because an enabled destination I never use is exposure to SMS pumping rather than reach.
- Opt-outs: import from my own records following https://bird.com/docs/guides/sms/opt-outs-and-keywords.md. A Bird suppression is one sender-and-subscriber pair, so tell me how many pairs my records become before you start.
- Sending and events: port the send call and replace the Application's callback with a workspace webhook, using the mapping tables on the provider page and https://bird.com/docs/guides/webhooks.md.
- US long codes: my 10DLC brand and campaign do not transfer and must be registered again, following https://bird.com/docs/guides/sms/10dlc.md. Submitting a registration is chargeable, so show me what you are about to submit and wait for me to say yes before you submit anything.
6. Test the ported path against Bird's simulated destinations before any real traffic, following https://bird.com/docs/guides/sms/migrate.md. They exercise the send and the webhook handler against real API responses and real signed deliveries without reaching a handset, but a simulated send is billed at the destination's normal rate, so tell me how many you plan to send and keep the smoke test to that. The United States must be enabled and `from` must be a sender valid for the US, or the test numbers reject.
7. Stop and ask me wherever a step needs a decision. Do not point production traffic at Bird, and do not retire the Bandwidth path, until I have seen the simulated-destination results and told you to go ahead. Finish by telling me what is left that only a person can do.Map the send call
| What it does | Bandwidth | Bird |
|---|---|---|
| Recipient | to (array) | to (one per request) |
| Sender | from | from |
| Body | text | text |
| Callback routing | applicationId | a workspace webhook subscribed to sms.* |
| Intent | (none) | category, required on free text |
| Free-form label | tag (one string) | metadata; tags only if you can name it |
| Round-trip context | your own store, keyed by ID | metadata: arbitrary JSON, echoed on every event |
| Delivery priority | priority | no equivalent |
| Safe retries | (none in their specification) | Idempotency-Key header |
| Media | media | no equivalent: media_urls is rejected |
Porting notes:
- to collapses from an array to one recipient. Bandwidth takes a list; Bird sends one message per request. A loop replaces the array, and each call can carry its own Idempotency-Key.
- The applicationId disappears rather than moving. It exists to tell Bandwidth where to post callbacks. On Bird that is a workspace subscription, so nothing on the send names it.
- tag and tags are not the same field. Bandwidth's tag is one free-form string; Bird's tags are {name, value} pairs that become query dimensions. A single opaque string is usually better carried in metadata.
- Nothing on the Messages API corresponds to category. Decide per message type whether it is transactional, marketing, authentication, or service.
Carry over opt-outs
There is no list to export, and that is the finding rather than a gap in this guide.
Outside toll-free, Bandwidth does not maintain opt-in or opt-out lists for you. Their own guidance puts it plainly: the onus of honoring the commands and maintaining the lists lies with the customer. Toll-free is the exception, where STOP and its variants are enforced at the network layer regardless of your configuration; long codes and short codes get no such handling.
So on this migration the authoritative list is already yours. It is a table, a flag on a contact record, or a check your send path runs before calling the API, and the first task is deciding which of those is authoritative rather than requesting an export from anyone. Your own inbound-message log is the fallback: every opt-out began as an inbound message whose body matched a stop keyword.
Then import through the suppression loop. A Bird suppression is one sender-and-subscriber pair, so a subscriber you stopped across three senders is three records. Reading and managing suppressions carries the command, and the reason a manual suppression blocks every category including transactional.
Decide who owns the list after cutover, because this is where you gain something and can lose track of it. Bird answers stop keywords from its own catalog per country, so once you are sending here the platform maintains suppressions for you: a subscriber who texts STOP produces a record with reason keyword_stop without your application doing anything. If your code keeps its own list and keeps enforcing it, the two drift, and the usual symptom is a subscriber who resumed on one side and not the other. Either retire your check and read suppressions as the source of truth, or keep yours authoritative and mirror deliberately. Reasons stack rather than merge, so a pair you imported as manual that later texts STOP holds two records, and messages stay stopped until both have ended.
Translate delivery statuses
| Outcome | Bandwidth callback type | Bird |
|---|---|---|
| API accepted the message | the 202 response, no event | sms.accepted |
| Handed to the carrier | message-sent | sms.sent |
| Carrier confirmed delivery | message-delivered | sms.delivered |
| Never reached the carrier | message-failed | sms.rejected |
| Carrier rejected it | message-failed | sms.failed |
| Carrier reported no receipt | message-failed | sms.undelivered |
| Carrier gave up | message-failed | sms.expired |
| Refused before sending | request error | sms.rejected |
Two things about that table are worth acting on rather than reading past.
Bandwidth sends either message-delivered or message-failed for an outbound message and never both, so a handler written against it assumes one terminal event. Bird's terminal events behave the same way, and that assumption survives the port.
But message-failed is one callback where Bird has four events, and the four are not interchangeable. It fires whether the message never reached the carrier, the carrier rejected it, the carrier accepted it and reported no receipt, or the carrier kept trying and gave up. Bird separates those into sms.rejected, sms.failed, sms.undelivered and sms.expired, and the split is on which side of the carrier the failure happened: sms.rejected is before a delivery attempt, the other three are the carrier's own delivery receipt. A handler that branches on message-failed and its error code gains four paths where it had one, so read Bird's event type first and treat the error code as detail within it.
message-sending has no row because it is MMS-only, and message-read is RBM-only; neither fires for SMS.
Two mechanics change with the names:
- Subscriptions replace the Application. Bandwidth routes callbacks by the applicationId the message named. Bird delivers to endpoints your workspace registers, each subscribed to the event types it wants, so a new consumer is a new subscription rather than a new Application and a redeploy.
- Standard Webhooks replaces their callback authentication. Bird sends JSON signed per Standard Webhooks; swap the verification for the recipe in Webhooks & events.
Register the endpoint once, naming the event types your handler wants: the sms.* events above are the list to subscribe to, and there is no wildcard that stands in for them. Create an endpoint has the command and the one thing to get right on the first call, which is storing the signing secret the response shows exactly once.
Cut over
Destinations, senders, and the traffic ramp are provider-independent and covered in the main guide. Two Bandwidth-specific items belong on the cutover plan: your 10DLC brand and campaign are registered with The Campaign Registry through Bandwidth, which is a direct Campaign Registry partner, and the registration does not transfer, so the same one is submitted again through Bird; and numbers you own at Bandwidth need a port that support arranges, on its own schedule rather than yours.
Because that is a fresh registration rather than a transfer, start from Register for 10DLC: it covers what each field means, the entity types the registry recognizes, and the requirements call that tells you what to supply before you create the brand, which is the chargeable step.
Next steps
- Sending SMS: the payload you are porting to, in full
- Opt-outs and keywords: keyword coverage per country and suppression management
- SMS events: the event vocabulary your callback handler moves to
- Webhooks & events: endpoint setup and Standard Webhooks verification