Skip to main content

Webhooks

Configure a URL on your instance and Tarceel POSTs a signed JSON payload to it whenever:

  • your instance receives an inbound message (message.received)
  • an outbound message's delivery status changes (message.status)
  • your linked session's connection status changes (session.status)
  • a prepaid instance's balance crosses its low-balance threshold (billing.low_balance)

This is the public contract for that payload shape and signature scheme — treat changes to it as breaking changes once you integrate against it.

Configuring a webhook​

All routes below are per-instance and accept either the instance's own API key or your account token (see Authentication).

MethodPathDescription
PUT/instances/:instanceId/webhookSet or rotate the webhook URL. Body: { "url": "https://..." }. Returns { url, secret } — the full secret is only ever returned here.
GET/instances/:instanceId/webhookCurrent config. secret is masked (e.g. whsec_******), or both fields are null if unset.
DELETE/instances/:instanceId/webhookRemoves the webhook config.
GET/instances/:instanceId/webhook/deliveries?limit=20Recent delivery attempts, most recent first — useful for debugging your own integration.

Rotating the URL issues a brand-new secret — update your verification code before rotating in production.

Envelope​

Every delivery is a JSON body of this shape:

{
"id": "cly9876...",
"event": "message.received",
"instanceId": "clx1234...",
"timestamp": 1730000000000,
"data": { "...": "event-specific, see below" }
}
  • id identifies this specific delivery, not the underlying WhatsApp message — use it to dedupe if your endpoint might see the same delivery twice (a retry, or a manual redrive). It stays the same across every retry attempt and across a redrive; a new logical event always gets a new id.
  • timestamp is milliseconds since epoch, when Tarceel dispatched the event (not when it's eventually delivered — retries can arrive later).

message.received​

Fired for every inbound message.

{
"event": "message.received",
"data": {
"id": "3EB0...",
"from": "923001234567@s.whatsapp.net",
"timestamp": 1730000000,
"type": "text",
"text": "Hello"
}
}

For a group message, from is the group's own JID (ends in @g.us) and data also carries author — the actual sender's JID. author is absent (not just empty) for a direct message.

type is "text", "location", "contact", "media", or "other". text is present only for "text"; the others carry their own field:

Location (type: "location")
{
"data": {
"type": "location",
"location": { "latitude": 24.8607, "longitude": 67.0011, "name": "Karachi", "address": "Sindh, Pakistan" }
}
}

name/address are only present when the sender's WhatsApp client included them.

Contact(s) (type: "contact")
{
"data": {
"type": "contact",
"contacts": [{ "displayName": "Jane Doe", "vcard": "BEGIN:VCARD\nFN:Jane Doe\nTEL;...\nEND:VCARD" }]
}
}

contacts is always an array — one entry for a single shared contact, more for a shared list. vcard is the real vCard (RFC 6350) text WhatsApp sent; we don't re-parse it, so parse it yourself if you need individual fields.

Media (type: "media") — image, video, audio, document, or sticker
{
"data": {
"type": "media",
"media": { "mediaType": "image", "mimetype": "image/jpeg", "caption": "look at this" }
}
}

caption is only present for image/video/document; fileName only for a document. This is metadata only — there is no download URL. WhatsApp's own media URL is encrypted and only decryptable with internal keys this API doesn't expose, so it wouldn't be independently usable anyway.

phone: when the sender has WhatsApp's privacy-LID feature active, from/author can be a @lid identifier instead of a phone number. data.phone, when present, is the sender's real phone number, resolved from the same message — prefer it over from/author whenever you need an actual number to key off of:

{ "data": { "from": "82988062781464@lid", "phone": "923001234567@s.whatsapp.net", "type": "text", "text": "Hello" } }

phone is absent only when the sender's privacy setting discloses no number at all.

message.status​

Fired for every outbound-message delivery-status transition: pending → sent → delivered → read (or failed).

{
"event": "message.status",
"data": {
"id": "83ad449d-c6ef-49c0-97be-c08b5e452d73",
"whatsappId": "3EB0C6F5BC2D50B88712F0",
"status": "delivered"
}
}

data.id matches the id your send call returned — use it to correlate this event back to the message you sent. data.whatsappId is the underlying WhatsApp message id, for consumers that specifically need it.

session.status​

Fired whenever your linked-device connection status changes — including logged_out, which means the number needs a new QR scan.

{ "event": "session.status", "data": { "status": "connected" } }

status is one of disconnected, connecting, qr_pending, connected, reconnecting, logged_out.

billing.low_balance​

Fired once, for a prepaid instance, the first time a send drops the balance to or below the low-balance threshold since the last top-up.

{ "event": "billing.low_balance", "data": { "creditBalance": 18, "threshold": 20 } }

Verifying the signature​

Every request carries:

  • X-Tarceel-Event: the event type (same as event in the body)
  • X-Tarceel-Signature: sha256=<hex hmac>, computed as HMAC-SHA256(secret, rawRequestBody)

Verify using the exact raw bytes of the request body — parsing then re-serializing JSON can change whitespace and break the signature.

const crypto = require('node:crypto');

function isValid(secret, rawBody, signatureHeader) {
const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(signatureHeader);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Retries and dead-lettering​

If your endpoint doesn't return a 2xx (or times out after 10s), Tarceel retries with exponential backoff — 5 attempts total, at roughly 5s, 10s, 20s, and 40s after the previous one. After the final attempt fails, the delivery is marked dead_letter and isn't retried further automatically. GET /instances/:instanceId/webhook/deliveries shows attempt counts and the last error for any delivery, dead-lettered or not.

Redriving a dead-lettered delivery​

If a delivery was dead-lettered because your endpoint was briefly down — longer than the ~75-second automatic retry window — and you've since fixed it, trigger one fresh 5-attempt retry cycle:

POST /instances/:instanceId/webhook/deliveries/:deliveryId/retry

Resends the exact original envelope (same id, same data) to your current webhook URL/secret — so rotating your URL and then redriving correctly reaches the new one.

ResponseMeaning
202 { "status": "redriven" }Re-enqueued.
404No such delivery for this instance.
409Not dead-lettered (still pending/retrying, already succeeded), or no webhook is configured.

There's no bulk/automatic redrive — each dead-lettered delivery is redriven one at a time, on purpose, so you don't accidentally replay a burst of stale events against a server that's still catching up.

Delivery records aren't permanent​

GET .../webhook/deliveries is for debugging your own integration's recent activity, not a permanent audit log — delivery records are deleted automatically after 90 days (including the exact payload we sent you, since it can contain message content). Redrive a dead-lettered delivery well within that window if you need it.

Testing locally​

Point your webhook at a tool like webhook.site or ngrok while developing, so you can see payloads land in real time and inspect the signature header before wiring up real verification code.