Integration

WhatsApp Cloud API: Integrate with Webhooks and REST API

Developer tutorial: integrate the WhatsApp Cloud API with a signed webhook (X-Hub-Signature-256), REST sending in Node.js and delivery status handling.

Programming code displayed on a screen
Illustrative photo · Pexels
In this article

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.

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: 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, 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.

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.

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.

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

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 and in the official code reference.

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

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

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. Through Conexa the version is the provider's, and you do not write it in the URL.

Next step

The quickstart takes you from token to first message in a few minutes, and the webhooks guide 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.

Keep reading

Connect the number you already use to the official WhatsApp API.

No switching numbers, no losing the app and no per-message fee from us. You pay per number; you pay Meta directly.