Skip to content

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.

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

POST /webhooks needs the webhooks:manage scope. The URL must be HTTPS (plain HTTP is accepted only for localhost).

Terminal window
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"}'
{
"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.

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.

Terminal window
curl -X POST https://app.whatsetter.com/api/v1/webhooks/66f1c0ffee00000000000abc/test \
-H "Authorization: Bearer $WHATSETTER_API_KEY"
{
"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.

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"
}
}
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.
{
"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.

Every delivery is signed with HMAC-SHA256 of the raw request body, using your subscription secret. The headers you receive:

Content-Type: application/json
User-Agent: Whatsetter-Webhooks/1.0
X-Whatsetter-Event: contact.qualified
X-Whatsetter-Delivery: evt_9f2c1a7e4b8d3056a1cf7e2b9d4a6013
X-Whatsetter-Timestamp: 2026-09-15T09:10:12.517Z
X-Whatsetter-Api-Version: 2026-06-01
X-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
// });
  • 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 /leads if you suspect a gap.
  • The request times out after 10 seconds. Answer 2xx right away and do your processing asynchronously; a timeout counts as a failure.
  • Deduplicate on event_id anyway. 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_at and last_delivery_status on the subscription tell you how the last delivery went.
  • 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.