Webhooks
Consume webhook events, verify signatures, and handle retries.
Webhook Overview
Webhooks are optional. You can poll message status using GET /v1/messages/:messageId, or receive push updates on your webhook endpoint.
Webhook destination and subscribed events are configured in the Chatmaid dashboard.
Use a single API domain; environment is inferred from key prefix:
https://developers-api.chatmaid.net+sk_test_*for sandbox behaviorhttps://developers-api.chatmaid.net+sk_live_*for production behavior
Integration host: https://developers-api.chatmaid.net.
Events and Signatures
Supported events include:
message.sent— a message you sent was accepted by WhatsAppmessage.failed— a message you sent could not be deliveredmessage.received— one of your connected phone numbers received an inbound messagemessage.delivered— a message you sent was delivered to the recipient's devicemessage.read— a message you sent was read by the recipientphone.connected— a phone number connected to WhatsAppphone.disconnected— a phone number disconnected from WhatsApp
message.delivered and message.read are driven by WhatsApp receipts: read may never fire if the recipient has read receipts disabled, and a message can jump from sent straight to read.
Each webhook includes these headers:
Content-Type: application/jsonX-Chatmaid-EventX-Chatmaid-Signature
import crypto from "crypto";
function verifySignature(rawBody: string, signatureHeader: string, secret: string) {
// signature format: t=1700000000,v1=<hex>
const pairs = Object.fromEntries(
signatureHeader.split(",").map((part) => {
const idx = part.indexOf("=");
return [part.slice(0, idx), part.slice(idx + 1)];
})
);
const timestamp = pairs.t;
const expected = crypto
.createHmac("sha256", secret)
.update(timestamp + "." + rawBody)
.digest("hex");
return crypto.timingSafeEqual(Buffer.from(pairs.v1), Buffer.from(expected));
}import hmac
import hashlib
def verify_signature(raw_body: str, signature_header: str, secret: str) -> bool:
# signature format: t=1700000000,v1=<hex>
parts = dict(part.split("=", 1) for part in signature_header.split(","))
payload = (parts["t"] + "." + raw_body).encode("utf-8")
expected = hmac.new(secret.encode("utf-8"), payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(parts.get("v1", ""), expected)# Webhook requests are sent by Chatmaid to your server.
# Use cURL locally only to simulate payload delivery to your endpoint:
curl -X POST https://your-receiver.example/webhooks/chatmaid \
-H "Content-Type: application/json" \
-H "X-Chatmaid-Event: message.sent" \
-H "X-Chatmaid-Signature: t=1700000000,v1=<signature>" \
-d '{"event":"message.sent","timestamp":"2026-02-06T14:20:00.000Z","data":{"messageId":"msg_abc123"}}'Payloads
Message lifecycle events (message.sent, message.delivered, message.read, message.failed) share the same data shape:
{
"event": "message.sent",
"timestamp": "2026-02-06T14:20:00.000Z",
"data": {
"messageId": "msg_abc123def456",
"from": "+15551234567",
"to": "+15557654321",
"status": "sent",
"sentAt": "2026-02-06T14:19:12.000Z",
"deliveredAt": null,
"readAt": null,
"failedAt": null,
"errorCode": null,
"errorMessage": null
}
}message.received carries the inbound message:
{
"event": "message.received",
"timestamp": "2026-02-06T14:21:09.000Z",
"data": {
"messageId": "inmsg_abc123def456",
"from": "+15557654321",
"to": "+15551234567",
"content": "Thanks, got it!",
"type": "text",
"isGroup": false,
"groupId": null,
"receivedAt": "2026-02-06T14:21:08.000Z"
}
}For message.received, from is the external sender and to is your connected phone number. type is one of text, image, video, audio, document, sticker, location, contact, other; for media messages, content carries the caption when present.
Retries and Debugging
Failed webhook deliveries are retried up to 3 attempts with exponential backoff: 1 minute, 5 minutes, then 15 minutes after the initial attempt. Your endpoint should return HTTP 2xx quickly and process events asynchronously.
Idempotent Receiver Required
Your webhook handler must be idempotent. Retries can deliver the same event more than once.