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

# Migrate from Twilio

Migrate your WhatsApp numbers from Twilio to Kapso.

## Step 1: Connect your number

1. In [WhatsApp Manager](https://business.facebook.com/latest/whatsapp_manager/phone_numbers), check the WABA for other numbers, templates, or assets you still need.
2. Remove the phone number from the WABA.
3. In [Meta Business Settings](https://business.facebook.com/latest/settings/whatsapp_account), open **Accounts → WhatsApp accounts**.
4. If the old WABA sits in your Business Portfolio, remove it.
5. Wait about five minutes.
6. In Kapso, start embedded signup. Create a new WABA when the flow asks.
7. Recreate your templates on the new WABA (see [Step 5](#step-5-templates)). Wait for Meta review.

The number stops sending after step 2, and it can send templates again once Meta approves them on the new WABA. Plan the migration accordingly.

See [Connect WhatsApp](/docs/how-to/whatsapp/connect-whatsapp) for the signup flow.

If Meta blocks the WABA removal over a pending balance, your old provider may still have a credit line attached to it. Meta Direct Support has to clear that. A different number on a new WABA unblocks you in the meantime.

If the reconnect fails, see [coexistence troubleshooting](/docs/how-to/whatsapp/coexistence-troubleshooting).

You can also start on a fresh number. [Instant setup](/docs/platform/phone-numbers/instant-setup) gives you a pre-verified US number, with no SMS verification step.

<Info>
  **Testing first?** The [sandbox](/docs/how-to/whatsapp/use-sandbox-for-testing) replaces Twilio's `join <code>` flow. Create a session for your test phone, then send the 6-character code from WhatsApp. You can then send and receive without a production number.
</Info>

## Step 2: Get your phone number IDs

```bash theme={null}
curl https://api.kapso.ai/platform/v1/whatsapp/phone_numbers \
  -H "X-API-Key: YOUR_API_KEY"
```

```json theme={null}
{
  "data": [
    {
      "id": "1234567890",
      "phone_number_id": "1234567890",
      "name": "Support Line",
      "business_account_id": "98765432109",
      "display_phone_number": "+1 555-123-4567",
      "quality_rating": "GREEN",
      "throughput_tier": "TIER_10K",
      "status": "CONNECTED"
    }
  ]
}
```

Store the mapping from `whatsapp:+E164` to `phone_number_id`.

Two IDs address different things:

| ID                    | Scopes to                      | Use it for                                              |
| --------------------- | ------------------------------ | ------------------------------------------------------- |
| `phone_number_id`     | one phone number               | sending messages, uploading media, registering webhooks |
| `business_account_id` | the WABA that owns the numbers | templates                                               |

Templates belong to the WABA, not to a number, so every number on that WABA can send them. That is why the template endpoints take the WABA ID in the path, as in `POST /{waba_id}/message_templates`.

## Step 3: Update message sending

For every message type, Kapso uses one endpoint: `POST /{phone_number_id}/messages`. The `type` field says which kind of message you are sending, and a field of that same name carries the content. A text message sets `type: "text"` and puts the body in `text`.

<Tabs>
  <Tab title="Template">
    Business-initiated sends, outside the 24-hour window. `ContentSid` becomes the template name, `ContentVariables` becomes Meta's `components` array.

    **Twilio:**

    ```bash theme={null}
    curl -X POST https://api.twilio.com/2010-04-01/Accounts/ACxxxx/Messages.json \
      -u ACxxxx:auth_token \
      -d "To=whatsapp:+15551234567" \
      -d "From=whatsapp:+15557654321" \
      -d "ContentSid=HXxxxxxxxxxxxx" \
      -d 'ContentVariables={"1": "John", "2": "ORD-123"}'
    ```

    **Kapso:**

    ```bash theme={null}
    curl -X POST https://api.kapso.ai/meta/whatsapp/v24.0/1234567890/messages \
      -H "X-API-Key: YOUR_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "messaging_product": "whatsapp",
        "to": "15551234567",
        "type": "template",
        "template": {
          "name": "order_update",
          "language": { "code": "en_US" },
          "components": [
            {
              "type": "body",
              "parameters": [
                { "type": "text", "text": "John" },
                { "type": "text", "text": "ORD-123" }
              ]
            }
          ]
        }
      }'
    ```

    Parameters are positional and must match the placeholder order. Named parameters work too. See [Simple text templates](/docs/whatsapp/templates/simple-text).
  </Tab>

  <Tab title="Text">
    Free-form replies, inside the 24-hour window.

    **Twilio:**

    ```bash theme={null}
    curl -X POST https://api.twilio.com/2010-04-01/Accounts/ACxxxx/Messages.json \
      -u ACxxxx:auth_token \
      -d "To=whatsapp:+15551234567" \
      -d "From=whatsapp:+15557654321" \
      -d "Body=Your order shipped!"
    ```

    **Kapso:**

    ```bash theme={null}
    curl -X POST https://api.kapso.ai/meta/whatsapp/v24.0/1234567890/messages \
      -H "X-API-Key: YOUR_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "messaging_product": "whatsapp",
        "to": "15551234567",
        "type": "text",
        "text": { "body": "Your order shipped!" }
      }'
    ```

    To reply to a specific message, add `"context": { "message_id": "wamid..." }`. To address a contact that has no phone number, use `recipient` with a [business-scoped user ID](/docs/whatsapp/business-scoped-user-ids) instead of `to`.
  </Tab>

  <Tab title="Media">
    `MediaUrl` becomes a typed object. Media is a message type, not a parameter on a text message.

    **Twilio:**

    ```bash theme={null}
    curl -X POST https://api.twilio.com/2010-04-01/Accounts/ACxxxx/Messages.json \
      -u ACxxxx:auth_token \
      -d "To=whatsapp:+15551234567" \
      -d "From=whatsapp:+15557654321" \
      -d "Body=Here's your receipt" \
      -d "MediaUrl=https://example.com/receipt.png"
    ```

    **Kapso:**

    ```bash theme={null}
    curl -X POST https://api.kapso.ai/meta/whatsapp/v24.0/1234567890/messages \
      -H "X-API-Key: YOUR_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "messaging_product": "whatsapp",
        "to": "15551234567",
        "type": "image",
        "image": {
          "link": "https://example.com/receipt.png",
          "caption": "Here'\''s your receipt"
        }
      }'
    ```

    `type` is `image`, `video`, `audio`, `document`, or `sticker`. Documents take a `filename`. Public URLs work directly. To upload bytes first, use `POST /{phone_number_id}/media` and pass the returned `id` instead of `link`. Kapso also ingests from a URL and hands back a Meta media ID via `POST /platform/v1/whatsapp/media`.
  </Tab>

  <Tab title="Interactive">
    Twilio expresses rich messages as Content API types. Kapso takes Meta's `interactive` object inline.

    | Twilio content type       | Kapso                                                  |
    | ------------------------- | ------------------------------------------------------ |
    | `twilio/text`             | `type: "text"`                                         |
    | `twilio/media`            | `type: "image"` / `video` / `document`                 |
    | `twilio/quick-reply`      | `interactive.type: "button"`                           |
    | `twilio/list-picker`      | `interactive.type: "list"`                             |
    | `twilio/call-to-action`   | `interactive.type: "cta_url"`                          |
    | `twilio/card`             | Media header + buttons, or a template                  |
    | `twilio/carousel`         | [Carousel template](/docs/whatsapp/templates/carousel) |
    | `twilio/location`         | `type: "location"`                                     |
    | `whatsapp/authentication` | `AUTHENTICATION` template                              |
    | `whatsapp/flows`          | `interactive.type: "flow"`                             |

    Quick replies:

    ```bash theme={null}
    curl -X POST https://api.kapso.ai/meta/whatsapp/v24.0/1234567890/messages \
      -H "X-API-Key: YOUR_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "messaging_product": "whatsapp",
        "to": "15551234567",
        "type": "interactive",
        "interactive": {
          "type": "button",
          "body": { "text": "Confirm your appointment?" },
          "action": {
            "buttons": [
              { "type": "reply", "reply": { "id": "btn_yes", "title": "Yes" } },
              { "type": "reply", "reply": { "id": "btn_no", "title": "No" } }
            ]
          }
        }
      }'
    ```

    Taps arrive on `whatsapp.message.received` as an `interactive` message with `button_reply.id`, Twilio's `ButtonPayload`. Full shapes in [Send buttons](/docs/whatsapp/send-messages/buttons) and [Send lists](/docs/whatsapp/send-messages/lists).
  </Tab>

  <Tab title="Read and typing">
    Twilio has no equivalent. Kapso passes Meta's through, on the same endpoint:

    ```bash theme={null}
    curl -X POST https://api.kapso.ai/meta/whatsapp/v24.0/1234567890/messages \
      -H "X-API-Key: YOUR_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "messaging_product": "whatsapp",
        "status": "read",
        "message_id": "wamid.HBgNMTU1NTE0OTU5Nzg1...",
        "typing_indicator": { "type": "text" }
      }'
    ```

    Reactions are `type: "reaction"`. See [Mark as read](/docs/whatsapp/send-messages/mark-read) and [Send reaction](/docs/whatsapp/send-messages/reaction).
  </Tab>
</Tabs>

The [TypeScript SDK](/docs/whatsapp/typescript-sdk/introduction) wraps all of this, so you do not have to write the envelopes by hand.

## Step 4: Update webhooks

Kapso posts JSON and expects a `200`. There is no TwiML, so auto-replies move into your handler or into a [workflow](/docs/workflows/introduction).

Register per phone number:

```bash theme={null}
curl -X POST https://api.kapso.ai/platform/v1/whatsapp/phone_numbers/1234567890/webhooks \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "whatsapp_webhook": {
      "url": "https://yourapp.com/webhooks/whatsapp",
      "events": [
        "whatsapp.message.received",
        "whatsapp.message.sent",
        "whatsapp.message.delivered",
        "whatsapp.message.read",
        "whatsapp.message.failed"
      ],
      "secret_key": "your-signing-secret"
    }
  }'
```

For connection lifecycle events (a customer finishing a setup link, Meta disabling a WABA), use [project webhooks](/docs/platform/webhooks/project-webhooks) instead. You configure those once for the whole project.

### Field mapping

| Twilio param                       | Kapso (`whatsapp.message.received`)                  |
| ---------------------------------- | ---------------------------------------------------- |
| `MessageSid`                       | `message.id`                                         |
| `From` (`whatsapp:+1...`) / `WaId` | `message.from`                                       |
| `To`                               | `phone_number_id`                                    |
| `ProfileName`                      | `conversation.contact_name`                          |
| `Body`                             | `message.text.body`                                  |
| `NumMedia` / `MediaUrl0`           | `message.kapso.has_media`, `message.kapso.media_url` |
| `ButtonText` / `ButtonPayload`     | `message.interactive.button_reply.title` / `.id`     |
| `Latitude` / `Longitude`           | `message.location`                                   |
| `MessageStatus`                    | The event name, plus `message.kapso.statuses`        |

`conversation` threads the messages for you, but it does not track the 24-hour window. A send can fail with `131047` while `conversation.status` is `active`. Read `conversation.kapso.last_inbound_at`, or handle the rejection and fall back to a template.

`from` is not always present. WhatsApp can identify a contact with `business_scoped_user_id` instead. See [business-scoped user IDs](/docs/whatsapp/business-scoped-user-ids).

### Signature verification

Twilio signs the request URL plus every form field, so verification needs all of them. Twilio's helper assembles them for you. Kapso signs only the request body. Verification is one HMAC over the bytes you received.

**Twilio:**

```javascript theme={null}
const twilio = require('twilio');

app.post('/webhooks/whatsapp', express.urlencoded({ extended: false }), (req, res) => {
  const valid = twilio.validateRequest(
    process.env.TWILIO_AUTH_TOKEN,
    req.headers['x-twilio-signature'],
    'https://yourapp.com/webhooks/whatsapp',  // must match exactly
    req.body                                  // every param, sorted for you
  );

  if (!valid) return res.status(403).send('Invalid signature');
  res.type('text/xml').send('<Response/>');
});
```

**Kapso:**

```javascript theme={null}
const crypto = require('crypto');

// express.raw, not express.json: Kapso signs the exact bytes it sent, and
// re-serializing a parsed body does not reproduce them
app.post('/webhooks/whatsapp', express.raw({ type: 'application/json' }), (req, res) => {
  const expected = crypto
    .createHmac('sha256', process.env.WEBHOOK_SECRET)
    .update(req.body)
    .digest('hex');

  const a = Buffer.from(expected, 'utf8');
  const b = Buffer.from(req.headers['x-webhook-signature'] ?? '', 'utf8');

  // timingSafeEqual throws on length mismatch, so compare lengths first
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    return res.status(401).send('Invalid signature');
  }

  const event = JSON.parse(req.body);
  res.sendStatus(200);
});
```

No URL goes into the hash, so your endpoint can move or sit behind a proxy without breaking verification.

### Delivery behavior

|          | Twilio          | Kapso                                             |
| -------- | --------------- | ------------------------------------------------- |
| Retries  | None by default | 3, at 10s / 40s / 90s                             |
| Timeout  | 15s             | 10s                                               |
| Dedup    | `MessageSid`    | `X-Idempotency-Key` header                        |
| Batching | None            | Optional buffering on `whatsapp.message.received` |

Delivery is at-least-once. Dedupe on `X-Idempotency-Key`. See [Advanced](/docs/platform/webhooks/advanced) for buffering and ordering, and [Security](/docs/platform/webhooks/security) for verification in Python and Ruby.

<Note>
  Do you parse raw Meta payloads elsewhere? Register the webhook with `"kind": "meta"` and Kapso forwards Meta's exact payload with no reshaping.
</Note>

## Step 5: Templates

Twilio needs two calls: create the content, then request WhatsApp approval. Kapso submits on create.

**Twilio:**

```bash theme={null}
curl -X POST https://content.twilio.com/v1/Content \
  -u ACxxxx:auth_token \
  -H "Content-Type: application/json" \
  -d '{
    "friendly_name": "order_update",
    "language": "en",
    "variables": {"1": "John"},
    "types": {"twilio/text": {"body": "Hi {{1}}, your order is confirmed."}}
  }'

curl -X POST https://content.twilio.com/v1/Content/HXxxxx/ApprovalRequests/whatsapp \
  -u ACxxxx:auth_token \
  -d '{"name": "order_update", "category": "UTILITY"}'
```

**Kapso:**

```bash theme={null}
curl -X POST https://api.kapso.ai/meta/whatsapp/v24.0/98765432109/message_templates \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "order_update",
    "category": "UTILITY",
    "language": "en_US",
    "components": [
      {
        "type": "BODY",
        "text": "Hi {{1}}, your order is confirmed.",
        "example": { "body_text": [["John"]] }
      }
    ]
  }'
```

The path takes the WABA ID, not the phone number ID.

If you build templates in WhatsApp Manager, pull them into Kapso from **WhatsApp → Templates → Sync from WhatsApp**. See [Template lifecycle](/docs/whatsapp/templates/lifecycle).

## Multi-tenant setups

Twilio subaccounts become customers, and each customer connects their own number through a setup link instead of you provisioning a sender per subaccount:

```bash theme={null}
curl -X POST https://api.kapso.ai/platform/v1/customers \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"customer": {"name": "Acme Corporation", "external_customer_id": "CUS-12345"}}'

curl -X POST https://api.kapso.ai/platform/v1/customers/CUSTOMER_ID/setup_links \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "setup_link": {
      "success_redirect_url": "https://your-app.com/whatsapp/success",
      "meta_billing_mode": "partner_managed"
    }
  }'
```

Send your customer the returned `url`. They log in with Facebook and connect in about five minutes. You then get `whatsapp.phone_number.created` on your project webhook, with the `customer.id` and `phone_number_id`. See [Onboard customers](/docs/platform/customer-guide) and [Connection detection](/docs/platform/setup-links/detect-connection).

One API key covers every customer, so there is no per-subaccount credential juggling.

## Bulk sends

If you loop over recipients yourself, or drive bulk sends from Studio, use [broadcasts](/docs/platform/broadcasts/api) instead. Create, add recipients with per-recipient template parameters, send, poll:

```bash theme={null}
POST /platform/v1/whatsapp/broadcasts
POST /platform/v1/whatsapp/broadcasts/{id}/recipients
POST /platform/v1/whatsapp/broadcasts/{id}/send
GET  /platform/v1/whatsapp/broadcasts/{id}
```

Kapso paces broadcasts internally to stay inside Meta's throughput limits, so you do not throttle them yourself. `POST /{id}/schedule` sends later, `POST /{id}/cancel` stops one in flight.

## Keep your Twilio account for numbers

You do not have to leave Twilio to leave Twilio's WhatsApp API. Point Kapso at your Twilio credentials and it provisions new numbers into your Twilio account, billed to you. You choose the countries, and each one can keep a pool of reusable pre-verified numbers. This covers provisioning, not the numbers your Twilio WhatsApp senders already use. Those still follow [Step 1](#step-1-connect-your-number).

This is an Enterprise feature, or a paid add-on on other plans. See [Provide local numbers](/docs/platform/phone-numbers/provide-local-numbers).

## Feature map

| Feature                 | Twilio                           | Kapso                                                                                                           |
| ----------------------- | -------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| WhatsApp messaging      | Messages + Content API           | [Meta-shaped API](/docs/whatsapp/send-messages/text)                                                            |
| Interactive messages    | Content types                    | [Buttons](/docs/whatsapp/send-messages/buttons), [lists](/docs/whatsapp/send-messages/lists), CTAs              |
| Templates               | Content API + approval requests  | [`/{waba_id}/message_templates`](/docs/whatsapp/templates/simple-text)                                          |
| WhatsApp Flows          | `whatsapp/flows` content type    | [Full Flows API](/docs/whatsapp/flows/overview), including hosted data endpoints                                |
| Bulk messaging          | Your own loop                    | [Broadcasts](/docs/platform/broadcasts/overview), with CSV upload and scheduling                                |
| Conversation automation | Studio                           | [Workflows](/docs/workflows/introduction) with AI steps and human handoff                                       |
| Team inbox              | Flex, a separate contract        | [Included](/docs/platform/inbox/overview), plus an [embeddable iframe](/docs/platform/inbox/embedded)           |
| Multi-tenant onboarding | Subaccounts you provision        | [Setup links](/docs/platform/setup-links/create-and-configure) your customer completes                          |
| Message history         | Messages resource                | [`/whatsapp/messages`](/docs/platform/whatsapp-data), `/whatsapp/conversations`                                 |
| Marketing opt-outs      | Advanced Opt-Out keywords        | [Automatic](/docs/whatsapp/templates/marketing-opt-outs), from WhatsApp's native control                        |
| WhatsApp calling        | Supported                        | [Supported](/docs/whatsapp/typescript-sdk/calls), with call logs                                                |
| Business profile        | API                              | [Display names](/docs/whatsapp/display-names), [usernames](/docs/whatsapp/business-usernames)                   |
| Sandbox                 | `join <code>` on a shared number | [6-character code](/docs/how-to/whatsapp/use-sandbox-for-testing)                                               |
| Serverless code         | Twilio Functions                 | [Functions](/docs/functions/overview) on Cloudflare Workers, as workflow steps, agent tools, or plain endpoints |
| Rate limits             | Per-number throughput            | [100-2,000 req/min by plan](/api/rate-limits)                                                                   |

Things Kapso adds:

* An inbox for your team
* [CTWA attribution](/docs/platform/whatsapp-data) and [contact properties](/docs/platform/inbox/overview)
* Automatic voice-note transcription
* An [MCP server](/docs/whatsapp/mcp) and a [CLI](/docs/whatsapp/cli)
* [Findings](/docs/platform/findings), which reads ended conversations with AI and groups the recurring problems

## What does not map

* **Messaging Services.** No sender pools, sticky sender, or geomatch. You send from a specific `phone_number_id`.
* **TwiML.** Auto-replies move into your webhook handler or a [workflow](/docs/workflows/introduction).
* **Link shortening and click tracking.**
* **Conversation tags.** Organize in the inbox UI, not the API.

## Cutover

Migrate one number at a time. A number's WhatsApp registration and webhook routing move as a unit, so there is no gradual split per number.

| Phase   | Actions                                                                                                                                             |
| ------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| Prep    | Create the project and API key, build against a [sandbox](/docs/how-to/whatsapp/use-sandbox-for-testing) number, export message history from Twilio |
| Pilot   | Move one low-traffic number end to end. Budget for the time the number is down, and for Meta's template review                                      |
| Rollout | Migrate remaining numbers in batches; for multi-tenant, one customer at a time                                                                      |
| Cutoff  | Stop Twilio sends for each migrated number, then release the sender in Twilio                                                                       |

One thing needs no migration. Meta bills message charges against the WABA on either platform, per delivered template message, at category and market rates. See [pricing](/docs/whatsapp/pricing-faq).

<Warning>
  Message history does not transfer. Twilio keeps it in its Messages resource. Export what you need before you close the account.
</Warning>

## Troubleshooting

| Symptom                               | Twilio equivalent              | Fix                                                                                                                      |
| ------------------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------ |
| `401 Unauthorized`                    | Basic auth credentials         | Send `X-API-Key: YOUR_API_KEY`                                                                                           |
| Body rejected                         | Form-encoded params            | Send JSON with `Content-Type: application/json`                                                                          |
| `(#131047)` re-engagement message     | Error 63016                    | Outside the 24-hour window. Send an approved template                                                                    |
| Number cannot be connected            | None                           | It is still on another BSP's WABA; migrate it in WhatsApp Manager                                                        |
| Template variables ignored            | `ContentVariables` JSON string | Use Meta `components` with a `parameters` array, in placeholder order                                                    |
| Template not found                    | `ContentSid`                   | Templates are addressed by `name` + `language`, and must exist on this WABA                                              |
| Webhook signature mismatch            | URL + sorted params, HMAC-SHA1 | HMAC-SHA256 over the raw body, checked against `X-Webhook-Signature`                                                     |
| Webhook keeps retrying                | TwiML response expected        | Return `200` within 10 seconds; the body is ignored                                                                      |
| Marketing template refused with `422` | Advanced Opt-Out               | The contact stopped marketing on that number. Check [marketing preferences](/docs/whatsapp/templates/marketing-opt-outs) |
| `429`                                 | `429 Too Many Requests`        | Back off on `Retry-After`; see [rate limits](/api/rate-limits)                                                           |

## Node.js example

```javascript theme={null}
const crypto = require('crypto');
const express = require('express');

const app = express();

// Any durable queue works. The point is that the job outlives the request.
const queue = require('./queue');

const KAPSO = 'https://api.kapso.ai/meta/whatsapp/v24.0';
const headers = {
  'X-API-Key': process.env.KAPSO_API_KEY,
  'Content-Type': 'application/json',
};

// Twilio: client.messages.create({ to, from, contentSid, contentVariables })
async function sendTemplate({ phoneNumberId, to, name, params = [] }) {
  const res = await fetch(`${KAPSO}/${phoneNumberId}/messages`, {
    method: 'POST',
    headers,
    body: JSON.stringify({
      messaging_product: 'whatsapp',
      to,
      type: 'template',
      template: {
        name,
        language: { code: 'en_US' },
        components: [
          {
            type: 'body',
            parameters: params.map((text) => ({ type: 'text', text })),
          },
        ],
      },
    }),
  });
  const body = await res.json();
  return body.messages[0].id; // wamid...
}

// Twilio: client.messages.create({ to, from, body })
async function sendText({ phoneNumberId, to, text }) {
  const res = await fetch(`${KAPSO}/${phoneNumberId}/messages`, {
    method: 'POST',
    headers,
    body: JSON.stringify({
      messaging_product: 'whatsapp',
      to,
      type: 'text',
      text: { body: text },
    }),
  });
  const body = await res.json();
  return body.messages[0].id;
}

// Twilio: form-encoded POST, reply with TwiML.
// express.raw, not express.json: the signature covers the bytes Kapso sent.
app.post('/webhooks/whatsapp', express.raw({ type: 'application/json' }), async (req, res) => {
  const expected = crypto
    .createHmac('sha256', process.env.WEBHOOK_SECRET)
    .update(req.body)
    .digest('hex');

  const a = Buffer.from(expected, 'utf8');
  const b = Buffer.from(req.headers['x-webhook-signature'] ?? '', 'utf8');

  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    return res.status(401).send('Invalid signature');
  }

  if (req.headers['x-webhook-event'] !== 'whatsapp.message.received') {
    return res.sendStatus(200);
  }

  const { message, conversation, phone_number_id } = JSON.parse(req.body);

  // Enqueue before acknowledging. A 200 tells Kapso the event is handled, so
  // anything that throws after this point is lost rather than retried.
  await queue.add('inbound', {
    idempotencyKey: req.headers['x-idempotency-key'],
    from: message.from,
    text: message.text?.body,
    conversationId: conversation.id,
    phoneNumberId: phone_number_id,
  });

  res.sendStatus(200);
});
```

## Need help

* [Send messages](/docs/whatsapp/send-messages/text) and [webhooks](/docs/platform/webhooks/overview)
* [API reference](/api/introduction)
* [WhatsApp support](https://wa.me/16266694464?text=Hi!%20I%20need%20help%20migrating%20from%20Twilio)
