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).
| Method | Path | Description |
|---|---|---|
PUT | /instances/:instanceId/webhook | Set or rotate the webhook URL. Body: { "url": "https://..." }. Returns { url, secret } — the full secret is only ever returned here. |
GET | /instances/:instanceId/webhook | Current config. secret is masked (e.g. whsec_******), or both fields are null if unset. |
DELETE | /instances/:instanceId/webhook | Removes the webhook config. |
GET | /instances/:instanceId/webhook/deliveries?limit=20 | Recent 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" }
}
ididentifies 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 newid.timestampis 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 aseventin the body)X-Tarceel-Signature:sha256=<hex hmac>, computed asHMAC-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.
| Response | Meaning |
|---|---|
202 { "status": "redriven" } | Re-enqueued. |
404 | No such delivery for this instance. |
409 | Not 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.