Webhooks

Receive signed WhatsApp events.

Message status, inbound messages, template decisions and campaign completion are delivered to your HTTPS endpoint as signed JSON. Deliveries are at-least-once, so consumers must be idempotent.

HMAC-SHA256

Signature over timestamp and raw body.

5 retries

Exponential backoff, then dead-letter.

5-minute window

Stale timestamps are rejected.

Request headers

HeaderValue
X-ClickMaker-Signaturev1=<hex HMAC-SHA256>
X-ClickMaker-TimestampUnix seconds
X-ClickMaker-Eventmessage.delivered
X-ClickMaker-DeliveryUnique delivery attempt ID
Content-Typeapplication/json

Verify a signature

Compute the expected signature over timestamp + "." + rawBody. Compare with a constant-time function. Do not parse and re-serialise the body before verification.

verify-webhook.ts
import crypto from "node:crypto"
import express from "express"

const app = express()

app.post(
  "/webhooks/clickmaker",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const timestamp = req.header("X-ClickMaker-Timestamp") ?? ""
    const signature = req.header("X-ClickMaker-Signature") ?? ""
    const raw = req.body.toString("utf8")

    // Reject stale events (replay protection).
    if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
      return res.status(401).json({ error: "stale timestamp" })
    }

    const expected = crypto
      .createHmac("sha256", process.env.CLICKMAKER_WEBHOOK_SECRET!)
      .update(`${timestamp}.${raw}`)
      .digest("hex")

    const received = signature.replace(/^v1=/, "")
    const valid =
      received.length === expected.length &&
      crypto.timingSafeEqual(
        Buffer.from(expected, "hex"),
        Buffer.from(received, "hex"),
      )

    if (!valid) return res.status(401).json({ error: "invalid signature" })

    // Acknowledge fast; process asynchronously.
    res.status(200).json({ received: true })

    const event = JSON.parse(raw)
    queue.push(event) // deduplicate by event.id in your worker
  },
)
verify-webhook.php
<?php
$raw = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_CLICKMAKER_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_CLICKMAKER_SIGNATURE'] ?? '';
$secret = getenv('CLICKMAKER_WEBHOOK_SECRET');

if (abs(time() - (int) $timestamp) > 300) {
    http_response_code(401);
    exit('stale timestamp');
}

$expected = hash_hmac('sha256', $timestamp . '.' . $raw, $secret);
$received = preg_replace('/^v1=/', '', $signature);

if (!hash_equals($expected, $received)) {
    http_response_code(401);
    exit('invalid signature');
}

http_response_code(200);
echo json_encode(['received' => true]);

Example payload

message.delivered.json
{
  "id": "evt_01J8XM2Q",
  "type": "message.delivered",
  "createdAt": "2026-11-07T06:12:04.512Z",
  "organizationId": "org_01J8XK4T",
  "applicationId": "app_01J8XK4T",
  "data": {
    "messageId": "msg_01J8XM2",
    "providerMessageId": "wamid.HBgL...",
    "conversationId": "cnv_01J8XK4T",
    "phoneNumberId": "pn_01J8XK4T",
    "to": "919876543210",
    "deliveredAt": "2026-11-07T06:12:04.000Z"
  }
}

Event catalogue

Subscribe per endpoint. Only events for authorized tenants are delivered.

11 events
whatsapp.connectedavailable

A WABA connection was verified and stored.

connectionId, wabaId, businessName, phoneNumbers[]

whatsapp.disconnectedavailable

A connection was revoked or became invalid.

connectionId, wabaId, reason

message.queuedavailable

A message was accepted and queued for dispatch.

messageId, conversationId, phoneNumberId, to, type

message.sentavailable

Meta accepted the message and returned a provider message ID.

messageId, providerMessageId, sentAt

message.deliveredavailable

The recipient device acknowledged delivery.

messageId, providerMessageId, deliveredAt

message.readavailable

The recipient read the message.

messageId, providerMessageId, readAt

message.failedavailable

Sending failed permanently or exhausted retries.

messageId, errorCode, errorTitle, failedAt

message.receivedavailable

An inbound customer message was persisted.

messageId, conversationId, from, type, content

template.approvedavailable

Meta approved a template version.

templateId, name, language, category

template.rejectedavailable

Meta rejected a template with a reason.

templateId, name, language, rejectionReason

campaign.completedavailable

All campaign recipients reached a terminal state.

campaignId, sent, delivered, failed, cancelled

Retry and failure policy

1

Respond with a 2xx status within 10 seconds. Slow responses are treated as failures.

2

Non-2xx responses are retried up to five times with exponential backoff and jitter.

3

After the final attempt, the delivery is marked failed and appears in developer diagnostics.

4

Endpoints returning persistent failures may be disabled after notification to the organization owner.

5

Deliveries are at-least-once. Deduplicate on the event id, not the payload hash.

Do not trust tenant IDs from the payload

Ownership is resolved server-side from the WABA and phone number mapping. The organizationId in the event is informational; never use a client-supplied tenant value to select a database or credential.

Ready to register an endpoint?