Watsy
EssentialsHow webhooks work

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

  1. In your project, open Webhooks and add your endpoint URL. It must be https.
  2. Pick the events you want, or leave the list empty to receive every event.
  3. 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

EventWhen it fires
verification.sentA channel accepted the code (or, in test mode, on creation)
verification.verifiedA successful check
verification.failedEvery channel failed or attempts ran out. The reason is in data.reason
verification.expiredExpired 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

  • idstringrequired

    Event id (evt_…). Use it to drop duplicates.

  • typestringrequired

    Event type.

  • created_atstringrequired

    When the event happened.

  • dataobjectrequired

    The verification object as in Get a verification, without messages and price_milli. It carries your metadata, plus reason on failures.

Headers

HeaderValue
Content-Typeapplication/json
User-AgentWatsy-Webhooks/1.0
Watsy-EventThe event type
Watsy-DeliveryId of this delivery (unchanged across retries)
Watsy-Signaturet=<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 2xx within 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 3xx counts as a failure.
  • The same event can arrive more than once, and events can arrive out of order. Rely on id and data.status.

The endpoint must be public: in production we never send to localhost or private network addresses.