Skip to content
WhatsMarketingDeveloper Hub

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

HTTP
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…
JSON
{
  "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.

Node.js
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);
  // …
});
Python
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
<?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 2xx response 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

  1. Start your server locally.
  2. Open a tunnel (ngrok http 3000 or Cloudflare Tunnel) and copy the https URL.
  3. Create the webhook with that URL and contact.created.
  4. Create a contact from the dashboard or with POST /contacts: within seconds you'll see the POST in your console.