HMAC-SHA256
Signature over timestamp and raw body.
5 retries
Exponential backoff, then dead-letter.
5-minute window
Stale timestamps are rejected.
Request headers
| Header | Value |
|---|---|
| X-ClickMaker-Signature | v1=<hex HMAC-SHA256> |
| X-ClickMaker-Timestamp | Unix seconds |
| X-ClickMaker-Event | message.delivered |
| X-ClickMaker-Delivery | Unique delivery attempt ID |
| Content-Type | application/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.
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
},
)<?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
{
"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.
whatsapp.connectedavailableA WABA connection was verified and stored.
connectionId, wabaId, businessName, phoneNumbers[]
whatsapp.disconnectedavailableA connection was revoked or became invalid.
connectionId, wabaId, reason
message.queuedavailableA message was accepted and queued for dispatch.
messageId, conversationId, phoneNumberId, to, type
message.sentavailableMeta accepted the message and returned a provider message ID.
messageId, providerMessageId, sentAt
message.deliveredavailableThe recipient device acknowledged delivery.
messageId, providerMessageId, deliveredAt
message.readavailableThe recipient read the message.
messageId, providerMessageId, readAt
message.failedavailableSending failed permanently or exhausted retries.
messageId, errorCode, errorTitle, failedAt
message.receivedavailableAn inbound customer message was persisted.
messageId, conversationId, from, type, content
template.approvedavailableMeta approved a template version.
templateId, name, language, category
template.rejectedavailableMeta rejected a template with a reason.
templateId, name, language, rejectionReason
campaign.completedavailableAll campaign recipients reached a terminal state.
campaignId, sent, delivered, failed, cancelled
Retry and failure policy
Respond with a 2xx status within 10 seconds. Slow responses are treated as failures.
Non-2xx responses are retried up to five times with exponential backoff and jitter.
After the final attempt, the delivery is marked failed and appears in developer diagnostics.
Endpoints returning persistent failures may be disabled after notification to the organization owner.
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?
