Outgoing webhooks

We POST to your URL when something happens. Every delivery is signed. Register the URL in the dashboard or through the webhook subscriptions API.

Nothing is delivered inside the request that caused it, so your endpoint being slow never slows the product down. It does mean an event reaches you a few seconds after it happens, not instantly.

The envelope

Every delivery body has the same envelope:

json
{
  "id": "evt_wamid.HBgMOTE5OTk5OTAwMDAwFQIAERgSN0E4..._delivered",
  "type": "message.delivered",
  "occurredAt": "2026-10-17T10:14:09.000Z",
  "data": { }
}

id is stable for a given real-world event. Treat it as the deduplication key on your side too. We try hard not to send the same event twice, but a receiver that relies on us for that will eventually be surprised by something.

Event list

TypeWhendata
message.sentWhatsApp accepted a message you sent.messageId, waMessageId, conversationId, contactId, to, status, reference
message.deliveredIt reached the customer's phone.as above
message.readThe customer opened it.as above
message.failedWhatsApp gave up on it.as above, plus error: { code, title }
message.inboundA customer wrote to the business, or tapped a button.messageId, waMessageId, conversationId, contactId, from, profileName, type, text, repliedToMessageId, reference
message.button_replyA customer tapped a button instead of typing. Also arrives as message.inbound, see below.messageId, repliedToMessageId, reference, button: { text, payload }, contact: { phone, name }, conversationId
order.paidA payment landed and the order moved to PAID.orderId, contactId, phone, total, currency, razorpayPaymentId, status

A message.delivered delivery:

json
{
  "id": "evt_wamid.HBgMOTE5OTk5OTAwMDAwFQIAERgSN0E4..._delivered",
  "type": "message.delivered",
  "occurredAt": "2026-10-17T10:14:09.000Z",
  "data": {
    "messageId": "clx9m4p2t0003",
    "waMessageId": "wamid.HBgMOTE5OTk5OTAwMDAwFQIAERgSN0E4...",
    "conversationId": "clx7k1m8n0002",
    "contactId": "clx8h2k9p0001",
    "to": "+919999900000",
    "status": "delivered",
    "reference": "shopify:order:4521:shipped"
  }
}

A message.failed delivery:

json
{
  "id": "evt_wamid.HBgMOTE5..._failed",
  "type": "message.failed",
  "occurredAt": "2026-10-17T10:14:20.000Z",
  "data": {
    "messageId": "clx9m4p2t0003",
    "waMessageId": "wamid.HBgMOTE5...",
    "conversationId": "clx7k1m8n0002",
    "contactId": "clx8h2k9p0001",
    "to": "+919999900000",
    "status": "failed",
    "error": { "code": 131049, "title": "This message was not delivered to maintain healthy ecosystem engagement." },
    "reference": "shopify:order:4521:shipped"
  }
}

Statuses do not always arrive in order and read may never arrive at all: the customer may have read receipts off. Never wait for read.

Matching an event to your own records

An event about a message you sent is useless if you cannot tell which of your own orders it is about. Two fields exist for exactly that, and neither needs you to keep a table of our ids.

reference on the way out. Both send endpoints take an optional reference: up to 128 characters of your own identifier, in whatever shape suits you ("shopify:order:4521:cod_confirm", "88", a UUID). We store it and never look inside it. It comes back on message.sent, message.delivered, message.read and message.failed, and it is null on any message that was sent without one.

repliedToMessageId and reference on the way back. When a customer replies to one of your messages (which is what tapping a button on it is), WhatsApp tells us which message they answered. message.inbound and message.button_reply then carry:

  • repliedToMessageId: the id we returned when you sent that message;
  • reference: the reference you sent with it.

Both are null when the customer wrote to you out of the blue, when they answered a message sent from the dashboard, and when they answered a message that carries no reference. A reference belongs to the workspace that sent it: a reply can only ever resolve to a message of your own.

reference is not an idempotency key

Two sends carrying the same reference are two messages, both delivered and both charged. It is a label, not a lock. To make a retry safe, send the Idempotency-Key header (see Idempotency). The two are independent and most integrations want both.

message.button_reply

Fired when the inbound message is a tap rather than typed text: a template's quick-reply button, an interactive button, or a row of an interactive list.

json
{
  "id": "evt_btn_wamid.HBgMOTE5OTk5OTAwMDAwFQIAEhgU...",
  "type": "message.button_reply",
  "occurredAt": "2026-10-18T09:12:31.000Z",
  "data": {
    "messageId": "clxa2k7v10008",
    "repliedToMessageId": "clx9m4p2t0003",
    "reference": "shopify:order:4521:cod_confirm",
    "button": { "text": "Confirm", "payload": "confirm" },
    "contact": { "phone": "919999999999", "name": "Asha" },
    "conversationId": "clx7k1m8n0002"
  }
}

button.text is the label the customer saw. button.payload is the identifier attached to the button: the id of an interactive button or list row, or the payload of a template quick reply. Where a button carries no identifier of its own, the two are the same string. Match on payload and treat text as display, but never assume the two differ.

One tap, two events

Every tap fires message.inbound and message.button_reply. That is deliberate: a subscriber that only wants what people type should not have to filter taps out, and one that only wants taps should not have to parse them out of a text field.

Subscribe to one of them, not both, or you will act on the same tap twice. If you are matching taps to orders, take message.button_reply and leave message.inbound off your subscription. If you are mirroring the whole conversation into a helpdesk, take message.inbound; a tap arrives there with type: "button" or "interactive" and the label in text.

Worked example: a COD confirmation

The round trip, end to end. A shop wants the customer to confirm a cash-on-delivery order before it is picked and packed.

1. Send the question, labelled with the order. The template cod_confirm has two quick-reply buttons, "Confirm" and "Cancel", with payloads confirm and cancel.

Request
POST /api/v1/messages/template
X-Api-Key: cbk_live_7mK2pQx9Lw4t....
Idempotency-Key: shopify-4521-cod-confirm
Content-Type: application/json

{
  "to": "+919999999999",
  "template": "cod_confirm",
  "reference": "shopify:order:4521:cod_confirm",
  "components": [
    { "type": "body", "parameters": [{ "type": "text", "text": "#1234" }] }
  ]
}

The Idempotency-Key is what makes your webhook's retry safe. The reference is what makes the answer meaningful. They are different things doing different jobs.

2. Store nothing. The response carries id and waMessageId, and you are welcome to keep them, but you do not have to. The reference is coming back on its own.

3. The customer taps Confirm. Your endpoint receives:

json
{
  "id": "evt_btn_wamid.HBgMOTE5OTk5OTAwMDAwFQIAEhgU...",
  "type": "message.button_reply",
  "occurredAt": "2026-10-18T09:12:31.000Z",
  "data": {
    "messageId": "clxa2k7v10008",
    "repliedToMessageId": "clx9m4p2t0003",
    "reference": "shopify:order:4521:cod_confirm",
    "button": { "text": "Confirm", "payload": "confirm" },
    "contact": { "phone": "919999999999", "name": "Asha" },
    "conversationId": "clx7k1m8n0002"
  }
}

4. Match it and act. Verify the signature, parse your own reference, and branch on the payload:

php
$event = json_decode($rawBody, true);
if ($event['type'] !== 'message.button_reply') return;

$reference = $event['data']['reference'] ?? '';
if (strpos($reference, 'shopify:order:') !== 0) return;

[, , $orderId, $purpose] = explode(':', $reference);
if ($purpose !== 'cod_confirm') return;

// Deduplicate on the event id, not on the reference: the same order will
// send you several references over its life, and a redelivered event will
// repeat one.
if ($this->alreadyHandled($event['id'])) return;

$event['data']['button']['payload'] === 'confirm'
    ? $this->markReadyToPick($orderId)
    : $this->cancelOrder($orderId);

Four things worth saying plainly about step 4:

  • Deduplicate on id, never on reference. One order legitimately produces many events carrying the same reference: sent, delivered, read, then the tap.
  • A tap is the only reliable confirmation. A customer who types "yes" instead of tapping produces message.inbound with no button at all. Treat typed text as a human to follow up with, not as a confirmation.
  • A tap also opens the 24-hour window, so from this moment you may reply with POST /v1/messages/text ("Thanks, #1234 is on its way") without a template.
  • The customer may never answer. Nothing fires for silence. Give the question a deadline of your own and decide what an unanswered order means.

Verifying a delivery

Four headers come with every POST:

HeaderExample
X-Chatbox-Eventmessage.delivered
X-Chatbox-Deliveryclx9w8n3r0011
X-Chatbox-Timestamp1760696049 (Unix seconds)
X-Chatbox-Signaturev1=5d41402abc4b2a76b9719d911017c592...

The signature is:

text
signature = "v1=" + hex( HMAC_SHA256( secret, timestamp + "." + raw_body ) )

To verify, in this order:

  1. Read the raw request body as bytes. Do not parse and re-serialise the JSON first: a re-serialised body will not match, every time, as soon as a payload contains a non-ASCII character.
  2. Check X-Chatbox-Timestamp is within 300 seconds of your clock. This is what stops somebody replaying a payload they captured last month. Reject anything older, and anything in the future.
  3. Compute HMAC-SHA256 over the string "<timestamp>.<raw_body>" using your subscription secret, hex-encode it, and prefix v1=.
  4. Compare with X-Chatbox-Signature using a constant-time comparison. === leaks how many leading characters were right.
  5. Only then parse the body.

The timestamp is inside the signed string, not merely beside it, so it cannot be edited to look fresh without breaking the signature.

PHP (WordPress)

php
function chatbox_verify_webhook( WP_REST_Request $request ): bool {
    $secret    = get_option( 'chatbox_webhook_secret' );
    $raw_body  = $request->get_body();                            // raw, unparsed
    $timestamp = $request->get_header( 'x-chatbox-timestamp' );
    $signature = $request->get_header( 'x-chatbox-signature' );

    if ( ! $secret || ! $timestamp || ! $signature ) {
        return false;
    }

    // Replay protection. The timestamp is signed, so it cannot be moved.
    if ( abs( time() - (int) $timestamp ) > 300 ) {
        return false;
    }

    $expected = 'v1=' . hash_hmac( 'sha256', $timestamp . '.' . $raw_body, $secret );

    return hash_equals( $expected, $signature );   // constant time
}

Node.js (Express)

js
const crypto = require('crypto');

// The raw body is required. express.json() throws it away, so keep it:
app.use('/chatbox/webhook', express.raw({ type: 'application/json' }));

app.post('/chatbox/webhook', (req, res) => {
  const secret = process.env.CHATBOX_WEBHOOK_SECRET;
  const timestamp = req.get('X-Chatbox-Timestamp');
  const signature = req.get('X-Chatbox-Signature');

  if (!timestamp || !signature) return res.sendStatus(400);

  // Replay protection, both directions.
  if (Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)) > 300) {
    return res.sendStatus(400);
  }

  const rawBody = req.body.toString('utf8');
  const expected =
    'v1=' +
    crypto.createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');

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

  const event = JSON.parse(rawBody);

  // Answer immediately; do the work afterwards. We time out at 10 seconds.
  res.sendStatus(200);
  handleAsync(event).catch(console.error);
});

Python (Flask)

python
import hmac, hashlib, os, time
from flask import request, abort

def verify() -> dict:
    secret    = os.environ["CHATBOX_WEBHOOK_SECRET"].encode()
    raw_body  = request.get_data()                      # bytes, unparsed
    timestamp = request.headers.get("X-Chatbox-Timestamp", "")
    signature = request.headers.get("X-Chatbox-Signature", "")

    if not timestamp or not signature:
        abort(400)
    if abs(int(time.time()) - int(timestamp)) > 300:
        abort(400)

    signed   = timestamp.encode() + b"." + raw_body
    expected = "v1=" + hmac.new(secret, signed, hashlib.sha256).hexdigest()

    if not hmac.compare_digest(expected, signature):    # constant time
        abort(401)

    return request.get_json()

Retries and failures

  • Answer with any 2xx. Anything else is a failure.
  • Answer within 10 seconds. We time out there. Acknowledge first, do the work afterwards: a slow handler turns into a retry and a duplicate.
  • A failure is retried 6 times with exponential backoff from 10 seconds, spanning roughly ten minutes.
  • Redirects are not followed. A 301 to your new URL is a failure; change the subscription instead.
  • Every attempt is recorded. GET /v1/webhooks/{id}/deliveries shows the status code and the error for each one: check there first when something "isn't firing".
  • After 20 consecutive failed deliveries, the subscription is switched off and the workspace is notified in the dashboard. isActive goes false and disabledReason says why. PATCH it back to isActive: true to resume; that also clears the counter.
  • One success resets the counter, so a host that is down for an hour does not cost anyone their integration.