Skip to content
Documentation menu

Documentation

Webhooks

Get a POST to your endpoint when an OTP or delivery changes state, verified with an HMAC signature, so you do not have to poll.

Webhooks notify your backend when an OTP or delivery changes state, so you do not have to poll. Set an endpoint URL and a signing secret on the API Keys screen in your dashboard. The otp.approved webhook is a push signal for approval on every channel, a convenient alternative to reading the POST /otp/verify response, and the only push signal for a WhatsApp Inbound approve verification, which has no verify call at all.

Events

EventWhen it fires
otp.approvedThe OTP was verified: a correct code was entered, or an inbound WhatsApp message approved it.
otp.failedVerify attempts were exhausted without a correct code.
delivery.deliveredA message reached the recipient, confirmed by a delivery receipt.
delivery.failedA message failed to deliver.

Payload

Every event is a POST with the same envelope. The otp.* events carry an otp_id and status; the delivery.* events also carry a delivery_id.

{
  "id": "evt_…",
  "type": "otp.approved",
  "payload": { "otp_id": "9f3c1b2a-…", "status": "approved" }
}
// delivery.delivered / delivery.failed
{
  "id": "evt_…",
  "type": "delivery.delivered",
  "payload": { "otp_id": "9f3c1b2a-…", "delivery_id": "d1a2…", "status": "delivered" }
}

Verifying the signature

Every request carries two headers:

HeaderValue
X-Webhook-SignatureHex HMAC-SHA256 of {timestamp}.{rawBody}, keyed with your signing secret.
X-Webhook-TimestampThe Unix timestamp used in the signature.

Recompute the signature over the raw request body, compare it in constant time, and reject the request if it does not match or the timestamp is stale.

import { createHmac, timingSafeEqual } from 'node:crypto';

function verify(rawBody, headers, secret) {
  const ts = headers['x-webhook-timestamp'];
  const expected = createHmac('sha256', secret).update(`${ts}.${rawBody}`).digest('hex');
  const got = headers['x-webhook-signature'] ?? '';
  return got.length === expected.length &&
    timingSafeEqual(Buffer.from(got), Buffer.from(expected));
}

Retries

A failing endpoint is retried with backoff; repeated failures pause delivery until you re-enable it on the API Keys screen. Respond 2xx quickly and do the work asynchronously. Treat events as idempotent: dedupe on the event id, since a retry can redeliver one you already processed.