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
2xxwithin 10 seconds; do slow work asynchronously. - Up to 20 endpoints per project.
eventsis a list of types, or["*"]for all.
Manage endpoints with GET/PATCH/DELETE /v1/webhooks/{id} (PATCH { "enabled": false } pauses an endpoint).
Events
| Type | When |
|---|---|
instance.qr_required | A QR or pairing code is waiting to be scanned |
instance.connected | The instance connected (after pairing, restore or reconnect) |
instance.disconnected | The connection closed (disconnect request, QR timeout, replaced connection) |
instance.logged_out | The device was unlinked; a new QR scan is needed |
instance.error | Reconnection gave up, or WhatsApp restricted the account |
message.queued | A message was accepted |
message.sent | Transmitted to WhatsApp |
message.delivered | Delivered to the recipient's device |
message.read | Read by the recipient |
message.failed | Will not be sent (see data.failure) |
message.unknown | A send attempt's outcome could not be confirmed |
message.received | An incoming message |
conversation.created | A team inbox conversation started (first message from or to a new person on an instance) |
conversation.assigned | A conversation was assigned, reassigned or unassigned |
conversation.status_changed | A conversation was resolved, reopened, snoozed or marked pending |
contact.opted_out | A contact opted out (STOP message, API, dashboard) |
contact.opted_in | A contact opted in (START message, API, import, dashboard) |
campaign.started | A campaign started; its audience was frozen |
campaign.paused | A campaign was paused by a user or automatically (data.reason) |
campaign.resumed | A paused campaign continues |
campaign.completed | Every recipient of a campaign was handled (data.stats) |
campaign.cancelled | A campaign was cancelled |
campaign.failed | A campaign could not start (data.reason) |
lead.created | A lead was captured (WhatsApp, form, click-to-chat link, API, import, dashboard) |
lead.updated | Lead fields changed (data.changes) |
lead.status_changed | A lead changed status (data.previousStatus) |
lead.qualified | A lead became sales-qualified |
lead.assigned | A lead owner was set or changed |
lead.converted | A lead was converted into a deal (data.dealId) |
lead.sla_breached | A lead missed its first-response target |
deal.created / deal.updated | A deal was created / changed |
deal.stage_changed | A deal moved to another stage |
deal.won / deal.lost | A deal was closed |
company.created / company.updated | A company was created / changed or merged |
task.created / task.due / task.completed | CRM task lifecycle |
webhook.test | Sent 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
| Header | Value |
|---|---|
WhatsTIG-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256> |
WhatsTIG-Event-Id | Event ID (same on every retry) |
WhatsTIG-Delivery-Id | Delivery ID (one per event × endpoint) |
WhatsTIG-Event-Type | Event type |
WhatsTIG-Attempt | Attempt number, starting at 1 |
Verify the signature
The signature is HMAC-SHA256(secret, "<t>.<raw body>"), hex encoded. Always:
- use the raw request body (before JSON parsing),
- reject timestamps older than 5 minutes (replay protection),
- 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 eventid. - Events are not guaranteed to arrive in order. Use
data.occurredAtand the status order (QUEUED < SENT < DELIVERED < READ) and ignore regressions. - A delivery counts as successful only on a
2xxresponse.
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(filterstatus:pending,succeeded,failed= retrying,dead)GET /v1/webhooks/{id}/deliveries/{deliveryId}— every attempt with status code, error and duration, plus the event bodyPOST /v1/webhooks/{id}/deliveries/{deliveryId}/replay— deliver the same event againGET /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.