Skip to content

Webhooks & receiving messages

WhatsTIG POSTs a signed JSON event to your endpoint whenever something happens in a project: connection changes, delivery receipts and incoming messages.

Register an endpoint

POST /v1/webhooks
Content-Type: application/json

{ "url": "https://api.example.com/whatstig/webhooks", "events": ["*"], "description": "production" }

Response 201 includes secret (whsec_...). It is shown only once; store it with your API key. Use POST /v1/webhooks/{id}/rotate-secret to replace it.

  • URLs must be HTTPS on a public host. Private, loopback, link-local and cloud-metadata addresses are rejected at registration (400 WEBHOOK_URL_REJECTED) and again for every delivery, after DNS resolution.
  • Redirects are not followed. Respond with any 2xx within 10 seconds; do slow work asynchronously.
  • Up to 20 endpoints per project. events is a list of types, or ["*"] for all.

Manage endpoints with GET/PATCH/DELETE /v1/webhooks/{id} (PATCH { "enabled": false } pauses an endpoint).

Events

TypeWhen
instance.qr_requiredA QR or pairing code is waiting to be scanned
instance.connectedThe instance connected (after pairing, restore or reconnect)
instance.disconnectedThe connection closed (disconnect request, QR timeout, replaced connection)
instance.logged_outThe device was unlinked; a new QR scan is needed
instance.errorReconnection gave up, or WhatsApp restricted the account
message.queuedA message was accepted
message.sentTransmitted to WhatsApp
message.deliveredDelivered to the recipient's device
message.readRead by the recipient
message.failedWill not be sent (see data.failure)
message.unknownA send attempt's outcome could not be confirmed
message.receivedAn incoming message
conversation.createdA team inbox conversation started (first message from or to a new person on an instance)
conversation.assignedA conversation was assigned, reassigned or unassigned
conversation.status_changedA conversation was resolved, reopened, snoozed or marked pending
contact.opted_outA contact opted out (STOP message, API, dashboard)
contact.opted_inA contact opted in (START message, API, import, dashboard)
campaign.startedA campaign started; its audience was frozen
campaign.pausedA campaign was paused by a user or automatically (data.reason)
campaign.resumedA paused campaign continues
campaign.completedEvery recipient of a campaign was handled (data.stats)
campaign.cancelledA campaign was cancelled
campaign.failedA campaign could not start (data.reason)
lead.createdA lead was captured (WhatsApp, form, click-to-chat link, API, import, dashboard)
lead.updatedLead fields changed (data.changes)
lead.status_changedA lead changed status (data.previousStatus)
lead.qualifiedA lead became sales-qualified
lead.assignedA lead owner was set or changed
lead.convertedA lead was converted into a deal (data.dealId)
lead.sla_breachedA lead missed its first-response target
deal.created / deal.updatedA deal was created / changed
deal.stage_changedA deal moved to another stage
deal.won / deal.lostA deal was closed
company.created / company.updatedA company was created / changed or merged
task.created / task.due / task.completedCRM task lifecycle
webhook.testSent by POST /v1/webhooks/{id}/test, only to that endpoint

Payload

{
  "id": "evt_01jq9a...",
  "type": "message.delivered",
  "apiVersion": "v1",
  "createdAt": "2026-10-09T10:15:22.120Z",
  "projectId": "prj_01jq...",
  "instanceId": "wa_01jq...",
  "externalReference": "tenant-101",
  "data": {
    "messageId": "msg_01jq...",
    "status": "DELIVERED",
    "direction": "outbound",
    "clientReference": "attendance-987",
    "recipient": "+923001234567",
    "sender": null,
    "type": "text",
    "providerMessageId": "3EB0A1B2C3D4E5F6A7B8C9",
    "failure": null,
    "content": null,
    "occurredAt": "2026-10-09T10:15:22.118Z"
  }
}

Instance events carry data: { status, reasonCode, phoneNumber }. message.received carries sender, type (text, image, document or unsupported) and content (text, caption, mimeType, fileName, and mediaId when inbound media storage is enabled). Content is included in the webhook even when the project does not retain message content. Messages sent by a campaign carry campaignId in message.* events. Campaign and contact events have instanceId: null; their data is described in Contacts, segments & campaigns API. Webhook payloads never contain credentials or encryption material.

Headers

HeaderValue
WhatsTIG-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256>
WhatsTIG-Event-IdEvent ID (same on every retry)
WhatsTIG-Delivery-IdDelivery ID (one per event × endpoint)
WhatsTIG-Event-TypeEvent type
WhatsTIG-AttemptAttempt number, starting at 1

Verify the signature

The signature is HMAC-SHA256(secret, "<t>.<raw body>"), hex encoded. Always:

  1. use the raw request body (before JSON parsing),
  2. reject timestamps older than 5 minutes (replay protection),
  3. compare in constant time.

TypeScript / Node.js (or use verifyWebhook from @whatstig/sdk):

import { createHmac, timingSafeEqual } from 'node:crypto';

export function verifyWhatsTig(rawBody: string, header: string | undefined, secret: string, toleranceSec = 300) {
  if (!header) throw new Error('missing signature');
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=', 2) as [string, string]));
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) throw new Error('stale signature');
  const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest();
  const given = Buffer.from(parts.v1 ?? '', 'hex');
  if (given.length !== expected.length || !timingSafeEqual(given, expected)) throw new Error('bad signature');
  return JSON.parse(rawBody);
}

C# / ASP.NET Core (or use WebhookVerifier.Verify from WhatsTig.Client):

using System.Security.Cryptography;
using System.Text;

app.MapPost("/whatstig/webhooks", async (HttpRequest req) =>
{
    using var reader = new StreamReader(req.Body);
    var raw = await reader.ReadToEndAsync();
    var header = req.Headers["WhatsTIG-Signature"].ToString();
    var parts = header.Split(',').Select(p => p.Split('=', 2)).ToDictionary(p => p[0], p => p[1]);
    var t = long.Parse(parts["t"]);
    if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - t) > 300) return Results.Unauthorized();
    var expected = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), Encoding.UTF8.GetBytes($"{t}.{raw}"));
    if (!CryptographicOperations.FixedTimeEquals(Convert.FromHexString(parts["v1"]), expected)) return Results.Unauthorized();
    // enqueue work, then:
    return Results.Ok();
});

Python:

import hashlib, hmac, json, time

def verify_whatstig(raw_body: bytes, header: str | None, secret: str, tolerance: int = 300) -> dict:
    if not header:
        raise ValueError("missing signature")
    parts = dict(p.split("=", 1) for p in header.split(","))
    t = int(parts["t"])
    if abs(time.time() - t) > tolerance:
        raise ValueError("stale signature")
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, parts.get("v1", "")):
        raise ValueError("bad signature")
    return json.loads(raw_body)

(FastAPI: read await request.body() before parsing JSON. Full example in the Python guide.)

Delivery guarantees

  • Events are written to a transactional outbox in the same database transaction as the change they describe, so an event is never lost or emitted for a change that did not happen.
  • Delivery is at least once. The same event (same id) can arrive more than once, for example when your endpoint timed out after processing it. Deduplicate on the event id.
  • Events are not guaranteed to arrive in order. Use data.occurredAt and the status order (QUEUED < SENT < DELIVERED < READ) and ignore regressions.
  • A delivery counts as successful only on a 2xx response.

Retries

Failed deliveries are retried with exponential backoff and ±20% jitter after about 30 s, 2 min, 10 min, 30 min, 1 h, 3 h and 12 h (8 attempts over roughly 16 hours). Then the delivery is dead. An endpoint is disabled automatically after 100 consecutive failed attempts; re-enable it with PATCH { "enabled": true }.

Inspecting and replaying

  • GET /v1/webhooks/{id}/deliveries (filter status: pending, succeeded, failed = retrying, dead)
  • GET /v1/webhooks/{id}/deliveries/{deliveryId} — every attempt with status code, error and duration, plus the event body
  • POST /v1/webhooks/{id}/deliveries/{deliveryId}/replay — deliver the same event again
  • GET /v1/events — every event of the project for 30 days, even without a webhook (useful for reconciliation polling)

The dashboard (Webhooks → endpoint) shows the same information and has replay and test buttons.