Essentials
How webhooks work
We send your server a signed request on every important change to a verification, so you never need to poll.
Setting up
- In your project, open Webhooks and add your endpoint URL. It must be
https. - Pick the events you want, or leave the list empty to receive every event.
- Copy the signing secret (
whsec_…). It is shown once, when you create the endpoint.
A project can have up to 10 endpoints, and each one's delivery log is shown on the same page.
Events
| Event | When it fires |
|---|---|
verification.sent | A channel accepted the code (or, in test mode, on creation) |
verification.verified | A successful check |
verification.failed | Every channel failed or attempts ran out. The reason is in data.reason |
verification.expired | Expired without a successful check |
reason values on verification.failed: all_channels_failed, insufficient_balance, spend_cap_reached (sending stopped mid-chain), and max_attempts.
Payload
{
"id": "evt_9f0c2d7a41b84e6f9c3a5b7d1e2f4a6c",
"type": "verification.verified",
"created_at": "2026-10-28T10:01:12.000Z",
"data": {
"id": "5d3c1f0e-7a1b-4c55-9a7e-2f1d6b0c9e11",
"object": "verification",
"mode": "live",
"phone": "+9647701234567",
"status": "verified",
"channel": "telegram",
"channels": [
"whatsapp",
"telegram",
"sms"
],
"attempts": 1,
"max_attempts": 5,
"attempts_remaining": 4,
"locale": "ar",
"metadata": {
"user_id": 8841
},
"expires_at": "2026-10-28T10:05:00.000Z",
"verified_at": "2026-10-28T10:01:12.000Z",
"created_at": "2026-10-28T10:00:00.000Z"
}
}Fields
idstringrequiredEvent id (
evt_…). Use it to drop duplicates.typestringrequiredEvent type.
created_atstringrequiredWhen the event happened.
dataobjectrequiredThe
verificationobject as in Get a verification, withoutmessagesandprice_milli. It carries yourmetadata, plusreasonon failures.
Headers
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | Watsy-Webhooks/1.0 |
Watsy-Event | The event type |
Watsy-Delivery | Id of this delivery (unchanged across retries) |
Watsy-Signature | t=<unix seconds>,v1=<hex> |
Verifying the signature
v1 is the hex HMAC-SHA256 of "<t>.<raw body>" keyed with your secret. Compute it over the raw bytes before parsing JSON, compare in constant time, and reject any t more than 5 minutes away from now.
import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.WATSY_WEBHOOK_SECRET; // whsec_…
// Keep the raw body: the signature covers the exact bytes we sent.
app.post("/watsy/webhook", express.raw({ type: "application/json" }), (req, res) => {
const raw = req.body.toString("utf8");
const header = req.get("Watsy-Signature") ?? "";
const parts = Object.fromEntries(
header.split(",").map((kv) => {
const i = kv.indexOf("=");
return [kv.slice(0, i).trim(), kv.slice(i + 1).trim()];
}),
);
const t = Number(parts.t);
if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > 300) return res.sendStatus(400);
const expected = crypto.createHmac("sha256", SECRET).update(`${t}.${raw}`).digest();
const given = Buffer.from(parts.v1 ?? "", "hex");
if (given.length !== expected.length || !crypto.timingSafeEqual(given, expected)) {
return res.sendStatus(400);
}
const event = JSON.parse(raw);
// Deduplicate by event.id, then handle event.type / event.data.
res.sendStatus(200);
});Delivery and retries
- Reply with any
2xxwithin 5 seconds. Do slow work after replying. - On failure we retry after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours. After 6 attempts the delivery is marked
failed. - Redirects are not followed: a
3xxcounts as a failure. - The same event can arrive more than once, and events can arrive out of order. Rely on
idanddata.status.
The endpoint must be public: in production we never send to localhost or private network addresses.