> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# WhatsApp API migration parity map

> Migrate a Meta Cloud API WhatsApp integration to Orbit: endpoint, authentication, payload, template, media, and webhook mappings with before/after code for the calls your app actually makes.

# WhatsApp API migration parity map

If your app calls Meta's Cloud API directly, this page maps every call you make to its Orbit equivalent. The [WABA migration guide](/guides/whatsapp/waba-migration) moves the **account** (number, templates, quality rating); this page moves the **code** — endpoint by endpoint, payload shape by payload shape.

Most teams replace fewer than a dozen call sites. Find yours in the table, port the shape, and the rest of this page covers the webhook and media quirks that differ more than they look.

## Migration at a glance

| You had (Meta Cloud API) | You now have (Orbit) |
| - | - |
| App credentials + permanent access token | One API key (`dv_live_sk_…`), `X-API-Key` header |
| Per-message endpoint shape (`/{phone-number-id}/messages`) | One endpoint, `POST /api/v1/messages/whatsapp` |
| Meta webhook verify handshake (`GET` + `hub.challenge`) | Signed `POST` events only — no handshake |
| Phone number ID in every URL | Resolved server-side from your connected WABA |
| Template management through Meta's Graph API | Dashboard **Channels → WhatsApp → Templates**, or the sync call below |

## Authentication

```bash theme={null}
# Before — Meta Cloud API
curl -X GET "https://graph.facebook.com/v19.0/<phone-number-id>" \
  -H "Authorization: Bearer <permanent-access-token>"

# After — Orbit
curl -X GET "https://api.orbit.devotel.io/api/v1/messages/whatsapp/window-status?to=%2B14155552671&from=%2B18005551234" \
  -H "X-API-Key: dv_live_sk_..."
```

One key, no token rotation job, no app-review scope check before each release. Revoke keys from **Settings → API Keys** the moment a laptop or CI box is decommissioned — you stop editing server env for a new Meta token.

## Send messages

Meta nests `messaging_product`, `recipient_type`, and the type block under the send URL; Orbit flattens the shape and puts the type up top.

### Text message

```bash theme={null}
# Before — Meta Cloud API
curl -X POST "https://graph.facebook.com/v19.0/<phone-number-id>/messages" \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{
    "messaging_product": "whatsapp",
    "recipient_type": "individual",
    "to": "14155552671",
    "type": "text",
    "text": { "preview_url": false, "body": "Your order shipped." }
  }'

# After — Orbit
curl -X POST https://api.orbit.devotel.io/api/v1/messages/whatsapp \
  -H "X-API-Key: dv_live_sk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "to": "+14155552671",
    "type": "text",
    "text": { "body": "Your order shipped." }
  }'
```

Two deliberate lambda-cuts: `messaging_product` and `recipient_type` are gone (implied by the endpoint and the `to` field), and the phone number carries its `+` prefix — no regex cleanup code on your side.

### Template message

```bash theme={null}
# Before — Meta Cloud API
curl -X POST "https://graph.facebook.com/v19.0/<phone-number-id>/messages" \
  -H "Authorization: Bearer <token>" \
  -d '{
    "messaging_product": "whatsapp",
    "to": "14155552671",
    "type": "template",
    "template": {
      "name": "order_confirmation",
      "language": { "code": "en" },
      "components": [
        { "type": "body", "parameters": [
            { "type": "text", "text": "Ada" },
            { "type": "text", "text": "ORD-12345" }
        ]}
      ]
    }
  }'

# After — Orbit — the template block is byte-identical
curl -X POST https://api.orbit.devotel.io/api/v1/messages/whatsapp \
  -H "X-API-Key: dv_live_sk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "to": "+14155552671",
    "type": "template",
    "template": {
      "name": "order_confirmation",
      "language": { "code": "en" },
      "components": [
        { "type": "body", "parameters": [
            { "type": "text", "text": "Ada" },
            { "type": "text", "text": "ORD-12345" }
        ]}
      ]
    }
  }'
```

Template sends are a straight swap — keeping Meta's component schema means your existing `components` builders copy over without a rewrite.

### Media message

```bash theme={null}
# Before — Meta Cloud API
{
  "type": "image",
  "image": { "id": "<media-id-from-upload>" }
}

# After — Orbit — URL-first, no upload step
{
  "type": "image",
  "image": { "link": "https://cdn.example.com/menu.pdf" }
}
```

Meta forces a two-step (upload to their media endpoint → send the returned id). Orbit accepts a public URL directly; if you orchestrate signed/private content, upload to Orbit's files API first (`POST /api/v1/files/upload`) and pass the returned `data.url`. The [channel reference](/channels/whatsapp) lists every type block — image, video, audio, document, sticker, location, contacts — with the fields each accepts.

## Webhooks

| Meta Cloud API | Orbit |
| - | - |
| `GET` verify handshake + `hub.challenge` echo | None — Orbit does not call `GET` on your endpoint |
| `POST` with a `messages-statuses` envelope per WABA | One `POST` per event, each a single typed payload |
| `X-Hub-Signature-256` (HMAC of raw body, app secret) | `X-Orbit-Signature` (HMAC-SHA256 of raw body, your endpoint secret) |
| Retries Meta-shaped (5xx/backoff inside Meta's schedule) | Retries with exponential backoff, then dead-letter — [delivery semantics](/webhooks/events) |

### Inbound message

```json theme={null}
// Before — Meta Cloud API (nested under entry[].changes[].value)
{
  "entry": [{
    "changes": [{
      "field": "messages",
      "value": {
        "messaging_product": "whatsapp",
        "messages": [{
          "from": "14155552671",
          "id": "wamid.HBgM...",
          "timestamp": "1761594707",
          "type": "text",
          "text": { "body": "Can I change the address?" }
        }]
      }
    }]
  }]
}

// After — Orbit (flat, one event per webhook)
{
  "id": "evt_in_53kdp",
  "type": "message.received",
  "created_at": "2026-10-08T14:11:47Z",
  "data": {
    "message_id": "msg_2c7a09",
    "from": "+14155552671",
    "channel": "whatsapp",
    "type": "text",
    "text": "Can I change the delivery address before it ships?"
  }
}
```

The flatten is the part that deletes the most code: no `entry[].changes[].value[]` walker, no filtering for `field === "messages"`, no timestamp-string-to-ISO conversion. Your handler reads `type`, switches on it, done.

### Delivery lifecycle

Meta emits `statuses` entries inside the same envelope; Orbit emits one typed event per transition — `message.sent`, `message.delivered`, `message.read`, `message.failed` — each carrying the `message_id` you persisted when you sent. The full event catalogue and payload contracts are the [webhook events reference](/webhooks/events).

## Templates

| Task | Meta Cloud API | Orbit |
| - | - | - |
| Create | `POST /<waba-id>/message_templates` | Dashboard **Channels → WhatsApp → Templates → New Template** |
| Edit | `POST /<template-id>` (resubmit language) | Edit + submit from the dashboard |
| Sync to Orbit after BSP transfer | — | `POST /api/v1/whatsapp/templates/sync` |

After you migrate a WABA from another BSP, run the sync once so the dashboard template picker picks up what Meta already approved:

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/whatsapp/templates/sync \
  -H "X-API-Key: dv_live_sk_..."
```

Runtime selection is by `(name, language)` — the same pair Meta uses — so code that names templates works identically before and after.

## Session windows → window status and fallbacks

Meta's Cloud API rejects a free-form send outside the 24-hour window with `400` and an unstructured reason string. Orbit rejects it with `422` and a typed error body, and gives you the pre-flight:

```bash theme={null}
curl "https://api.orbit.devotel.io/api/v1/messages/whatsapp/window-status?to=%2B14155552671&from=%2B18005551234" \
  -H "X-API-Key: dv_live_sk_..."
# => { "data": { "window_open": true, "seconds_remaining": 41207 } }
```

That one `GET` is what turns "retry the template branch after every failure" into "compose the right message type first". The model is documented in [the 24-hour window](/guides/whatsapp/24h-window).

## Error surface

| Meta response | Orbit equivalent |
| - | - |
| `400` + `error.message` free-text | `422` with a typed `code` your client can switch on |
| `131030` recipient-not-allowed | `message.failed` webhook with `error.code = "131030"` |
| Rate limit `429` + Retry-After | `429` + Retry-After — same semantics, no change |

## Migration checklist

* [ ] Swap auth header `Authorization: Bearer` → `X-API-Key`; drop the token-rotation cron
* [ ] Replace `/{phone-number-id}/messages` URL builder with the single messages endpoint
* [ ] Move every `messaging_product` / `recipient_type` literal out of payloads
* [ ] Accept `+`-prefixed numbers end to end (retire the strip-the-plus normalizer)
* [ ] Replace the webhook verify `GET` handler with `X-Orbit-Signature` verification
* [ ] Replace the nested webhook walker with a `type` switch on `message.*` / `whatsapp.template.*`
* [ ] Swap Meta media-upload-then-send flows for URL-first sends
* [ ] Subscribe to `whatsapp.template.approved` / `.rejected` and delete the template-status poller
* [ ] Run the [getting-started walkthrough](/guides/whatsapp/onboarding) end to end against a dev key before cutting production keys

## See also

* [Get started with the WhatsApp Business Platform](/guides/whatsapp/getting-started) — the platform rules this map assumes
* [BSP transfer / number porting](/guides/whatsapp/waba-migration) — moving the WABA itself to Orbit
* [WhatsApp channel reference](/channels/whatsapp) — every request field and type block
* [Webhook events](/webhooks/events) — the full event catalogue your handler will switch on


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.