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

Migrate your WhatsApp numbers from YCloud to Kapso.

## Step 1: Connect your number

1. In YCloud's [WhatsApp accounts](https://www.ycloud.com/console/#/app/dashboard/account), find which WABA owns the number, then confirm its owner in [Meta Business Settings](https://business.facebook.com/latest/settings/whatsapp_account). If YCloud or an agency owns it, only they can release the number.
2. In [WhatsApp Manager](https://business.facebook.com/latest/whatsapp_manager/phone_numbers), check the WABA for other numbers, templates, or assets you still need.
3. Confirm you are an admin of the owning Business Portfolio, which Meta requires for removal.
4. Under **Account tools → Insights**, confirm the number has not sent a paid message in the last 30 days.
5. Turn off two-step verification on the number, or re-registration will ask for a PIN.
6. Remove the phone number from the WABA.
7. Wait about five minutes.
8. In Kapso, start embedded signup. Share your existing WABA, or create one when the flow asks.
9. Recreate your templates if you created a new WABA (see [Step 5](#step-5-templates)). Wait for Meta review.

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

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

Migrating does not touch the old WABA. If you retire it later and Meta blocks removal over a pending balance, YCloud may still have a credit line attached, which Meta Direct Support has to clear.

If the reconnect fails, see [coexistence troubleshooting](/docs/how-to/whatsapp/coexistence-troubleshooting) and [bring your own SIM troubleshooting](/docs/how-to/whatsapp/bring-your-own-sim-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?** Build against a [sandbox](/docs/how-to/whatsapp/use-sandbox-for-testing) number while YCloud still carries production traffic. Create a session for your test phone, then send the 6-character code from WhatsApp.
</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 `+E164` to `phone_number_id`. It replaces every `"from": "+1555..."` in your send code.

| YCloud                   | Kapso                         | Scopes to                      |
| ------------------------ | ----------------------------- | ------------------------------ |
| `from` (E.164, with `+`) | `phone_number_id` in the path | one phone number               |
| `wabaId`                 | `business_account_id`         | the WABA that owns the numbers |

YCloud takes `wabaId` in the request body for templates and flows. Kapso takes it in the path, as in `POST /{waba_id}/message_templates`. Templates belong to the WABA in both, so every number on that WABA can send them.

## Step 3: Update message sending

YCloud has two send endpoints. `POST /v2/whatsapp/messages` queues the message and returns `accepted`, and `POST /v2/whatsapp/messages/sendDirectly` submits to Meta and waits.

Kapso has one: `POST /{phone_number_id}/messages`. It submits to Meta synchronously and returns the `wamid`, so it behaves like `sendDirectly`. If your code branches between the two YCloud endpoints, both branches collapse into this one.

Three things change in every call:

* `from` moves out of the body and into the path, as `phone_number_id`.
* `messaging_product: "whatsapp"` is required.
* `to` is written as bare digits by convention, but your existing E.164 values work unchanged. Kapso forwards the request body to Meta byte for byte, and Meta accepts the leading `+`.

The content object itself does not change. YCloud passes Meta's shapes through, and so does Kapso.

<Tabs>
  <Tab title="Template">
    Business-initiated sends, outside the 24-hour window.

    **YCloud:**

    ```bash theme={null}
    curl -X POST https://api.ycloud.com/v2/whatsapp/messages \
      -H "X-API-Key: YOUR_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "from": "+15557654321",
        "to": "+15551234567",
        "type": "template",
        "externalId": "ORD-123",
        "template": {
          "name": "order_update",
          "language": { "code": "en_US" },
          "components": [
            {
              "type": "body",
              "parameters": [{ "type": "text", "text": "John" }]
            }
          ]
        }
      }'
    ```

    **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",
        "biz_opaque_callback_data": "ORD-123",
        "template": {
          "name": "order_update",
          "language": { "code": "en_US" },
          "components": [
            {
              "type": "body",
              "parameters": [{ "type": "text", "text": "John" }]
            }
          ]
        }
      }'
    ```

    The `template` object is identical. `externalId` becomes `biz_opaque_callback_data`, capped at 512 characters and echoed back on status webhooks. See [Simple text templates](/docs/whatsapp/templates/simple-text).
  </Tab>

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

    **YCloud:**

    ```bash theme={null}
    curl -X POST https://api.ycloud.com/v2/whatsapp/messages/sendDirectly \
      -H "X-API-Key: YOUR_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "from": "+15557654321",
        "to": "+15551234567",
        "type": "text",
        "text": { "body": "Your order shipped!", "preview_url": false }
      }'
    ```

    **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!", "preview_url": false }
      }'
    ```

    `context.message_id` works the same way, and takes a wamid in both. YCloud's `recipient` field for [business-scoped user IDs](/docs/whatsapp/business-scoped-user-ids) is also called `recipient` in Kapso, and takes the same `US.1234...` values. See [Send text](/docs/whatsapp/send-messages/text).
  </Tab>

  <Tab title="Media">
    Images, video, audio, documents, and stickers. The object is identical in both.

    **YCloud:**

    ```bash theme={null}
    curl -X POST https://api.ycloud.com/v2/whatsapp/messages \
      -H "X-API-Key: YOUR_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "from": "+15557654321",
        "to": "+15551234567",
        "type": "image",
        "image": { "link": "https://example.com/receipt.png", "caption": "Your receipt" }
      }'
    ```

    **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": "Your receipt" }
      }'
    ```

    Uploads move too. `POST /v2/whatsapp/media/{phoneNumber}/upload` becomes `POST /{phone_number_id}/media`, addressed by ID instead of phone number, and returns a Meta media ID you pass as `id` instead of `link`. Kapso also ingests from a URL and hands back a media ID via `POST /platform/v1/whatsapp/media`.
  </Tab>

  <Tab title="Interactive">
    Buttons, lists, CTAs, and Flows all use Meta's `interactive` object in both platforms, so these payloads move across as-is:

    ```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`, the same shape YCloud delivers on `whatsapp.inbound_message.received`. See [Send buttons](/docs/whatsapp/send-messages/buttons), [Send lists](/docs/whatsapp/send-messages/lists), and [Sending Flows](/docs/whatsapp/flows/sending-flows).
  </Tab>

  <Tab title="Read and typing">
    YCloud marks a message read on a dedicated endpoint, keyed by the inbound message ID. Kapso uses the same send endpoint, with Meta's status body.

    **YCloud:**

    ```bash theme={null}
    curl -X POST https://api.ycloud.com/v2/whatsapp/inboundMessages/wamid.HBgNMTU1.../markAsRead \
      -H "X-API-Key: YOUR_API_KEY"
    ```

    **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",
        "status": "read",
        "message_id": "wamid.HBgNMTU1NTE0OTU5Nzg1...",
        "typing_indicator": { "type": "text" }
      }'
    ```

    Keep `typing_indicator` to mark the message read and show the indicator. Drop it to only mark read. YCloud has a separate endpoint for each. See [Mark as read](/docs/whatsapp/send-messages/mark-read).

    Reactions are their own message type, `type: "reaction"`. See [Send reaction](/docs/whatsapp/send-messages/reaction).
  </Tab>
</Tabs>

YCloud returns its own object ID on send, and the Meta `wamid` only later, on the `sent` status webhook. Kapso returns the wamid immediately:

```json theme={null}
{ "messaging_product": "whatsapp", "messages": [{ "id": "wamid.HBgNMTU1..." }] }
```

If your database keys messages on YCloud's `id`, switch that column to the wamid. Everything that took a wamid on YCloud (`context.message_id`, mark-as-read, reactions) keeps taking one.

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

YCloud webhooks are account-wide: one endpoint receives every event for every number, and you route in your own handler. Which field carries your number depends on the event:

| YCloud event family                        | Your number is in                                                 |
| ------------------------------------------ | ----------------------------------------------------------------- |
| Inbound messages                           | `whatsappInboundMessage.to`                                       |
| Outbound status updates                    | `whatsappMessage.from`, with `.to` holding the customer           |
| Account, template, and phone-number events | Neither. Switch on `wabaId` or the phone number in the event body |

Kapso registers webhooks per phone number, so that routing disappears: the endpoint you register only ever receives that number's events.

```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"
    }
  }'
```

You choose the signing secret. YCloud generates one and returns it on create.

Account-level events (a WABA ban, a number offboarded, a customer finishing a setup link) arrive on [project webhooks](/docs/platform/webhooks/project-webhooks) instead. You configure those once for the whole project.

### Event mapping

| YCloud event                                                              | Kapso                                                                                    |
| ------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| `whatsapp.inbound_message.received`                                       | `whatsapp.message.received`                                                              |
| `whatsapp.message.updated`, `status: sent`                                | `whatsapp.message.sent`                                                                  |
| `whatsapp.message.updated`, `status: delivered`                           | `whatsapp.message.delivered`                                                             |
| `whatsapp.message.updated`, `status: read`                                | `whatsapp.message.read`                                                                  |
| `whatsapp.message.updated`, `status: failed`                              | `whatsapp.message.failed`                                                                |
| `whatsapp.business_account.updated`                                       | By `updateEvent`. See [below](#account-and-number-events)                                |
| `whatsapp.business_account.deleted`                                       | `whatsapp.phone_number.disconnected` for partner removal. None for a real deletion       |
| `whatsapp.phone_number.deleted`                                           | `whatsapp.phone_number.deleted`, `.offboarded`, or `.disconnected`, by cause             |
| `whatsapp.template.reviewed` / `.category_updated` / `.quality_updated`   | None. Poll `GET /{waba_id}/message_templates`                                            |
| `whatsapp.phone_number.quality_updated` / `.name_updated`                 | None                                                                                     |
| `whatsapp.user.preferences`                                               | `whatsapp.contact.marketing_preference_changed`                                          |
| `contact.unsubscribe.created` / `.deleted`                                | None. YCloud's own list, not Meta's. See [Contacts and opt-outs](#contacts-and-opt-outs) |
| `whatsapp.payment.updated`                                                | None                                                                                     |
| `whatsapp.business_account.reviewed`                                      | None. Dashboard only, or a [raw Meta webhook](#raw-meta-webhooks)                        |
| `contact.created` / `.deleted`                                            | None. Contacts appear silently, on first message or via the API                          |
| `contact.attributes_changed`                                              | None. Patching `metadata` fires nothing                                                  |
| `contact.note.created` / `.updated` / `.deleted`                          | None                                                                                     |
| `whatsapp.phone_number.business_username_updated`                         | None. Read `GET /{phone_number_id}/username`                                             |
| `whatsapp.flow.status_change`                                             | None. [Flows API](/docs/whatsapp/flows/overview), or a raw Meta webhook                  |
| `whatsapp.call.connect` / `.status.updated` / `.terminate`                | None named. Meta events, so a [raw Meta webhook](#raw-meta-webhooks) carries them        |
| `whatsapp.call.recording.updated` / `whatsapp.call.transcription.updated` | None, and no fallback. YCloud's own pipeline, never sent by Meta                         |
| `whatsapp.group.*`, `whatsapp.smb.*`                                      | None                                                                                     |
| `sms.*`, `voice.*`, `email.*`                                             | None. See [What does not map](#what-does-not-map)                                        |
| None                                                                      | `whatsapp.conversation.created`, `.ended`, `.inactive`                                   |
| None                                                                      | `whatsapp.contact.identity_changed`                                                      |

YCloud dispatches one event for delivery and asks you to switch on `whatsappMessage.status`. Kapso dispatches one per status, named in the `X-Webhook-Event` header. The switch moves from the body to the header.

### Account and number events

`whatsapp.business_account.updated` carries seven `updateEvent` values, on [project webhooks](/docs/platform/webhooks/project-webhooks):

| `updateEvent`                                                                     | Kapso                                                      |
| --------------------------------------------------------------------------------- | ---------------------------------------------------------- |
| `DISABLED_UPDATE`                                                                 | `whatsapp.account.disabled` or `.reinstated`, by ban state |
| `ACCOUNT_RESTRICTION`                                                             | `whatsapp.account.restricted`                              |
| `ACCOUNT_VIOLATION`                                                               | `whatsapp.account.violation`                               |
| `PARTNER_REMOVED` / `PARTNER_APP_UNINSTALLED`                                     | `whatsapp.phone_number.disconnected`                       |
| `AUTH_INTL_PRICE_ELIGIBILITY_UPDATE` / `BUSINESS_PRIMARY_LOCATION_COUNTRY_UPDATE` | None                                                       |

Since 2026-06-10 YCloud sends partner removal as `business_account.deleted` instead, so handle both. Its `removedReason`, `removedInitiatedBy`, and `removedTime` are optional, so their absence does not prove a real deletion.

### Raw Meta webhooks

Kapso can also forward every Meta webhook for a number, unreshaped. Register a second webhook with `"kind": "meta"`:

```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": {
      "kind": "meta",
      "url": "https://yourapp.com/webhooks/meta",
      "secret_key": "your-signing-secret"
    }
  }'
```

Payloads are Meta's, so this needs its own handler. See [Webhooks overview](/docs/platform/webhooks/overview).

### Field mapping

Inbound, from `whatsappInboundMessage` to Kapso's `whatsapp.message.received`:

| YCloud                                    | Kapso                                                                                            |
| ----------------------------------------- | ------------------------------------------------------------------------------------------------ |
| `wamid`                                   | `message.id`                                                                                     |
| `id` (YCloud's own ID)                    | None. Kapso keys on the wamid                                                                    |
| `from` (`+E164`)                          | `message.from` (no `+`)                                                                          |
| `fromUserId` / `fromParentUserId`         | `message.from_user_id` / `message.from_parent_user_id`                                           |
| `customerProfile.name`                    | `conversation.contact_name`                                                                      |
| `customerProfile.username`                | `conversation.username`                                                                          |
| `to` (`+E164`)                            | `phone_number_id`, at the top level                                                              |
| `sendTime` (RFC 3339)                     | `message.timestamp` (Unix epoch string)                                                          |
| `type`, `text`, `interactive`, `location` | Same names, same shapes                                                                          |
| `image.link`                              | `message.kapso.media_url`, plus `media_data`                                                     |
| `referral`                                | On the payload when present. See [Referrals (CTWA)](/docs/platform/whatsapp-data#referrals-ctwa) |
| None                                      | `conversation.id`, `message.kapso.transcript` for voice notes                                    |

Outbound, from `whatsappMessage` to the status events:

| YCloud                                            | Kapso                                                                            |
| ------------------------------------------------- | -------------------------------------------------------------------------------- |
| `wamid`                                           | `message.id`                                                                     |
| `status`                                          | The event name                                                                   |
| `errorCode` / `errorMessage` / `whatsappApiError` | `message.kapso.statuses[].errors[]`                                              |
| `recipientUserId` / `parentRecipientUserId`       | `message.to_user_id` / `message.to_parent_user_id`                               |
| `externalId`                                      | `biz_opaque_callback_data`                                                       |
| `conversation.id` / `conversation.expireTime`     | None. Kapso's `conversation.id` is its own, not Meta's pricing conversation      |
| `totalPrice` / `currency`                         | None. See [Meta message billing](/docs/whatsapp/meta-message-billing)            |
| `pricingCategory`                                 | `message.kapso.statuses[].pricing.category`, with `billable` and `pricing_model` |

`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

YCloud signs the timestamp and the body together, and sends both in one header. Kapso signs only the body.

**YCloud:**

```javascript theme={null}
// YCloud-Signature: t=1654084800,s=8eb70f2a...
const [t, received] = signatureHeader.split(',').map((part) => part.split('=')[1]);

const expected = crypto
  .createHmac('sha256', secret)
  .update(`${t}.${rawBody}`)   // timestamp, a dot, then the body
  .digest('hex');
```

**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);
});
```

Both need the raw bytes, so whatever you did to capture `rawBody` still applies. The freshness check does not carry over: Kapso signs no timestamp, so the tolerance you tuned against YCloud's `t` has nothing to bind to. See [Security](/docs/platform/webhooks/security#signature-verification) for Python and Ruby.

### Delivery behavior

|                     | YCloud                                    | Kapso                                                                                                                           |
| ------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| Retries             | 7, at 10s / 30s / 5m / 30m / 1h / 2h / 2h | 3 attempts in about 50 seconds: the first, then 10s and 40s later                                                               |
| Timeout             | 6s recommended, 10s deprioritized         | 10s in the documented contract; the sender aborts at 15s                                                                        |
| Dedup               | `event.id`                                | `X-Idempotency-Key` header                                                                                                      |
| Scope               | Account-wide, max 20 endpoints            | Per phone number, plus project webhooks                                                                                         |
| Batching            | None                                      | Optional buffering on `whatsapp.message.received`                                                                               |
| Endpoint suspension | 3 minutes, then automatic resume          | Paused once a 15-minute window holds 40+ deliveries, 10+ failures, and an 85% failure rate. Re-enabled by hand in the dashboard |

See [Advanced](/docs/platform/webhooks/advanced) for buffering and ordering, and [Security](/docs/platform/webhooks/security) for verification in Python and Ruby.

## Step 5: Templates

Same components, different addressing. YCloud takes `wabaId` in the body, Kapso takes it in the path.

**YCloud:**

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

**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 rest of the lifecycle maps directly:

| YCloud                                                    | Kapso                                            |
| --------------------------------------------------------- | ------------------------------------------------ |
| `POST /v2/whatsapp/templates`                             | `POST /{waba_id}/message_templates`              |
| `GET /v2/whatsapp/templates`                              | `GET /{waba_id}/message_templates`               |
| `PATCH /v2/whatsapp/templates/{wabaId}/{name}/{language}` | `POST /{waba_id}/message_templates?hsm_id=...`   |
| `DELETE /v2/whatsapp/templates/{wabaId}/{name}`           | `DELETE /{waba_id}/message_templates?name=...`   |
| `GET /v2/whatsapp/templates/{wabaId}/{name}/{language}`   | `GET /{waba_id}/message_templates/{template_id}` |

Create and edit are one endpoint in Kapso. `hsm_id` is a query parameter, not a body field: add `?hsm_id=...` to update instead of create. Delete also identifies the template by query string, taking either `?name=...` or `?hsm_id=...`.

Templates belong to the WABA. If the number stayed on a WABA you already owned, they are still there. If it landed on a new one, recreate them and wait for review.

Rebuilt them in WhatsApp Manager? Pull them in from **WhatsApp → Templates → Sync from WhatsApp**. See [Template lifecycle](/docs/whatsapp/templates/lifecycle).

## Contacts and opt-outs

YCloud stores contacts in its own CRM, with tags, custom attributes, owners, and source attribution. Kapso stores a smaller contact record and a free-form `metadata` object:

```bash theme={null}
curl -X POST https://api.kapso.ai/platform/v1/whatsapp/contacts \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "contact": {
      "wa_id": "+15551234567",
      "profile_name": "John Doe",
      "metadata": { "plan": "pro", "crm_id": "CUS-12345" }
    }
  }'
```

| YCloud                                                          | Kapso                                                                                                                                                                  |
| --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `phoneNumber`                                                   | `wa_id`                                                                                                                                                                |
| `nickname` on a read (WhatsApp's own, read-only)                | `profile_name`                                                                                                                                                         |
| `remarkName`                                                    | `display_name`                                                                                                                                                         |
| `nickname` on a write                                           | `display_name`. YCloud deprecated it as an alias for `remarkName`, so anything you set through it is a business label, not WhatsApp's nickname                         |
| `metaUsername`                                                  | `username`, read-only                                                                                                                                                  |
| `customAttributes`                                              | `metadata`, an untyped JSON object                                                                                                                                     |
| `tags`, `ownerEmail`, `sourceType`, `email`                     | None. Put them in `metadata`                                                                                                                                           |
| `GET /v2/contact/contacts`                                      | `GET /platform/v1/whatsapp/contacts`, with offset (`page`, `per_page`) or cursor (`limit`, `after`, `before`) pagination                                               |
| Contact attribute definitions                                   | None. `metadata` is schemaless                                                                                                                                         |
| Contact notes API, and `notes` on contact create                | No Platform API route. The developer API serves them at `/api/v1/whatsapp_contacts/{identifier}/notes` on the same key, outside the [API reference](/api/introduction) |
| `POST /v2/event/events` and event definitions                   | [Project events](/docs/platform/events)                                                                                                                                |
| `POST /v2/unsubscribers`, `filterUnsubscribed`, `filterBlocked` | None. Kapso reads WhatsApp's own opt-out and refuses marketing sends with `422`. Only the contact can change it                                                        |

Export your YCloud unsubscriber list before you close the account and keep suppressing those contacts on your side. See [Marketing opt-outs](/docs/whatsapp/templates/marketing-opt-outs).

## Multi-tenant setups

If you message on behalf of your own customers, each one becomes a Kapso customer that connects its own number through a setup link, instead of you onboarding WABAs on their behalf:

```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. Migrate one customer at a time.

## Bulk sends

YCloud's queueing endpoint absorbs bursts, so bulk sends there are often just a fast loop over `POST /v2/whatsapp/messages`, whatever the message type. Kapso's send endpoint is synchronous, and what replaces the loop depends on what you were sending.

Approved-template campaigns become [broadcasts](/docs/platform/broadcasts/api). `whatsapp_template_id` is required on create, so a broadcast always sends one approved template, of any category. The one restriction is that authentication templates cannot go to [BSUID](/docs/whatsapp/business-scoped-user-ids) recipients:

```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. On a marketing broadcast, recipients who stopped marketing are marked `suppressed` and skipped, which covers what `filterUnsubscribed` did per send. The guard checks the template category first, so utility and authentication broadcasts are not subject to marketing opt-outs at all. They can still fail per recipient for other reasons, including the BSUID restriction above.

Bulk text, media, and interactive sends have no broadcast equivalent, because those are session messages rather than templates. Keep your own durable queue in front of `POST /{phone_number_id}/messages` for them, and pace it yourself against your [rate limit](/api/rate-limits).

`POST /{id}/schedule` sends later. Stopping depends on what state the broadcast is in: `POST /{id}/cancel` returns a *scheduled* broadcast to draft, and it does nothing for one that is already sending. To stop a send in flight, patch the status:

```bash theme={null}
curl -X PATCH https://api.kapso.ai/platform/v1/whatsapp/broadcasts/BROADCAST_ID \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"whatsapp_broadcast": {"status": "stopped"}}'
```

Pending recipients stay pending and no new sends start.

Campaigns built in the YCloud console move to [broadcasts](/docs/platform/broadcasts/overview), with [CSV upload](/docs/platform/broadcasts/csv-upload) for the recipient list.

## Billing

YCloud runs on a prepaid balance: you top up, YCloud pays Meta, and `BALANCE_INSUFFICIENT` stops sends. Kapso offers the same shape, or direct Meta billing:

|                          | YCloud                                              | Kapso                                                     |
| ------------------------ | --------------------------------------------------- | --------------------------------------------------------- |
| Who pays Meta            | YCloud, from your balance                           | Kapso from project credits, or your business directly     |
| Where you see the charge | `GET /v2/balance`, `totalPrice` on message webhooks | Project credits, or Meta Billing Hub                      |
| Out of funds             | `BALANCE_INSUFFICIENT`, `403`                       | Paid sends pause until credits are added, or Meta decides |

Choose the mode when the WABA is connected. See [Meta message billing](/docs/whatsapp/meta-message-billing) and [pricing](/docs/whatsapp/pricing-faq).

## Feature map

| Feature                 | YCloud                                                                 | Kapso                                                                                                                                        |
| ----------------------- | ---------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| WhatsApp messaging      | `/v2/whatsapp/messages` and `sendDirectly`                             | [`POST /{phone_number_id}/messages`](/docs/whatsapp/send-messages/text)                                                                      |
| Interactive messages    | Meta `interactive` object                                              | [Same object](/docs/whatsapp/send-messages/buttons)                                                                                          |
| Templates               | `/v2/whatsapp/templates`, `wabaId` in body                             | [`/{waba_id}/message_templates`](/docs/whatsapp/templates/simple-text)                                                                       |
| WhatsApp Flows          | Full Flows API                                                         | [Full Flows API](/docs/whatsapp/flows/overview), including hosted data endpoints                                                             |
| Bulk messaging          | Campaigns, or your own loop, any message type                          | [Broadcasts](/docs/platform/broadcasts/overview) for approved templates, with CSV upload and scheduling. Session messages stay your own loop |
| Conversation automation | Chatbot Flow Builder, AI Agent                                         | [Workflows](/docs/workflows/introduction) with AI steps and human handoff                                                                    |
| Team inbox              | Shared Team Inbox                                                      | [Included](/docs/platform/inbox/overview), plus an [embeddable iframe](/docs/platform/inbox/embedded)                                        |
| Contacts                | Contacts with tags and custom attributes                               | [Contacts](/docs/platform/whatsapp-data#contacts) with `metadata`                                                                            |
| Message history         | `GET /v2/whatsapp/messages/{id}`                                       | [`/whatsapp/messages`](/docs/platform/whatsapp-data), `/whatsapp/conversations`                                                              |
| CTWA attribution        | `referral` on inbound messages                                         | [Same, plus an Ads view](/docs/platform/whatsapp-data#referrals-ctwa)                                                                        |
| WhatsApp calling        | Connect, accept, terminate, plus stored recordings and transcriptions  | [Supported](/docs/whatsapp/typescript-sdk/calls), with call logs. No recordings or transcriptions                                            |
| Business profile        | Profile and commerce settings                                          | [Display names](/docs/whatsapp/display-names), [usernames](/docs/whatsapp/business-usernames)                                                |
| Opt-outs                | `/v2/unsubscribers`, plus per-send filters                             | [Automatic](/docs/whatsapp/templates/marketing-opt-outs), from WhatsApp's native control                                                     |
| Multi-tenant onboarding | WABAs you onboard per client                                           | [Setup links](/docs/platform/setup-links/create-and-configure) your customer completes                                                       |
| Rate limits             | 200 rps per sender on `/messages`, 80 rps per sender on `sendDirectly` | [100-2,000 req/min by plan](/api/rate-limits)                                                                                                |
| SDKs                    | Go, Java, Node.js, PHP                                                 | [TypeScript](/docs/whatsapp/typescript-sdk/introduction), [MCP server](/docs/whatsapp/mcp), [CLI](/docs/whatsapp/cli)                        |

Things Kapso adds:

* Automatic voice-note transcription on inbound audio
* [Functions](/docs/functions/overview) on Cloudflare Workers, as workflow steps, agent tools, or plain endpoints
* [Findings](/docs/platform/findings), which reads ended conversations with AI and groups the recurring problems
* Conversation lifecycle webhooks, so you can act when a conversation goes quiet or ends

## What does not map

* **SMS, email, voice, and Verify.** YCloud's `/v2/sms`, `/v2/emails`, `/v2/voices`, and `/v2/verify/verifications` have no Kapso equivalent. Kapso is WhatsApp only. Keep a YCloud account or another provider for those channels.
* **Contact attribute definitions, tags, and owners.** Contact `metadata` is schemaless.
* **Balance API.** No `GET /v2/balance`. Credits live in project settings.

## 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 contacts, unsubscribers, history, and call recordings from YCloud |
| 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 YCloud sends for each migrated number, then drain the remaining balance before closing the account                                                                                   |

<Warning>
  Message history does not transfer, and neither do contacts or unsubscribers. YCloud keeps them behind its own APIs. Export what you need before you close the account.
</Warning>

## Troubleshooting

| Symptom                                          | YCloud equivalent                             | Fix                                                                                                                                      |
| ------------------------------------------------ | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `401 Unauthorized`                               | `UNAUTHORIZED`                                | Same header, different key. Send Kapso's `X-API-Key`                                                                                     |
| `404` on send                                    | Right key, wrong path                         | The sender is `phone_number_id` in the path, not `from` in the body                                                                      |
| Webhook reports a different number than you sent | E.164 stored verbatim                         | Meta received yours unchanged. Kapso keyed its own records on the normalized form, with Argentina `549` as `54` and Mexico `521` as `52` |
| `(#131047)` re-engagement message                | "More than 24 hours have passed"              | Outside the 24-hour window. Send an approved template                                                                                    |
| Template not found                               | `WHATSAPP_TEMPLATE_UNAVAILABLE`               | Templates are addressed by `name` + `language`, and must exist and be approved on this WABA                                              |
| Templates missing after the move                 | None                                          | They belonged to the old WABA. Recreate them on the new one and wait for review                                                          |
| Number cannot be connected                       | None                                          | Still on the old WABA, a PIN is set, or it sent a paid message in the last 30 days. See [Step 1](#step-1-connect-your-number)            |
| Webhook signature mismatch                       | HMAC over `{timestamp}.{body}`                | HMAC-SHA256 over the raw body alone, checked against `X-Webhook-Signature`                                                               |
| Webhook fires once and gives up                  | 7 retries over hours                          | 3 attempts in about 50 seconds. Return `200` fast and process from a queue                                                               |
| Status handler never runs                        | Switched on `whatsappMessage.status`          | Status is the event name now. Read `X-Webhook-Event`                                                                                     |
| Marketing template refused with `422`            | `RECIPIENT_UNSUBSCRIBED`                      | The contact stopped marketing on that number. Check [marketing preferences](/docs/whatsapp/templates/marketing-opt-outs)                 |
| `429`                                            | `ACCOUNT_RATE_LIMITED`, `SENDER_RATE_LIMITED` | Back off on `Retry-After`; see [rate limits](/api/rate-limits)                                                                           |

## Node.js example

The example below is scoped to what changes when you move off YCloud. It is not a hardened receiver: deduplication, retry handling, and buffering are the same on Kapso whatever you migrated from, so they live in [Advanced](/docs/platform/webhooks/advanced) and [Security](/docs/platform/webhooks/security#idempotency). Read those before this handler takes production traffic.

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

const app = express();

// Any durable queue works. YCloud queued sends for you; Kapso does not.
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',
};

// YCloud: POST /v2/whatsapp/messages with { from, to, type, template, externalId }
async function sendTemplate({ phoneNumberId, to, name, params = [], externalId }) {
  const res = await fetch(`${KAPSO}/${phoneNumberId}/messages`, {
    method: 'POST',
    headers,
    body: JSON.stringify({
      messaging_product: 'whatsapp',
      to,                                       // forwarded to Meta as-is; '+1555...' is fine
      type: 'template',
      biz_opaque_callback_data: externalId,     // YCloud: externalId
      template: {
        name,
        language: { code: 'en_US' },
        components: [
          {
            type: 'body',
            parameters: params.map((text) => ({ type: 'text', text })),
          },
        ],
      },
    }),
  });
  const body = await res.json();

  // A 4xx or 5xx returns an error object, not `messages`. Reading
  // messages[0] there throws a TypeError that hides the real cause.
  if (!res.ok) {
    throw new Error(`Send failed (${res.status}): ${JSON.stringify(body.error ?? body)}`);
  }

  return body.messages[0].id; // wamid, not a YCloud object ID
}

// YCloud: one endpoint, switch on event.type and whatsappMessage.status.
// Kapso: event name in the header, raw body signed without a timestamp.
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');
  }

  const event = req.headers['x-webhook-event'];
  const payload = JSON.parse(req.body);

  // YCloud: event.id. Delivery is at-least-once, so every job carries the
  // key and the worker has to be idempotent on it.
  const idempotencyKey = req.headers['x-idempotency-key'];

  if (event === 'whatsapp.message.failed') {
    // YCloud: whatsappMessage.errorCode / errorMessage
    const errors = (payload.message.kapso.statuses ?? []).flatMap((s) => s.errors ?? []);
    await queue.add('send-failed', {
      idempotencyKey,
      id: payload.message.id,
      phoneNumberId: payload.phone_number_id,
      errors,
    });
    return res.sendStatus(200);
  }

  if (event !== 'whatsapp.message.received') {
    return res.sendStatus(200);
  }

  const { message, conversation, phone_number_id } = payload;

  await queue.add('inbound', {
    idempotencyKey,
    // `from` can be absent on a BSUID-only contact, so carry the scoped
    // IDs too or the job cannot tell you who wrote in.
    from: message.from ?? conversation.phone_number ?? null,
    fromUserId: message.from_user_id ?? conversation.business_scoped_user_id ?? null,
    fromParentUserId:
      message.from_parent_user_id ?? conversation.parent_business_scoped_user_id ?? null,
    username: conversation.username ?? null,
    text: message.text?.body,
    mediaUrl: message.kapso.media_url,
    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%20YCloud)
