---
title: WhatsApp Cloud API: Integrate with Webhooks and REST API
description: Developer tutorial: integrate the WhatsApp Cloud API with a signed webhook (X-Hub-Signature-256), REST sending in Node.js and delivery status handling.
date: 2026-09-06
category: integracao
tags: whatsapp cloud api, webhook, rest api, node.js, x-hub-signature-256, idempotency
author: gabriel-miranda
cover: /blog-assets/covers/whatsapp-cloud-api-como-integrar-webhook.jpg
coverAlt: Programming code displayed on a screen
coverSource: https://www.pexels.com/photo/14553707/
ptSlug: whatsapp-cloud-api-como-integrar-webhook
---

Integrating the WhatsApp Cloud API means putting together three pieces: a number authorized at Meta with an access token, a webhook that receives messages and delivery statuses, and REST calls to send. The webhook arrives signed with HMAC-SHA256 in the `X-Hub-Signature-256` header, Meta resends anything that did not get a `200` and, outside the 24-hour window, only an approved template goes out. The rest of this guide is the detail of each piece, with Node.js code, and the difference between doing everything directly at Meta or through a Tech Provider.

The audience here is people who write code. If you want to understand the product before getting into the technical side, start with the overview of the [official WhatsApp API](/en/blog/official-whatsapp-api).

## The three pieces of any integration

Every Cloud API integration, in any language and with any provider, boils down to this:

| Piece | What it is | Whose rule |
|---|---|---|
| Authorized number + token | A number registered on the WhatsApp Business Platform and a token with permission to operate it | Meta |
| Webhook (inbound) | An HTTPS endpoint of yours that Meta calls on every incoming message and every status change | Meta signs and redelivers; you validate and deduplicate |
| REST API (outbound) | `POST` to send text, media, buttons or a template | Meta defines format, window and limits |

The part that misleads most is the first one. Sending and receiving is simple code. Getting to an authorized number, with the app approved and the right token, is where most tutorials spend 80% of their text and where most integrations get stuck. That is why it is worth separating the two paths before the code.

## Two paths: directly at Meta or through a Tech Provider

### What you do yourself: app, App Review, verification, permanent token and /register

Integrating directly, the sequence is yours. You create an app in Meta for Developers, add the WhatsApp product, verify the company in Business Manager and request App Review for the `whatsapp_business_management` and `whatsapp_business_messaging` permissions. Without an approved review, the app only talks to test numbers. The development token expires in hours; for production you create a system user in Business Manager and generate a permanent token with those permissions. Then you register the webhook in the App Dashboard, choose the subscribed fields and register the number with `POST /{phone_number_id}/register`.

Each step has a way to fail without a clear error. The most common is forgetting `/register`: the token works, the webhook responds, and every send returns code 133010, "number not registered". The second is subscribing only to `messages` in the App Dashboard and then wondering why template approvals and quality drops never arrive.

### What the provider has already solved: onboarding, token, webhooks, dedup, retry

Through a Tech Provider verified by Meta, the number comes in through [Embedded Signup](/en/blog/whatsapp-embedded-signup): a Meta window, opened inside the provider's site, where the number's owner authorizes the provider's app to operate that number. Meta returns the ids and a `code` exchangeable for a token. The App Review is the provider's, the token is stored encrypted at the provider, Meta's webhooks arrive at the provider, and what reaches you is an already-deduplicated event, with retry and its own signature.

This does not change what Meta charges. As a Tech Provider, Frame Conexa does not bill for messages and does not register payment at Meta on behalf of the customer: the card goes into the number owner's Business Manager, and Meta charges them directly by the per-message table. The provider charges for the software. The difference between the two paths is who carries the App Review, the token rotation and the redelivery queue, not how much each message costs.

There is an important technical difference between number modes. With [coexistence](/en/blog/whatsapp-business-api-coexistence), the number stays on the WhatsApp Business app on the phone and joins the API at the same time; the app has already registered the number, and calling `/register` on it logs the phone app out. On a new, API-only number, `/register` is mandatory. The provider knows which case it is and makes the right call; integrating directly, you need to know.

## Receiving messages: the webhook

The webhook is a public HTTPS endpoint, with a valid certificate, that answers two verbs. `GET` is the signup handshake. `POST` is what Meta calls on each event.

### Verification handshake: hub.mode, hub.verify_token, hub.challenge

When you save the URL in the App Dashboard, Meta makes a `GET` with three parameters: `hub.mode=subscribe`, `hub.verify_token` with the value you yourself typed into its dashboard, and `hub.challenge`, a number. If the token matches, you return `hub.challenge` as the body, as text, with `200`. Any other response and the registration fails.

```javascript
app.get('/webhook', (req, res) => {
  const mode = req.query['hub.mode'];
  const token = req.query['hub.verify_token'];
  const challenge = req.query['hub.challenge'];
  if (mode === 'subscribe' && token === process.env.VERIFY_TOKEN) {
    return res.status(200).send(challenge);
  }
  return res.sendStatus(403);
});
```

The `verify_token` is a secret you make up. It only serves the handshake; it does not sign any event. Compare in constant time if you want to be rigorous, but the important value is the next one.

### Validate the HMAC-SHA256 signature over the raw body

Every notification comes with the `X-Hub-Signature-256` header, in the format `sha256=<hex>`. The hex is the HMAC-SHA256 of the request body, computed with your app's App Secret. Recomputing and comparing is what guarantees that the `POST` came from Meta and not from anyone who found the URL. The rule is in the [Graph API webhooks documentation](https://developers.facebook.com/docs/graph-api/webhooks/getting-started).

The detail that breaks most implementations: the HMAC is over the **exact bytes** of the body. If a JSON parser runs first and you reserialize the object, key order and spacing change, and the hash never matches. Read the raw body first, validate, and only then parse.

```javascript
import crypto from 'node:crypto';
import express from 'express';

const app = express();

app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const received = req.get('X-Hub-Signature-256') || '';
  const expected = 'sha256=' + crypto
    .createHmac('sha256', process.env.APP_SECRET)
    .update(req.body)
    .digest('hex');

  const a = Buffer.from(received);
  const b = Buffer.from(expected);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    return res.sendStatus(401);
  }

  res.sendStatus(200);
  enqueue(JSON.parse(req.body.toString('utf8')));
});
```

Two points in this code are deliberate. `timingSafeEqual` instead of `===`, because a comparison that stops at the first differing byte leaks, through response time, how many bytes matched. And the `200` goes out **before** processing: Meta measures response time and resends anything slow. Write the event to a queue or a table and respond; processing happens afterward.

### The envelope: messages, statuses, errors

The body always has the same shell: `object`, an `entry` list, each entry with a `changes` list, and each change with a `field` and a `value`. For the `messages` field, the `value` carries `messaging_product`, `metadata` with the business number, and then `contacts` and `messages` when someone wrote, or `statuses` when one of your messages changed state. The full reference is in the [WhatsApp webhooks overview](https://developers.facebook.com/documentation/business-messaging/whatsapp/webhooks/overview).

An incoming message:

```json
{
  "object": "whatsapp_business_account",
  "entry": [{
    "id": "WABA_ID",
    "changes": [{
      "field": "messages",
      "value": {
        "messaging_product": "whatsapp",
        "metadata": { "display_phone_number": "5585999990000", "phone_number_id": "PHONE_NUMBER_ID" },
        "contacts": [{ "profile": { "name": "Ana" }, "wa_id": "5585988880000" }],
        "messages": [{
          "from": "5585988880000",
          "id": "wamid.HBgNNTU4NT...",
          "timestamp": "1757340000",
          "type": "text",
          "text": { "body": "Hi, is there still a spot on Saturday?" }
        }]
      }
    }]
  }]
}
```

A status for your message:

```json
{
  "field": "messages",
  "value": {
    "messaging_product": "whatsapp",
    "metadata": { "phone_number_id": "PHONE_NUMBER_ID" },
    "statuses": [{
      "id": "wamid.HBgNNTU4NT...",
      "status": "delivered",
      "timestamp": "1757340042",
      "recipient_id": "5585988880000",
      "conversation": { "id": "CONVERSATION_ID", "origin": { "type": "service" } },
      "pricing": { "billable": false, "pricing_model": "PMP", "category": "service" }
    }]
  }
}
```

The four `status` values tell a story in order: `sent` means Meta accepted and forwarded it; `delivered`, that it reached the device; `read`, that the person opened it, when they have not turned off read receipts; `failed`, that it did not, and then `statuses[].errors` carries the code. It is through this `failed` that you learn of a 131047 or a 131026 after the API had already answered `200` to your `POST`. An accepted send is not a delivered send.

The `pricing` object is what says whether that message costs anything. `billable` answers whether Meta charges, `category` gives the category and `pricing_model: "PMP"` indicates the per-message model, which replaced the old per-conversation model. Worth storing: it is the only source of truth about cost that reaches your system.

> **Warning:** service messages, the free-form replies inside the 24-hour window, are free until 09/30/2026. From 10/01/2026 Meta starts charging them per message, and the same goes for utility templates sent inside the window. The `pricing.billable` of those statuses will change from `false` to `true` on that date. Details on the [official page about non-template messages](https://developers.facebook.com/documentation/business-messaging/whatsapp/pricing/non-template-messages).

Errors show up in three places: `value.errors` for problems with the whole event, `messages[].type: "unsupported"` for content the API cannot represent, and `statuses[].errors` for delivery failure. A receiver that only reads `messages[].text.body` ignores all three.

### Redeliveries and duplicates: why deduplicating is mandatory

When your endpoint does not answer `200`, Meta resends. WhatsApp's documentation speaks of retries at decreasing frequency for up to seven days; the generic webhooks documentation says 36 hours. In both cases the consequence is the same: the same event may arrive more than once, and it may also arrive out of order, a `delivered` before the `sent`.

The natural deduplication key is `messages[].id`, the `wamid`, and for statuses the pair `statuses[].id` plus `status`. An `INSERT` with a unique constraint and `ON CONFLICT DO NOTHING` solves it; processing the event and only then answering `200` does not, because Meta's timeout is short and slow processing turns into redelivery of something you already handled. If your database fails on the `INSERT`, answer `500` and let Meta resend. The external retry queue is Meta itself.

## Sending messages: REST API

Sending is a `POST` to `https://graph.facebook.com/v25.0/{phone_number_id}/messages`, with `Authorization: Bearer` and a JSON body that starts with `messaging_product: "whatsapp"`. The destination number goes in E.164 without the plus sign.

### Text inside the window and a template outside it

What can go out depends on a rule that is Meta's, not the provider's: a free-form message only inside the [24-hour window](/en/blog/whatsapp-24-hour-window) opened by the person's last message. Outside it, only an approved template, in the marketing, utility or authentication categories.

Text, when the window is open:

```bash
curl -X POST "https://graph.facebook.com/v25.0/$PHONE_NUMBER_ID/messages" \
  -H "Authorization: Bearer $META_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "messaging_product": "whatsapp",
    "to": "5585988880000",
    "type": "text",
    "text": { "body": "Yes, there is. Saturday at 10 a.m. or 2 p.m.?" }
  }'
```

Template, when the window is closed, in Node.js with native `fetch`:

```javascript
const res = await fetch(`https://graph.facebook.com/v25.0/${PHONE_NUMBER_ID}/messages`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.META_ACCESS_TOKEN}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    messaging_product: 'whatsapp',
    to: '5585988880000',
    type: 'template',
    template: {
      name: 'appointment_reminder',
      language: { code: 'en_US' },
      components: [
        { type: 'body', parameters: [{ type: 'text', text: 'Saturday, 10 a.m.' }] },
      ],
    },
  }),
});
const data = await res.json();
if (!res.ok) throw Object.assign(new Error(data.error?.message), { graph: data.error });
const wamid = data.messages[0].id;
```

The `wamid` that comes back is the same `id` that will show up in the webhook statuses. Store it right away: it is what ties the send to the `delivered` and the `failed` that arrive minutes later. How to create a template and what Meta looks at during approval is in [approved WhatsApp template](/en/blog/approved-whatsapp-template).

### Idempotency, retry and codes 130429, 131047, 133010 and 190

The API returns errors at two moments: synchronously, on the `POST`, and asynchronously, in a `failed` status. The ones that matter for the sending code:

| Code | What it means | What to do | Whose |
|---|---|---|---|
| 130429 | Sending pace above what the number supports | Wait and resend with spacing; short exponential backoff | Meta |
| 131047 | More than 24 hours since the person's last message | Do not resend the text; send an approved template | Meta |
| 131026 | Recipient is not a WhatsApp user, did not accept the terms or uses an old app | Remove from the list; resending gives the same result | Meta |
| 133010 | Number not registered on the platform | Call `/register` on a new number; never on coexistence | Meta |
| 190 | Token expired or revoked | Generate a new token, or redo the authorization; nothing else goes out until then | Meta |

The full list, with what the dashboard shows for each, is in [WhatsApp API errors](/en/blog/whatsapp-api-errors-131047-131026-131049) and in the [official code reference](https://developers.facebook.com/documentation/business-messaging/whatsapp/support/error-codes).

On pace: a directly integrated number starts at 80 messages per second, according to Meta's throughput documentation; a coexistence number stays at 20. Above that comes 130429, and the right response is to space out sends, not insist in a loop.

On retry: the Cloud API has no native idempotency key. If your `POST` times out with no response, you do not know whether the message went out. Resending blindly duplicates; not resending may lose it. The way out is to record the intent to send **before** the `POST`, with an id of your own, and only mark it as sent when the `wamid` comes back. A send with no `wamid` stays uncertain until a status arrives by webhook, or until you check another way. It is laborious, and it is exactly the kind of thing a provider does for you.

## How it works with Frame Conexa

With the number connected through Embedded Signup, the three pieces look like this: the token is an `fc_` generated in Settings, in the dashboard, and sent in `Authorization: Bearer`; the webhook is a URL of yours registered by API or through the dashboard; sending is the same format as the Cloud API, on a route that already knows the instance. The [documentation](/docs) has the quickstart and the reference for each route; below is what changes compared with direct integration.

### Signed webhook with instanceId, customerRef and Meta's raw payload

You register the URL with `PUT /api/v1/webhook-config` and the body `{ "url": "https://..." }`. The response carries the `webhookSecret`, generated on the first save; `POST /api/v1/webhook-config/secret` rotates the secret when you need to, and the previous one dies immediately.

What arrives at your endpoint is an envelope signed in the `X-Frame-Signature-256` header, in the same scheme as Meta's: `sha256=` plus the HMAC-SHA256 of the raw body, computed with that secret. The validation code is the same as in the previous section, swapping the header name and the secret:

```javascript
app.post('/webhooks/frame-conexa', express.raw({ type: '*/*' }), (req, res) => {
  const received = req.get('X-Frame-Signature-256') || '';
  const expected = 'sha256=' + crypto
    .createHmac('sha256', process.env.FRAME_WEBHOOK_SECRET)
    .update(req.body)
    .digest('hex');
  const a = Buffer.from(received);
  const b = Buffer.from(expected);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.sendStatus(401);
  res.sendStatus(200);
  enqueue(JSON.parse(req.body.toString('utf8')));
});
```

The envelope has six top-level keys, and `payload` is Meta's raw `value`, with none of our fields inside:

```json
{
  "type": "messages",
  "clientId": "8",
  "instanceId": "6ed76b84-4d65-4f3d-88c1-8d4174bdce73",
  "customerId": null,
  "customerRef": null,
  "payload": { "messaging_product": "whatsapp", "metadata": {}, "messages": [] }
}
```

Three event types arrive this way. `messages` is the Meta field of the same name, with incoming messages and delivery statuses together, as Meta sends them. `smb_message_echoes` only exists with coexistence: it is a message someone sent from the phone app, so your system knows the conversation was answered there. `instance_status` is ours: `connected` or `disconnected`, emitted once per transition, so your system finds out the number went down before the first send that fails with 409.

Two decisions in this envelope are for compatibility. `instanceId` is the UUID that appears as `id` in `GET /api/v1/instances`, and it is how you match the event to an instance; `clientId` is an old numeric key that still exists because changing it broke consumers in production. And `customerId`/`customerRef` come as `null`, never absent: they are the end customer in a software partnership, and a strict consumer treats a null key and a missing key as different things.

Delivery is durable on our side. The event goes into a queue before going out, the `POST` has a 10-second timeout, and any non-2xx response schedules another attempt with increasing spacing, until exhausted; only then does the event go to a dead-letter queue, visible in the dashboard. A slow or down destination does not lose events or block ingestion of the others. On your side, keep deduplicating by `messages[].id`: Meta may redeliver to us, and we redeliver to you.

### POST /api/v1/instances/:id/messages with an fc_ token and Idempotency-Key

Sending is the same body as the Cloud API, without `messaging_product` and without `phone_number_id`, because the instance in the URL already says which number it is:

```bash
curl -X POST "https://frameconexa.com/api/v1/instances/$FRAME_CONEXA_INSTANCE_ID/messages" \
  -H "Authorization: Bearer fc_YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 9f8d4a11-5e52-4c83-a72c-45d48f4ea6d5" \
  -d '{"to":"5585988880000","type":"text","text":{"body":"Yes, there is. Saturday at 10 a.m. or 2 p.m.?"}}'
```

The `Idempotency-Key` is what solves the retry problem described above. Use one UUID per message. If the response gets lost, repeat the same key with the same body: you get the previous result instead of a new send. A repeated key with a different body responds `409 IDEMPOTENCY_CONFLICT`; a send still processing responds `409 SEND_IN_PROGRESS` with `ambiguous: true` and is not repeated.

Internally, sending is DB-first: the intent becomes a row before the `POST` to Meta. An acceptance becomes `accepted`, an explicit error becomes `failed`, a timeout with no `wamid` becomes `unknown`, and in that last case the response carries `ambiguous: true`. It means "it may have gone out": do not resend without checking. What promotes `accepted` to delivered or read is the webhook status, never the response to the `POST`.

A template follows the Cloud API format: `"type": "template"` with `name`, `language` and `components`. Two provider-side rules apply here: marketing templates and template creation require a marketing-type number, and the template's category is checked before sending. A support number sends utility and authentication with no restriction. Call limits are in [errors and limits](/docs/errors-and-limits): 120 requests per minute per token, with `Retry-After` on the 429.

### Downloading received media through the proxy

When someone sends a photo or a document, the webhook delivers only `image.id` or `document.id`. The binary stays at Meta and requires the number's token to download, in two steps: a `GET` for the metadata, which returns a CDN URL valid for minutes, and a second `GET` on that URL, also with the token. Integrating directly, you do both. Through Conexa, one `GET` does it:

```bash
curl -L "https://frameconexa.com/api/v1/instances/$FRAME_CONEXA_INSTANCE_ID/media/$MEDIA_ID" \
  -H "Authorization: Bearer fc_YOUR_TOKEN" \
  -o received.jpg
```

The proxy streams, with a 100 MB cap, and never returns or caches the CDN URL. Download as soon as the event arrives: retention on Meta's side is limited.

If your case is an automation flow rather than your own code, the same routes feed the HTTP node in [n8n and Make](/en/blog/whatsapp-business-api-n8n-make).

## Production checklist

Before pointing real traffic at the integration, check:

- **Signature validated over the raw body**, with constant-time comparison, and `401` for anything that does not match. A webhook without validation accepts any `POST` as if it came from Meta.
- **Fast `200` and queued processing.** Store first, process later. If you cannot store, answer `500` and let it be resent.
- **Deduplication by `wamid`** for messages and by `wamid` plus `status` for statuses. Repeated and out-of-order events are normal.
- **`wamid` stored on send**, tying the `POST` to the statuses that arrive later. Without it, `failed` has nothing to belong to.
- **Idempotency on send**: a key per message, and never blindly resend an uncertain result.
- **The 24-hour window in code**, not just in your head: free text only goes out when the person's last message is less than 24 hours old; otherwise, a template. Accounts with Click-to-WhatsApp ads have a free 72-hour window that the dashboard flags, and the article on [Click-to-WhatsApp ads](/en/blog/click-to-whatsapp-ads) explains the condition for it to exist.
- **Handling of 190**: a revoked token means nothing else goes out until reauthorization. Alert the team, do not retry.
- **Graph API version pinned** in the URL. A call to an expired version silently falls back to the oldest available one.
- **Cost forecast for October 2026**: if your integration replies a lot inside the window, the `pricing.billable` of those messages will become `true` on 10/01/2026. The per-message table is the one published by Meta; check the official rate card. What a provider charges, and what Meta charges, is separated in [how much the WhatsApp Business API costs](/en/blog/whatsapp-business-api-pricing).

## Frequently asked questions

### Do I need to create an app at Meta to integrate the Cloud API?

Integrating directly, yes: an app in Meta for Developers, the WhatsApp product, company verification and App Review for the two WhatsApp permissions. Through a verified Tech Provider, the app is the provider's and the number comes in through Embedded Signup; you receive a token from the provider and never touch Meta's App Dashboard. What does not change on either path is payment: the card goes into the number owner's Business Manager, and Meta charges them directly.

### How do I validate the WhatsApp webhook signature?

Read the request body as bytes, before any JSON parser. Compute the HMAC-SHA256 of those bytes with the app's App Secret, prefix it with `sha256=` and compare with the `X-Hub-Signature-256` header in constant time. If it matches, answer `200` and process; if not, `401`. On the Frame Conexa webhook the header is `X-Frame-Signature-256` and the secret is your account's `webhookSecret`; the code is the same.

### Why do I receive the same event twice?

Because Meta resends anything that did not get a `200` in time, with attempts at decreasing frequency for up to seven days according to WhatsApp's documentation. Slow processing before the response, a deploy in the middle, a network timeout: any of them causes redelivery. Deduplicate by `messages[].id` and, for statuses, by the id plus `status` pair. If you receive through Conexa, the same rule applies: we redeliver what your endpoint did not acknowledge.

### How do I send a message outside the 24h window through the API?

Only with a template approved by Meta, sent with `"type": "template"`, the name, the language and the component parameters. Free text outside the window returns 131047 and does not go out. The template's category defines what can be said and how much it costs: marketing for promotion, utility for orders and appointments, authentication for one-time codes.

### Which Graph API version should I use in 2026?

The latest stable one you have tested. As of September 2026, v25.0 was released on 02/18/2026 and is valid until 07/29/2028; v26.0 came out on 07/29/2026. Each version lives about two years after the next one. v20.0 is removed on 09/24/2026, and calls to an expired version fall back to the oldest available one. The official table is in the [Graph API versions list](https://developers.facebook.com/docs/graph-api/changelog/versions/). Through Conexa the version is the provider's, and you do not write it in the URL.

## Next step

The [quickstart](/docs/quickstart) takes you from token to first message in a few minutes, and the [webhooks guide](/docs/guides/webhooks) has the full envelope with the schema for each event type. To connect a number and generate your first token, see the [per-number plans](/#precos).
