← Back to API referencePublic Webhook API · v1

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's signing_secret
  • X-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).

AttemptSchedule offsetCumulative
1immediate0s
2+30 seconds30s
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 pair before_ts + before_id (both halves required together). Use ?status=succeeded to 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=dead with 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.

FieldReliabilityUse it for
participant_id
e.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_name
e.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_phone
e.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.

FieldReliabilityUse it for
from_number / chat_id
e.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_id
e.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 original dead row is preserved unchanged for audit.
  • The replay carries the same event_id as the original (Stripe-style — your dedupe layer should treat it as the same logical event).
  • Only dead deliveries are retryable. Non-dead returns 409 with current_status in 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 (succeeded or dead) 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.

Reference