Signed webhooks
Instead of polling the API in a loop, let WhatSetter push events to your server the moment they happen. Every delivery is signed, so you can be sure it comes from us.
The events
Section titled “The events”| Event | Fired when | Status |
|---|---|---|
contact.qualified |
The agent qualifies a lead (your prompt’s criteria) | Live |
contact.not_qualified |
The agent rules a lead out | Live |
agent.disconnected |
A WhatsApp number disconnects | Live |
booking.created |
A meeting is booked in the conversation | Not emitted yet |
message.received |
A lead replies | Not emitted yet |
Create a subscription
Section titled “Create a subscription”POST /webhooks needs the webhooks:manage scope. The URL must be HTTPS (plain HTTP is accepted only for localhost).
curl -X POST https://app.whatsetter.com/api/v1/webhooks \ -H "Authorization: Bearer $WHATSETTER_API_KEY" \ -H "Content-Type: application/json" \ -d '{"url":"https://your-server.com/webhooks/whatsetter","events":["contact.qualified","contact.not_qualified"],"description":"CRM sync"}'const res = await fetch('https://app.whatsetter.com/api/v1/webhooks', { method: 'POST', headers: { Authorization: `Bearer ${process.env.WHATSETTER_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ url: 'https://your-server.com/webhooks/whatsetter', events: ['contact.qualified', 'contact.not_qualified'], description: 'CRM sync', }),});const { data } = await res.json();console.log(data.secret); // whsec_… shown once, store it nowimport os, requests
r = requests.post( "https://app.whatsetter.com/api/v1/webhooks", headers={"Authorization": f"Bearer {os.environ['WHATSETTER_API_KEY']}"}, json={ "url": "https://your-server.com/webhooks/whatsetter", "events": ["contact.qualified", "contact.not_qualified"], "description": "CRM sync", },)print(r.json()["data"]["secret"]) # whsec_… shown once, store it now{ "data": { "id": "66f1c0ffee00000000000abc", "url": "https://your-server.com/webhooks/whatsetter", "events": ["contact.qualified", "contact.not_qualified"], "active": true, "description": "CRM sync", "secret_prefix": "whsec_3f9a", "secret": "whsec_3f9a…", "last_delivery_at": null, "last_delivery_status": null, "created_at": "2026-09-19T10:00:00.000Z" }}The response contains the signing secret (whsec_…). It is shown only here: store it immediately. Later calls to GET /webhooks only return secret_prefix.
Errors that matter:
| HTTP | code |
Why |
|---|---|---|
| 422 | validation_error |
Missing or non-HTTPS url, empty or unknown events, description longer than 255 characters. |
| 429 | quota_exceeded |
The workspace already has 10 subscriptions. Delete one first. |
Test the delivery
Section titled “Test the delivery”POST /webhooks/{id}/test immediately sends a signed sample event to the subscription’s URL and reports how your endpoint answered. The sample uses the first event of the subscription and carries "is_test": true.
curl -X POST https://app.whatsetter.com/api/v1/webhooks/66f1c0ffee00000000000abc/test \ -H "Authorization: Bearer $WHATSETTER_API_KEY"const res = await fetch('https://app.whatsetter.com/api/v1/webhooks/66f1c0ffee00000000000abc/test', { method: 'POST', headers: { Authorization: `Bearer ${process.env.WHATSETTER_API_KEY}` },});console.log(await res.json());import os, requests
r = requests.post( "https://app.whatsetter.com/api/v1/webhooks/66f1c0ffee00000000000abc/test", headers={"Authorization": f"Bearer {os.environ['WHATSETTER_API_KEY']}"},)print(r.json()){ "data": { "delivered": true, "endpoint_status": 200, "error": null, "event_id": "evt_9f2c1a7e4b8d3056a1cf7e2b9d4a6013" }}delivered is false when your endpoint answered a non-2xx status (see endpoint_status) or could not be reached within 10 seconds (see error). A 404 not_found means the subscription id is unknown or belongs to another workspace.
What you receive
Section titled “What you receive”Every delivery shares the same top-level envelope. The event’s own fields sit next to the envelope fields, not nested under a payload key.
{ "event_type": "contact.qualified", "event_id": "evt_9f2c1a7e4b8d3056a1cf7e2b9d4a6013", "api_version": "2026-06-01", "occurred_at": "2026-09-15T09:10:12.517Z", "team_id": "6b0c1d2e3f405162738a", "is_test": false,
"campaign": { "id": "6b0c1d2e3f4051627abc", "slug": "acme-morocco-1-ab12", "name": "Alex" }, "contact": { "id": "6b0c1d2e3f4051627def", "lead_id": "212600000000@c.us", "name": "Sam", "phone": "212600000000", "classification": "qualified", "ai_reasoning": "Looking for an SUV, budget confirmed, wants a callback.", "conversation_url": "https://app.whatsetter.com/dashboard/conversations?leadId=212600000000%40c.us", "external_reference": "your-crm-id-12345" }}Envelope fields
Section titled “Envelope fields”| Field | Notes |
|---|---|
event_type |
One of the events above. |
event_id |
Unique per delivery. Use it to deduplicate. |
api_version |
Currently 2026-06-01. |
occurred_at |
ISO 8601, UTC. |
team_id |
The WhatSetter workspace the event belongs to. |
is_test |
true when sent from POST /webhooks/{id}/test. |
contact.qualified and contact.not_qualified
Section titled “contact.qualified and contact.not_qualified”| Field | Notes |
|---|---|
campaign.id |
Internal campaign id. |
campaign.slug |
Stable, human-readable campaign identifier. |
campaign.name |
The agent’s display name. |
contact.id |
Internal lead id, the same id as in GET /leads. |
contact.lead_id |
Raw WhatsApp id, for example 212600000000@c.us. |
contact.name |
Name detected by the agent, may be null. |
contact.phone |
Digits only, see the caution below. |
contact.classification |
The agent’s verdict. |
contact.ai_reasoning |
Why the agent decided that, in plain text. |
contact.conversation_url |
Deep link into the WhatSetter inbox. |
contact.external_reference |
Your own id, see the tip below. |
agent.disconnected
Section titled “agent.disconnected”{ "event_type": "agent.disconnected", "event_id": "evt_…", "api_version": "2026-06-01", "occurred_at": "2026-09-14T13:00:09.250Z", "team_id": "6b0c1d2e3f405162738a", "is_test": false,
"agent": { "name": "Alex", "phone": "212611111111@c.us", "campaign_id": "acme-morocco-1-ab12", "campaign_name": "Acme Morocco 1", "session_status": "disconnected", "disconnected_at": "2026-09-14T12:54:00.000Z" }}Sent once, when the incident is first detected. Reconnection reminders do not re-send it.
Verify the signature
Section titled “Verify the signature”Every delivery is signed with HMAC-SHA256 of the raw request body, using your subscription secret. The headers you receive:
Content-Type: application/jsonUser-Agent: Whatsetter-Webhooks/1.0X-Whatsetter-Event: contact.qualifiedX-Whatsetter-Delivery: evt_9f2c1a7e4b8d3056a1cf7e2b9d4a6013X-Whatsetter-Timestamp: 2026-09-15T09:10:12.517ZX-Whatsetter-Api-Version: 2026-06-01X-Whatsetter-Signature: sha256=<hex>X-Whatsetter-Delivery repeats the envelope’s event_id, and X-Whatsetter-Timestamp its occurred_at. Compute sha256= + the hex HMAC of the raw body, then compare it to X-Whatsetter-Signature in constant time.
import crypto from 'node:crypto';
function verify(rawBody, header, secret) { const expected = 'sha256=' + crypto .createHmac('sha256', secret) .update(rawBody) // the RAW body, before any JSON.parse .digest('hex'); const a = Buffer.from(expected); const b = Buffer.from(header || ''); return a.length === b.length && crypto.timingSafeEqual(a, b);}
// Express: keep the raw body for this route// app.post('/webhooks/whatsetter', express.raw({ type: 'application/json' }), (req, res) => {// if (!verify(req.body, req.get('X-Whatsetter-Signature'), process.env.WHATSETTER_WEBHOOK_SECRET)) {// return res.status(401).end();// }// const event = JSON.parse(req.body);// res.status(200).end(); // answer fast, process asynchronously// });import hmac, hashlib
def verify(raw_body: bytes, header: str, secret: str) -> bool: expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, header or "")
# Flask: use request.get_data(), not request.json, so the body stays raw# @app.post("/webhooks/whatsetter")# def whatsetter_webhook():# if not verify(request.get_data(), request.headers.get("X-Whatsetter-Signature", ""), os.environ["WHATSETTER_WEBHOOK_SECRET"]):# return "", 401# event = request.get_json()# return "", 200<?phpfunction verify(string $rawBody, string $header, string $secret): bool{ $expected = 'sha256=' . hash_hmac('sha256', $rawBody, $secret); return hash_equals($expected, $header);}
$rawBody = file_get_contents('php://input');$header = $_SERVER['HTTP_X_WHATSETTER_SIGNATURE'] ?? '';
if (!verify($rawBody, $header, getenv('WHATSETTER_WEBHOOK_SECRET'))) { http_response_code(401); exit;}$event = json_decode($rawBody, true);http_response_code(200);Delivery rules
Section titled “Delivery rules”- One attempt per event. If your endpoint is down or answers an error, the event is not retried and is lost. Keep the endpoint available, and reconcile with
GET /leadsif you suspect a gap. - The request times out after 10 seconds. Answer
2xxright away and do your processing asynchronously; a timeout counts as a failure. - Deduplicate on
event_idanyway. It costs nothing and protects you if retries are introduced later. - An event fans out to every active subscription that includes it, up to 10 subscriptions per workspace. Two subscriptions to the same URL each get their own copy.
last_delivery_atandlast_delivery_statuson the subscription tell you how the last delivery went.
Manage subscriptions
Section titled “Manage subscriptions”- GET /webhooks lists your subscriptions, without the secret.
DELETE /webhooks/{id}stops deliveries immediately. To change the URL or the events, delete and create again: you get a new secret.

