# Migrate SMS from Sinch

This page maps Sinch's SMS API, groups, and delivery reports to Bird. Follow the [main migration guide](/docs/guides/sms/migrate) in order and use these mappings for steps 3, 4, and 5.

Two structural differences shape the port, and both cost more than the field renames. Sinch keys the send on a service plan in the URL path and sends a **batch**, so one message to one person is still an array; Bird's [`POST /v1/sms/messages`](/docs/api/reference/create-sms-message) takes one recipient on your regional host with a bearer key and no plan segment. And the US registration you cannot skip is on a different host from the send, behind a different credential family, so a codebase that reaches Sinch for both is reaching two places.

## 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.

```text
I am moving an SMS integration from Sinch 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/sinch.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 Sinch usage in this repository before you change anything: the batches call sites and any SDK wrappers around them, the service plan IDs in the paths, every group ID I send to, the delivery-report handler, and anywhere I set delivery_report or a per-batch callback_url. Show me the list before you touch any of it.
4. Tell me early about three things, because each one changes the shape of the work rather than a field name. First, whether I send to GROUPS: my opt-out state may be recorded as absence from a group rather than as a list of opt-outs, and rebuilding it is the longest task here. Second, whether I rely on per-batch delivery_report granularity or a per-batch callback_url override, because Bird subscribes at the workspace level and I lose that per-send control. Third, whether I use my registration credentials anywhere in the same codebase as the send, since those are two different hosts and two different credential families on Sinch and become one host and one key on Bird.
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: work out where my opt-out state lives before importing anything, then follow 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 it becomes.
   - Sending and events: port the send call and repoint the delivery report, 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 Sinch 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       | Sinch                                         | Bird                                     |
| ------------------ | --------------------------------------------- | ---------------------------------------- |
| Recipient          | `to` (array, or a group ID)                   | `to` (one per request)                   |
| Sender             | `from`                                        | `from`                                   |
| Body               | `body`                                        | `text`                                   |
| Account routing    | service plan, in the URL path                 | the bearer key; no path segment          |
| Intent             | (none)                                        | `category`, required on free text        |
| Delivery reporting | `delivery_report` + `callback_url`, per batch | a workspace webhook; no per-send control |
| Correlation        | `client_reference`                            | `metadata`, echoed on every event        |
| Filterable labels  | (none)                                        | `tags`: `{name, value}` pairs            |
| Safe retries       | (none documented)                             | `Idempotency-Key` header                 |
| Flash              | `flash_message`                               | no equivalent                            |

Porting notes:

- **The batch collapses to one recipient.** `to` is an array on Sinch and a single number here, so a loop replaces the array and each call can carry its own `Idempotency-Key`. A batch that named a group needs the membership resolved first; see the opt-out section, because that is the same problem.
- **`body` becomes `text`.** It is the one rename that touches every call site.
- **`client_reference` is not an idempotency key.** Sinch defines it as an identifier added to the batch's delivery report, so it correlates but does not deduplicate. If you were relying on it to make a retry safe, you were not covered; `Idempotency-Key` is what does that here.
- **Nothing corresponds to `category`.** Decide per message type whether it is `transactional`, `marketing`, `authentication`, or `service`.

## Carry over opt-outs

**Sinch records who is in, and Bird needs to know who is out.** That inversion is the work.

Sinch manages recipients as groups, and a group can auto-update from keyword triggers, so a subscriber who texts `STOP` is removed from the group and a subscriber who texts `SUBSCRIBE` is added. The opt-out is therefore encoded as _absence_ from a list rather than presence on one, and absence is not exportable: a number missing from a group may have opted out, may never have joined, or may have been removed by an import six months ago.

So reconstruct rather than export. Your own inbound-message log is the reliable source, because every opt-out began as an inbound message whose body matched a stop keyword, and those messages exist whatever the group membership now says. Where you kept your own unsubscribe flag alongside the group, that flag is better evidence than the membership. Take the reconstructed list into the [suppression loop](/docs/guides/sms/migrate#4-carry-over-your-opt-out-list), and show the list to whoever owns the account before importing it: a wrong entry here silently stops mail you are meant to send.

A Bird suppression is one sender-and-subscriber pair, so a subscriber you stop across three senders is three records. [Reading and managing suppressions](/docs/guides/sms/opt-outs-and-keywords#reading-and-managing-suppressions) carries the command, and the reason a manual suppression blocks every category including transactional.

Once you are here, Bird answers the stop keywords itself from its own catalog per country, so the group auto-update behaviour has no counterpart to rebuild: a subscriber who texts `STOP` produces a suppression without your application doing anything. 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

| Sinch status | What Sinch means by it                | Bird              |
| ------------ | ------------------------------------- | ----------------- |
| `Queued`     | Queued in their system, awaiting rate | `sms.accepted`    |
| `Dispatched` | Accepted for delivery by the SMSC     | `sms.sent`        |
| `Delivered`  | Delivered                             | `sms.delivered`   |
| `Aborted`    | Aborted before reaching the SMSC      | `sms.rejected`    |
| `Expired`    | Expired before delivery to the SMSC   | `sms.rejected`    |
| `Rejected`   | Rejected by the SMSC                  | `sms.failed`      |
| (no status)  | no counterpart                        | `sms.undelivered` |
| (no status)  | no counterpart                        | `sms.expired`     |

**Their vocabulary stops at the SMSC and Bird's continues past it**, and the row to be careful with is the one where the same word means opposite things.

Sinch's `Expired` is a message that timed out in their queue _before_ delivery to the SMSC, so it never reached a carrier. Bird's `sms.expired` is the other end entirely: the carrier accepted the message, kept retrying, gave up, and reported that in its delivery receipt. Bird sets no validity window of its own and runs no timer that ends a message, so nothing here produces `sms.expired` for the reason Sinch produces `Expired`. Matching them by name is the easiest mistake to make on this table; a Sinch `Expired` belongs with the other pre-carrier outcomes.

The two Bird events with no Sinch counterpart run the same way. `sms.undelivered` is the carrier reporting a temporary non-delivery such as an unavailable handset, and `sms.expired` is the carrier giving up, and Sinch's batch statuses reach neither because they stop at the gateway. A handler ported straight across has no branch for either, and both are real outcomes here, so add them rather than letting them fall through a default.

Note also that intermediate statuses are only reported when the batch asked for `per_recipient` reporting, which is part of what changes below.

**You lose per-send control of delivery reporting, and it is worth saying plainly.** A Sinch batch chooses its own report granularity and can override the service plan's callback URL for that send alone. Bird has neither: reporting is a workspace subscription, every subscribed event is delivered, and there is no per-message override. If you were using `delivery_report` to keep chatty campaigns quiet, that filtering moves into your handler. If you were routing one campaign's reports to a different endpoint, that becomes one endpoint plus a branch, or a second subscription.

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. Bird sends JSON signed per [Standard Webhooks](https://www.standardwebhooks.com); [Create an endpoint](/docs/guides/webhooks#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.

Bird reports a failure with a standardized `error` code such as `invalid_destination`, `blocked_by_carrier`, `sender_unregistered`, or `recipient_opted_out`; the full list is on the [events page](/docs/guides/sms/events#failure-events).

## Cut over

[Destinations](/docs/guides/sms/migrate#1-enable-your-destination-countries), [senders](/docs/guides/sms/migrate#2-set-up-a-sender), and the [traffic ramp](/docs/guides/sms/migrate#6-test-against-simulated-destinations) are provider-independent and covered in the main guide. Two Sinch-specific items belong on the cutover plan.

Your 10DLC brand and campaign are registered with The Campaign Registry through Sinch and do not transfer, so the same registration is submitted again through Bird. **This is also where the integration simplifies.** On Sinch the registration API is a separate host from the send and takes project credentials rather than the service plan token, and Sinch's own documentation says HTTP Basic there is intended for test purposes only and is heavily rate limited, so a production integration builds an OAuth token flow for it. On Bird `/v1/sms/10dlc/*` sits beside `/v1/sms/messages` under one base URL and one key, so that token lifecycle is retired rather than ported. Start from [Register for 10DLC](/docs/guides/sms/10dlc), which covers what each field means and the requirements call that tells you what to supply before you create the brand, which is the chargeable step.

Numbers you own at Sinch need a port that support arranges, on its own schedule rather than yours.

## Next steps

- [Sending SMS](/docs/guides/sms/sending-sms): the payload you are porting to, in full
- [Opt-outs and keywords](/docs/guides/sms/opt-outs-and-keywords): keyword coverage per country and suppression management
- [SMS events](/docs/guides/sms/events): the event vocabulary your report handler moves to
- [Webhooks & events](/docs/guides/webhooks): endpoint setup and Standard Webhooks verification