Webhooks
A webhook is an https URL of yours to which WhatsMarketing sends a POST every time an event you subscribed to happens. They are created in Integrations → Webhooks & API (up to 10 per organization), with a whsec_… secret that is shown only once.
Events
| Event | When | Status |
|---|---|---|
contact.created |
A contact is created (dashboard, API, import or inbound WhatsApp) | Active |
contact.updated |
A contact's details change | Active |
contact.stage_changed |
A contact moves to another lifecycle stage | Active |
campaign.finished |
A campaign finishes sending | Active |
campaign.replied |
A recipient replies to a campaign | Coming soon |
conversation.opened, conversation.assigned, conversation.closed |
Lifecycle of a conversation | Coming soon |
message.received, message.sent |
Inbound and outbound messages | Coming soon |
automation.run |
An automation runs | Coming soon |
bot.handoff, bot.message |
An AI agent hands off to a person or replies | Coming soon |
You can subscribe to "coming soon" events now: they will start arriving once they go live, with no changes on your side.
What you receive
POST /your-path HTTP/1.1
Content-Type: application/json
User-Agent: WhatsMarketing-Webhooks/1.0
X-WM-Event: contact.created
X-WM-Delivery: 3f4e7f0a-9b1c-4d2e-8f3a-4b5c6d7e8f90
X-WM-Signature: t=1760436060,v1=5d41402abc4b2a76b9719d911017c592…{
"id": "3f4e7f0a-9b1c-4d2e-8f3a-4b5c6d7e8f90",
"event": "contact.created",
"createdAt": "2026-10-14T10:21:00.000Z",
"data": {
"organizationId": "f0e1d2c3-b4a5-4968-8776-655443322110",
"contactId": "0b1c2d3e-5f6a-4b7c-8d9e-0f1a2b3c4d5e",
"fullName": "Laura Pérez",
"primaryPhone": "+34612345678",
"primaryEmail": "laura@ejemplo.com",
"lifecycleStage": "Lead nuevo"
}
}id and X-WM-Delivery identify the delivery: if you receive the same value twice (because of a retry), process only the first one. data carries just enough to know what happened; for the full record request GET /contacts/{contactId}.
| Event | data |
|---|---|
contact.created |
organizationId, contactId, fullName, primaryPhone, primaryEmail, lifecycleStage |
contact.updated |
the same plus changedFields (the fields that changed) |
contact.stage_changed |
the same plus previousStage |
campaign.finished |
organizationId, campaignId, name, status, sent, failed |
Verifying the signature
The signature protects you from someone sending you fake events. It is the HMAC-SHA256 of the text "<t>.<raw body>" with your secret, in hexadecimal. Compute it with the body exactly as it arrived (without re-serializing the JSON), compare in constant time and discard timestamps older than 5 minutes.
import crypto from 'node:crypto';
function verify(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const expected = crypto.createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex');
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
return fresh && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}
// Express: use the raw body, not the already-parsed JSON.
app.post('/webhooks/wm', express.raw({ type: 'application/json' }), (req, res) => {
if (!verify(req.body.toString(), req.header('X-WM-Signature'), process.env.WM_WEBHOOK_SECRET)) {
return res.status(401).end();
}
res.status(200).end(); // respond now; process later
const event = JSON.parse(req.body);
// …
});import hmac, hashlib, time
def verify(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(p.split("=") for p in header.split(","))
expected = hmac.new(secret.encode(), f"{parts['t']}.".encode() + raw_body, hashlib.sha256).hexdigest()
fresh = abs(time.time() - int(parts["t"])) < 300
return fresh and hmac.compare_digest(expected, parts["v1"])<?php
function verify(string $rawBody, string $header, string $secret): bool {
parse_str(str_replace(',', '&', $header), $parts);
$expected = hash_hmac('sha256', $parts['t'] . '.' . $rawBody, $secret);
$fresh = abs(time() - (int) $parts['t']) < 300;
return $fresh && hash_equals($expected, $parts['v1']);
}Retries and pausing
- Any
2xxresponse within 10 seconds counts as received. - If it fails, we retry 5 times: after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours.
- Ten consecutive exhausted deliveries pause the webhook. You reactivate it from the dashboard; you can also retry the failed deliveries from there.
- Deliveries are kept for 7 days: in the dashboard you can see each one with its response.
Testing locally
- Start your server locally.
- Open a tunnel (
ngrok http 3000or Cloudflare Tunnel) and copy thehttpsURL. - Create the webhook with that URL and
contact.created. - Create a contact from the dashboard or with
POST /contacts: within seconds you'll see thePOSTin your console.