Webhooks Guide
A conceptual walkthrough of the WhatsScale Public Webhook API. For the per-endpoint reference (request/response shapes, status codes), see the webhooks section of /docs.
1. Subscribe to events
Generate an API key in your dashboard, then call POST /v1/webhooks/subscribe from your backend with the URL where you want to receive incoming WhatsApp messages. The response includes a subscription_id and a signing_secret.
The signing_secret is shown ONCE — store it now.
It is never returned by GET /v1/webhooks, and there is no in-place rotation in v1. If you lose it, the recovery path is DELETE /v1/webhooks/:id followed by a fresh POST /v1/webhooks/subscribe — the subscription_id and signing_secret both change.
Other guarantees of the subscribe call: duplicate subscriptions (same session + trigger_type + filter_id + webhook_url) return HTTP 409 with the existing subscription_id — never a silent re-issue of the secret. The cap of 50 active subscriptions per user returns HTTP 422 WEBHOOK_SUBSCRIPTION_CAP_EXCEEDED. You must own the WhatsApp session you're subscribing to.
2. Verify the signature on every delivery
Every outbound delivery includes two headers:
X-WhatsScale-Signature: sha256=<hex>— HMAC-SHA-256 over the raw request body using your subscription'ssigning_secretX-WhatsScale-Timestamp: <unix-epoch-seconds>— the worker's clock at sign time, used to bound replay attacks
Verify on every request. Use timing-safe equality (e.g. crypto.timingSafeEqual in Node, hmac.compare_digest in Python) — naive == comparison opens a timing side-channel. Reject events whose timestamp is more than ~5 minutes from your clock to limit replay risk.
HMAC the RAW request body bytes — do not parse-then-restringify the JSON. Key order and whitespace must be exact for the signature to match.
// Node 16+ — built-in crypto, no dependencies.
import crypto from 'node:crypto';
const SECRET = process.env.WEBHOOK_SIGNING_SECRET; // store from subscribe response
const REPLAY_WINDOW_SECONDS = 300; // recommended 5 minutes
export function verifyWhatsScaleWebhook(req) {
const signature = req.headers['x-whatsscale-signature'];
const timestamp = req.headers['x-whatsscale-timestamp'];
const rawBody = req.rawBody; // Buffer/string of the raw request body
if (!signature || !timestamp || !rawBody) return false;
// 1) Replay window — reject events too far from now
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - Number(timestamp)) > REPLAY_WINDOW_SECONDS) return false;
// 2) Compute the expected signature
const expected =
'sha256=' +
crypto.createHmac('sha256', SECRET).update(rawBody).digest('hex');
// 3) Timing-safe compare. Both buffers MUST be the same length —
// crypto.timingSafeEqual throws otherwise.
const a = Buffer.from(expected);
const b = Buffer.from(signature);
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}3. Retry schedule and the 4xx-is-permanent rule
Deliveries are at-least-once with exponential backoff. The schedule is applied per delivery; Retry-After headers from your endpoint on HTTP 429 are honoured (floored at the scheduled delay).
| Attempt | Schedule offset | Cumulative |
|---|---|---|
| 1 | immediate | 0s |
| 2 | +30 seconds | 30s |
| 3 | +2 minutes | ~2.5 min |
| 4 | +10 minutes | ~12 min |
| 5 | +1 hour | ~1h 12m |
| 6 | +6 hours | ~7h 12m |
After attempt 6, the delivery is marked dead. | ||
4xx responses (other than 408 and 429) are PERMANENT.
We mark the delivery dead immediately and do not retry. Most webhook 4xx responses indicate a permanent customer-side issue (URL is wrong, the workflow was deleted, auth is misconfigured) — retrying for 7 hours wastes our worker budget and your endpoint's logs. If you need the request retried, return 5xx, 408, or 429.
3xx redirects are also treated as dead — we do not follow them. Configure your endpoint with its final URL.
Per-attempt timeout is 10 seconds. WhatsScale considers any 2xx response code a successful delivery, regardless of body content — configure your endpoint to return non-2xx (we recommend 500) on processing error so we'll retry.
4. Inspect deliveries
Two endpoints surface delivery state for debugging and audit.
GET /v1/webhooks/:id/deliveries— all statuses (pending, delivering, succeeded, failed, dead). Filter with?status=; paginate with the cursor pairbefore_ts+before_id(both halves required together). Use?status=succeededto confirm an event was delivered, or no filter to trace the full lifecycle of a specificevent_id.GET /v1/webhooks/:id/failures— the dead-letter queue specifically. Older endpoint, narrower lens — equivalent to/deliveries?status=deadwith a different cursor shape (timestamp-only).
payload_preview is opaque text — do NOT JSON.parse it
Both endpoints return payload_preview: the first 500 characters of the JSON-stringified envelope. Cuts mid-string or mid-object are likely. Use it for visual debugging only — for full payload access, contact support to query webhook_deliveries.payload directly.
To replay a dead delivery (after fixing your endpoint), see section 7. Older dead deliveries can be re-driven without DELETE + re-subscribe.
5. Idempotency keys
At-least-once delivery means your endpoint may receive the same event multiple times. Always dedupe before doing real work.
PRIMARY: event_id
Top-level field on the envelope. Stable across all retry attempts of the same delivery. Use this to dedupe retries — covers 99% of the duplication you'll see.
SECONDARY: data.message_id
The underlying WhatsApp message ID. Use this only if you have multiple webhook subscriptions on the same session — each subscription gets its own event_id for the same WhatsApp message, but data.message_id is shared.
// Dedupe pattern (any language)
//
// PRIMARY KEY — event_id (envelope-level, in JSON body)
// Stable across retry attempts of the same delivery.
// Use this to skip duplicates from at-least-once retry.
//
// SECONDARY KEY — data.message_id (the underlying WhatsApp message ID)
// Use only if you have multiple webhook subscriptions on the same
// session — different subscriptions produce different event_id values
// for the same WhatsApp message.
// Pseudocode — happens in your handler before doing real work
const event = JSON.parse(rawBody);
if (await yourCache.has(event.event_id)) {
// Already processed this delivery (likely a retry). Acknowledge with
// 200 so we stop retrying, but skip the side effects.
return res.status(200).end();
}
await yourCache.set(event.event_id, true, ttlSeconds = 7 * 24 * 3600);
// Now process the event for real.
processIncomingMessage(event.data);
res.status(200).end();Group webhook payload — three participant fields
Group events (trigger_type: "group") include three fields that identify the underlying WhatsApp sender. Each has different reliability guarantees — pick the right one for your use case.
| Field | Reliability | Use it for |
|---|---|---|
participant_ide.g. 263195814002890@lid | Reliable. Always populated; only empty in unrecoverable engine cases. | In-group operations: kick, demote, list-membership match. Round-trips with /v1/groups/:groupId/participants id field — pass it back to action endpoints directly. |
participant_namee.g. "John Smith" | Mutable. User-controlled — anyone can change their WhatsApp display name. | Display only. Never use for identity decisions, ban lists, or moderation logic — a spammer can change their name to evade pattern matching. |
participant_phonee.g. "918585010183" | Best-effort. May be empty depending on the participant's privacy settings or whether the session has them as a saved contact. | Cross-group correlation, ban-by-number, contact lookup, DM the offender. Always handle empty-string defensively — fall back to participant_id-only when phone is empty. |
Note on LID stability: a participant's participant_id is stable within a group, but the same person in different groups may have different LIDs by WhatsApp's privacy design. Don't assume one LID = one person across groups. After a removed participant rejoins, their LID for that group may also regenerate — re-list participants if you need the current value.
1-on-1 webhook payload — phone & sender identity
1-on-1 events (trigger_type: "1on1") identify the sender with a phone field and a stable id field — the same pattern as group participants.
| Field | Reliability | Use it for |
|---|---|---|
from_number / chat_ide.g. "918585010183" | Best-effort phone. The contact's phone number. For contacts WhatsApp has migrated to a LID, this is resolved from your session's contact map; if it can't be resolved it falls back to the bare LID digits. | Reply-to, CRM match, contact lookup. Was previously a raw …@lid for migrated contacts — it is now the phone. Handle the rare unresolved case defensively. |
from_ide.g. 263195814002890@lid or …@c.us | Reliable. The raw WhatsApp jid (the parallel to a group's participant_id). Always present. | A stable correlation/dedup key. If you previously keyed on the raw chat_id value, switch to from_id. Filters set on either the phone or the raw jid both still match. |
7. Manually replay a dead delivery
When a delivery transitions to dead after exhausting its retry budget, you can replay it once you've fixed the underlying issue (e.g. your endpoint was returning 500 due to a deploy bug). Use POST /v1/webhooks/deliveries/:id/retry.
Replay semantics
- A replay is a new delivery row linked to the original via
parent_delivery_id. The originaldeadrow is preserved unchanged for audit. - The replay carries the same
event_idas the original (Stripe-style — your dedupe layer should treat it as the same logical event). - Only
deaddeliveries are retryable. Non-dead returns 409 withcurrent_statusin the body. - If a previous replay on the same parent is still pending, retrying returns 409 with
existing_retry_id. Wait for it to terminate (succeededordead) before retrying again.
⚠ Critical: prevent retry storms in your dedupe code
When your endpoint detects a duplicate event_id (a replay of an event you already processed) and decides to discard it, return HTTP 200 OK with an empty body. Never return a 4xx for "already processed" — a 4xx tells WhatsScale the delivery failed and triggers another replay attempt against your endpoint. The result is a feedback loop that retries until the dead cap is hit again. 200 OK with no work done is the correct dedupe response.
If you specifically need re-processing — say, the original handler had a bug that silently dropped the event but recorded it as processed — clear your dedupe state for that event_id before calling retry. WhatsScale cannot do this for you; only your dedupe layer knows which event_ids are recorded.
Active-flight window — quick double-retries on a fast endpoint may both succeed
The dup-prevention check (409 "Replay already in flight") is by status, not permanent. Once a replay terminates (succeeded or dead), a fresh retry on the same parent IS allowed. If you call retry twice on the same delivery_id with a fast-responding endpoint, both calls may return 201 — each enqueues a separate replay because the first one finished within milliseconds. Use GET /v1/webhooks/:id/deliveries to verify the previous replay's state before issuing another retry.
8. Group management for moderation
The /v1/groups/* endpoints pair with group webhook events to close the moderation loop: receive an event, decide a participant is spam, take action by passing the same data.participant_id back to the relevant action endpoint. No translation needed — participant_id is the same LID format you pass to /participants/remove, /admin/demote, etc.
Example flow: kick spammers automatically
// 1. Webhook arrives
{
"event_id": "...",
"trigger_type": "group",
"data": {
"group_id": "[email protected]",
"participant_id": "263195814002890@lid",
"participant_name": "Mahidhar",
"body": "BUY CRYPTO HERE: <spam>",
...
}
}
// 2. Your spam classifier flags it. Kick the sender.
POST /v1/groups/[email protected]/participants/remove
{ "session": "user_xxx",
"participants": [{ "id": "263195814002890@lid" }] }Idempotency: WAHA-style 4xx for "already in target state"
The action endpoints don't normalise "no-op" responses. Removing an already-not-in-group participant returns 4xx; adding an already-in one returns 4xx; promoting/demoting to the current role returns 4xx. Treat those 4xx responses as success-equivalent in your retry logic — the operation's net effect is achieved. The code: WAHA_<status> field in the body distinguishes these from genuine upstream errors.
Engine quirk: 502 (with code: 500) on missing groups
If you call a group action with a groupId that doesn't exist for your session, the response is HTTP 502 with code: 500 in the body — that's the underlying engine's shape. Treat this as "your groupId is wrong, fix it" rather than "upstream is down, retry." A genuine upstream outage produces a different 502 shape (no engine-specific message).
9. Known limitation: ingress rate-limit loss
~2% of inbound events may be dropped under sustained burst load.
WhatsScale ingests WhatsApp events through a rate-limited webhook endpoint (current Nginx config: 10 requests/sec per session, burst 20). Under sustained burst load above this threshold, a small fraction of events — currently ~1.92% measured on production over the trailing 24 hours as of April 30, 2026 — are rejected at ingress with HTTP 429 from Nginx and never reach the delivery worker. v1 cannot recover these.
If you need strict at-least-once delivery for high-volume sessions, contact support to discuss a higher rate limit. The number above scales linearly with your traffic — at 10× volume, expect ~2% of a larger denominator.
This figure is observed, not guaranteed. We will update this number as Nginx config and traffic patterns change.
Operational reminder: never include your signing_secret in a WhatsApp message body.
Message content flows through the same webhook system you're configuring — a secret in a message body would be sent to every subscriber on that session.