API v2 (beta)
Webhooks
A webhook endpoint is a URL on your server that WbizTool calls when something happens to a message: it was sent, it failed, it was cancelled or it expired. You get the news straight away instead of polling, and every request is signed so you can check it came from WbizTool.
| Operation | Endpoint | Scope |
|---|---|---|
| Add an endpoint | POST /api/v2/webhooks | messages:read |
| List endpoints | GET /api/v2/webhooks | messages:read |
| Get an endpoint | GET /api/v2/webhooks/{id} | messages:read |
| Remove an endpoint | DELETE /api/v2/webhooks/{id} | messages:read |
| Replace the signing secret | POST /api/v2/webhooks/{id}/rotate-secret | messages:read |
| Send a test event | POST /api/v2/webhooks/{id}/test | messages:read |
Endpoints belong to the workspace and get events for all its messages, whatever sent them: the API, the dashboard, campaigns, reminders or integrations. That's why a key limited to some numbers can't manage them: every call answers 403 number_not_allowed.
Events#
| Type | When |
|---|---|
message.sent | A message went out from your number. |
message.failed | A message couldn't be sent; error.message says why. |
message.cancelled | A waiting message was cancelled: with the API, from the dashboard, or because its number or campaign was removed. |
message.expired | A message wasn't sent before its expires_at. |
ping | A test event you asked for. |
Events for numbers (connected, logged out) and for incoming messages come later. Delivered and read ticks don't send events; get the message to see them.
Add an endpoint#
https://wbiztool.com/api/v2/webhooksBody: JSON (application/json)
Body
urlstringrequiredYour endpoint: a public
https://address, up to 500 characters.http://, private and local addresses (such aslocalhostor192.168.x.x) are refused.eventsarray of stringsoptionalThe event types it gets: any of
message.sent,message.failed,message.cancelledandmessage.expired. Default: all four.pingalways arrives when you ask for one.descriptionstringoptionalA note for yourself, up to 100 characters, such as
Order system.
A workspace can have up to 5 endpoints. The answer is 201 with the endpoint and its signing secret, shown this once. Store it where your receiver can read it, such as an environment variable. Later answers show only secret_last4; if you lose the secret, replace it.
curl -X POST https://wbiztool.com/api/v2/webhooks \
-H "Authorization: Bearer $WBIZTOOL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/wbiztool/events",
"events": ["message.sent", "message.failed"],
"description": "Order system"
}'import os
import requests
response = requests.post(
"https://wbiztool.com/api/v2/webhooks",
headers={"Authorization": f"Bearer {os.environ['WBIZTOOL_API_KEY']}"},
json={
"url": "https://example.com/wbiztool/events",
"events": ["message.sent", "message.failed"],
"description": "Order system",
},
timeout=30,
)
endpoint = response.json()
print("Store this secret now:", endpoint["secret"])// Node.js 18+ (built-in fetch). Save as .mjs to use top-level await.
const response = await fetch("https://wbiztool.com/api/v2/webhooks", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.WBIZTOOL_API_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({
url: "https://example.com/wbiztool/events",
events: ["message.sent", "message.failed"],
description: "Order system",
}),
});
const endpoint = await response.json();
console.log("Store this secret now:", endpoint.secret);<?php
$ch = curl_init('https://wbiztool.com/api/v2/webhooks');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('WBIZTOOL_API_KEY'), 'Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode([
'url' => 'https://example.com/wbiztool/events',
'events' => ['message.sent', 'message.failed'],
'description' => 'Order system',
]),
CURLOPT_RETURNTRANSFER => true,
]);
$endpoint = json_decode(curl_exec($ch), true);
curl_close($ch);
echo 'Store this secret now: ' . $endpoint['secret'];Answer (201):
{
"id": 41,
"url": "https://example.com/wbiztool/events",
"description": "Order system",
"events": ["message.sent", "message.failed"],
"secret_last4": "9c1e",
"active": true,
"disabled_reason": null,
"created_at": "2026-10-18T11:20:05.733Z",
"last_delivery": null,
"secret": "3f0b…9c1e"
}
The real secret is 64 hexadecimal characters. A retry of this request with the same Idempotency-Key doesn't add a second endpoint, and its answer leaves the secret out.
Errors when adding an endpoint#
| HTTP | Code | When |
|---|---|---|
400 | validation_failed | url is missing or too long, events is empty or has an unknown type, or a field is unknown |
403 | webhook_limit_reached | The workspace already has 5 endpoints |
403 | number_not_allowed | The key is limited to some numbers |
422 | invalid_webhook_url | url isn't a public https:// address |
List endpoints#
https://wbiztool.com/api/v2/webhooksThe workspace's endpoints that receive events, oldest first, in one data list.
curl https://wbiztool.com/api/v2/webhooks \
-H "Authorization: Bearer $WBIZTOOL_API_KEY"
The list leaves out endpoints that stopped. Owners also see the endpoints, and can remove them, on Settings → API keys under Webhook endpoints.
Get an endpoint#
https://wbiztool.com/api/v2/webhooks/{id}One endpoint. last_delivery shows how its latest delivery went. An endpoint that doesn't exist or was removed answers 404 webhook_not_found; a stopped one is shown with active: false.
Remove an endpoint#
https://wbiztool.com/api/v2/webhooks/{id}Nothing more is sent to it, including retries still waiting. The answer is {"id": 41, "deleted": true}.
Replace the signing secret#
https://wbiztool.com/api/v2/webhooks/{id}/rotate-secretMakes a new secret for the endpoint. The answer is 200 with the endpoint and its new secret, shown this once. The old secret stops at once: events sent after this are signed with the new one, retries of earlier events included. Update your receiver straight away.
curl -X POST https://wbiztool.com/api/v2/webhooks/41/rotate-secret \
-H "Authorization: Bearer $WBIZTOOL_API_KEY"
Send a test event#
https://wbiztool.com/api/v2/webhooks/{id}/testQueues a ping event to this endpoint, signed like every event, so you can test your receiver. The answer is 202:
{ "event_id": "evt_8a2f4c1d9b7e6a5f4c3b2a19", "type": "ping", "queued": true }
Your endpoint receives:
{
"id": "evt_8a2f4c1d9b7e6a5f4c3b2a19",
"type": "ping",
"created_at": "2026-10-18T11:21:40.002Z",
"data": { "webhook_id": 41 }
}
The event payload#
Each event is a POST with a JSON body:
{
"id": "evt_c41d7e2a9f3b8c6d5e4f3a21",
"type": "message.sent",
"created_at": "2026-10-18T10:15:09.912Z",
"data": {
"message": {
"id": 9817263,
"status": "sent",
"from": { "id": 101, "phone": "919800000000", "platform": "whatsapp" },
"to": { "phone": "+919876543210", "contact_id": 991 },
"text": "Hi Asha, your order #1042 is ready.",
"media": null,
"send_at": null,
"expires_at": null,
"created_at": "2026-10-18T10:15:02.311Z",
"sent_at": "2026-10-18T10:15:09.870Z",
"error": null
}
}
}
| Field | Description |
|---|---|
id | The event's id, evt_ and 24 characters. The same event keeps its id on every retry. |
type | One of the event types. |
created_at | When the event happened. |
data.message | For message.* events: the Message as it was then. A media.url in it works for at least 26 hours. |
data.webhook_id | For ping: the endpoint's id. |
Headers:
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | WbizTool-Webhooks/2 |
WbizTool-Event-Id | The event's id, the same on every retry |
WbizTool-Timestamp | When this attempt was sent, in Unix seconds, such as 1792310409 |
WbizTool-Signature | v1= and the hex HMAC-SHA256 of <timestamp>.<raw body>, made with the endpoint's secret |
Verify the signature#
Check every request before you trust it: work out the signature from the raw body (before your framework parses the JSON), compare it with the header in constant time, and refuse timestamps more than 5 minutes off, so an old request can't be replayed. It's the same scheme as API v1's webhook signatures, with the endpoint's own secret.
Then use WbizTool-Event-Id to drop repeats: the same event can arrive more than once, for example when your answer was lost and we tried again.
import hashlib, hmac, json, os, time
SECRET = os.environ["WBIZTOOL_WEBHOOK_SECRET"]
def verify(secret: str, body: bytes, headers) -> bool:
ts, sig = headers.get("WbizTool-Timestamp", ""), headers.get("WbizTool-Signature", "")
if not ts.isdigit() or abs(time.time() - int(ts)) > 300:
return False
mac = "v1=" + hmac.new(secret.encode(), ts.encode() + b"." + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(mac, sig)
# Flask
from flask import Flask, request
app = Flask(__name__)
seen = set() # use your database in production
@app.post("/wbiztool/events")
def events():
body = request.get_data() # the raw bytes
if not verify(SECRET, body, request.headers):
return "", 401
event_id = request.headers["WbizTool-Event-Id"]
if event_id in seen:
return "", 200 # a repeat: already handled
seen.add(event_id)
event = json.loads(body)
if event["type"] == "message.failed":
message = event["data"]["message"]
print("Failed:", message["id"], message["error"]["message"])
return "", 200In Django, pass request.body and request.headers.
const crypto = require("crypto");
const express = require("express");
const SECRET = process.env.WBIZTOOL_WEBHOOK_SECRET;
function verify(secret, rawBody, headers) {
const ts = String(headers["wbiztool-timestamp"] || ""), sig = String(headers["wbiztool-signature"] || "");
if (!/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const mac = "v1=" + crypto.createHmac("sha256", secret).update(ts + ".").update(rawBody).digest("hex");
return sig.length === mac.length && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(mac));
}
const app = express();
const seen = new Set(); // use your database in production
// express.raw keeps the body as a Buffer: the signature covers the exact bytes.
app.post("/wbiztool/events", express.raw({ type: "*/*" }), (req, res) => {
if (!verify(SECRET, req.body, req.headers)) return res.sendStatus(401);
const eventId = req.headers["wbiztool-event-id"];
if (seen.has(eventId)) return res.sendStatus(200); // a repeat: already handled
seen.add(eventId);
const event = JSON.parse(req.body);
if (event.type === "message.failed") {
const message = event.data.message;
console.log("Failed:", message.id, message.error.message);
}
res.sendStatus(200);
});
app.listen(3000);Answer 2xx quickly, then do slow work afterwards (in a queue, say).
Retries#
- Answer with any
2xxwithin 10 seconds. Anything else is a failure: another status code (4xxincluded), no answer within 10 seconds, or no connection within 3. Redirects are followed, at most 3, and each must lead to a public address. - A failed delivery is tried again after 1, 5 and 15 minutes, then 1, 2, 4, 8 and 8 hours: 9 tries over about 23 hours. After the last one, that event is dropped.
- The retry has the same body and
WbizTool-Event-Id, with a newWbizTool-Timestampand signature. - Order isn't guaranteed. A retried
message.sentcan arrive after a later event. Use the message'sstatus, or get the message, when the order matters.
last_delivery on the endpoint shows how the latest delivery went: state (pending, delivered or failed), the status_code your server answered, and when the event was made.
When endpoints stop#
An endpoint stops getting events when one of these happens. It stays in GET /webhooks with active: false and a disabled_reason, so you can see why; it can still be read and removed, but not tested or given a new secret (404).
- It has failed for 24 hours. If nothing has been delivered to it for 24 hours while its deliveries kept failing, it's turned off and its waiting retries are dropped. Settings → API keys then shows it as Off. Fix your server, then add the endpoint again.
- The key that made it is revoked or expires. Revoking a key stops everything it set up. Add the endpoint again with another key.
- It's removed, with Remove an endpoint or on Settings → API keys.
Endpoints that stopped don't count toward the 5.
The webhook endpoint object#
| Field | Type | Description |
|---|---|---|
id | integer | The endpoint's id. |
url | string | Where events are sent. |
description | string | Your note; "" when there's none. |
events | array | The event types it gets. |
secret | string | The signing secret: only in the answers to Add an endpoint and Replace the signing secret. |
secret_last4 | string | The last 4 characters of the secret, to tell which one you have. |
active | boolean | false when it gets no events any more – see When endpoints stop. |
disabled_reason | string or null | Why it stopped (failing for 24 hours, or the key that made it was revoked or expired); null while active. |
created_at | string | When it was added. |
last_delivery | object or null | The latest delivery tried: state, status_code (or null when your server didn't answer) and created_at. null before the first. |
Related#
- Messages: the Message object in
data.message - Webhook signatures for API v1
- Errors
