Webhooks
Endpoints, events, signature, and retries.
Webhooks — managing endpoints
GET /instances/{instance_id}/webhooks
POST /instances/{instance_id}/webhooks
PATCH /instances/{instance_id}/webhooks/{webhook_id}
POST /instances/{instance_id}/webhooks/{webhook_id}/enabled { "enabled": true }
DELETE /instances/{instance_id}/webhooks/{webhook_id}
POST /instances/{instance_id}/webhooks
{
"destination_url": "https://your-site.com/webhook",
"events": ["message", "message.ack", "session.status", "message.unknown"],
"client_sign_secret": "your-secret",
"ignore_status": true
}
→ 201 { "success": true, "webhook_id": "whk_…" } Webhooks are created disabled — enable via the enabled path. PATCH replaces the whole config, so always send destination_url and events; an empty client_sign_secret keeps the current secret and clear_client_sign = true removes it. All of this is also available on the session page.
Webhooks — events
session.status | Session status changed (WORKING, SCAN_QR_CODE, FAILED…) with the linked phone. |
message | Incoming message from others. |
message.any | Every message in the session, incoming and outgoing (fromMe). |
message.ack | Delivery/read update for a message you sent (ack field). |
message.reaction | Reaction (emoji) on a message. |
message.edited | Message edited. |
message.revoked | Message deleted for everyone. |
message.unknown | Platform event: a message you sent via the API became unknown and needs your decision. |
More events are available (groups, calls, labels, polls, presence) — see the full list in the webhook form on the session page.
ignore_status (default true): WhatsApp Status/Stories (status@broadcast) are not sent to you. Set false to receive them with message events.
Webhooks — request sent to your endpoint
POST https://your-site.com/webhook
Content-Type: application/json
X-TProTool-Event-Id: evt_…
X-TProTool-Timestamp: 1760209200
X-TProTool-Signature: v1=…
{
"event_id": "evt_…",
"event": "message",
"instance_id": "ins_…",
"timestamp": 1760209200000,
"payload": {
"id": "…",
"from": "[email protected]",
"from_phone": "9665xxxxxxxx",
"fromMe": false,
"body": "Hello",
"hasMedia": true,
"media": {
"status": "ready",
"mimetype": "image/jpeg",
"media_id": "med_…",
"url": "https://…/m/…",
"expires_at": "…"
}
}
}
// message.unknown
{
"event_id": "evt_…",
"event": "message.unknown",
"instance_id": "ins_…",
"payload": { "message_id": "msg_…", "client_reference": "order-123", "to": "9665xxxxxxxx", "type": "text", "error_code": "TIMEOUT" }
}
// error_code: TIMEOUT | WORKER_DIED media.status = ready | File stored — download from url before expires_at. |
media.status = too_large | File exceeds the allowed size and was not stored. |
media.status = failed | Could not download the file from WhatsApp. |
media.status = outgoing | Media in a message sent by the session — no copy is stored. |
media.status = unavailable | Media is not available for download. |
Webhooks — verifying the signature
When client_sign_secret is set, the X-TProTool-Signature header carries v1= followed by HMAC-SHA256 of (timestamp + "." + raw request body) with your secret. X-TProTool-Event-Id and X-TProTool-Timestamp are always sent. Reject requests whose signature does not match or whose timestamp is older than 5 minutes.
<?php
$secret = 'your-secret';
$raw = file_get_contents('php://input');
$ts = $_SERVER['HTTP_X_TPROTOOL_TIMESTAMP'] ?? '';
$sig = $_SERVER['HTTP_X_TPROTOOL_SIGNATURE'] ?? '';
$expected = 'v1=' . hash_hmac('sha256', $ts . '.' . $raw, $secret);
if (!hash_equals($expected, $sig) || abs(time() - (int) $ts) > 300) {
http_response_code(401);
exit;
}
$event = json_decode($raw, true);
// ignore if $event['event_id'] was already processed
http_response_code(200);
Webhooks — responses and retries
Reply with any 2xx within 8 seconds for a successful delivery. Otherwise delivery is retried with growing intervals (5, 10, 20, 40, 80 seconds) up to 6 attempts, then marked as failed permanently; you can redeliver it manually from the delivery log on the session page.
410 Gone: the webhook is disabled and nothing more is sent until you enable it again. 429 with Retry-After: the attempt is postponed to the requested time (up to one hour).
event_id stays the same for an event across all attempts — store it and ignore events you have already received. Reply fast and process afterwards if handling takes long.