WatificationDocs

Webhooks

Verify signed OTP and COD events, acknowledge deliveries, and recover failures.

Add a public HTTPS endpoint to receive message outcomes and COD answers. Every enabled endpoint receives all event types; there are no per-endpoint subscriptions. Verify the signature, persist the event, and respond 2xx quickly before doing slower work.

Add an endpoint

  1. Open Developers > Webhooks and select Add endpoint.
  2. Enter the URL, such as https://example.com/webhooks/watification, and select Add endpoint.
  3. Use Copy webhook secret to save the one-time secret in your backend's secret storage.
  4. From the endpoint actions, choose Send test event, then View deliveries to inspect the result.

A workspace can have 5 endpoints, including disabled endpoints. URLs must use HTTPS, have no embedded username/password, and be at most 2,048 characters without whitespace or backslashes. The host must resolve to public addresses. Loopback, private, link-local, and other non-public addresses are rejected. All resolved addresses are checked on each attempt. Redirects are not followed.

Secrets have the format whsec_ followed by 43 base64url characters. Rotate secret replaces the secret immediately and shows the new value once. Coordinate the receiver update with rotation; there is no overlap with the old secret.

Event catalog

TypeProduced when
message.sentOTP first reaches sent, delivered, read, or played.
message.failedOTP first reaches failed.
cod.confirmedCustomer confirms an order, including a later answer change.
cod.cancelledCustomer cancels an order, including a later answer change.
cod.reschedule_requestedReschedule completes with choices, a note, or partial expiry.
cod.no_responseInitial confirmation-answer deadline passes.
cod.failedSubmitted confirmation or reminder fails while waiting for an answer.
cod.refusal_confirmedCustomer's refusal reason/note step completes, including partial expiry.
cod.refusal_deniedCustomer says they did not refuse delivery.
cod.refusal_no_responseInitial refusal-answer deadline passes.
cod.refusal_failedRefusal initial or reminder message fails while waiting for an answer.
pingYou choose Send test event in the dashboard.

OTP produces each outcome type at most once per message, but both can occur for one message. COD answers can change and produce later events. Delivery retries and replay can deliver any event more than once.

Envelope and payloads

Every JSON envelope has id (event UUID), type, createdAt (ISO event creation time), and data. Outcome events also have data.occurredAt. Example IDs and values below are fictional.

message.sent
{
  "id": "44444444-4444-4444-8444-444444444444",
  "type": "message.sent",
  "createdAt": "2026-10-09T10:00:03.000Z",
  "data": {
    "messageId": "22222222-2222-4222-8222-222222222222",
    "reference": "login-example-002",
    "to": "+15555550123",
    "senderId": "11111111-1111-4111-8111-111111111111",
    "status": "sent",
    "occurredAt": "2026-10-09T10:00:02.000Z",
    "failure": null
  }
}

data.status is the actual reported status, so message.sent can carry delivered or read. reference can be null. message.failed has the same fields with status: "failed"; failure is null or { "code": "...", "message": "The message could not be sent." }. The supplied OTP code is never included.

Headers and signature

HeaderValue
Content-Typeapplication/json
User-AgentWatification-Webhooks/1
Watification-Event-IdSame event UUID as body id.
Watification-Event-TypeSame event type as body type.
Watification-Signaturet=<unix seconds>,v1=<hex digest>

Compute HMAC-SHA256 with the entire secret string, including whsec_, over the timestamp string, a literal dot, and the unaltered raw request body bytes. Do not base64-decode the secret or parse and re-serialize JSON before verification. Use a constant-time digest comparison.

The examples reject timestamps more than 300 seconds from the receiver's clock in either direction. This five-minute tolerance is receiver advice, not an API limit. Keep your server clock synchronized and adjust the tolerance for your environment. Each delivery attempt gets a fresh signed timestamp; do not compare the signature timestamp to the event's createdAt.

Save as verify-webhook.mjs. Call with a Buffer from your HTTP framework before its JSON parser, the signature header, and your stored secret. It throws on invalid input and returns the verified event.

verify-webhook.mjs
import { createHmac, timingSafeEqual } from 'node:crypto';

export function verifyWebhook(rawBody, signature, secret, tolerance = 300) {
  if (!Buffer.isBuffer(rawBody) || !secret) {
    throw new Error('Raw Buffer and secret required');
  }
  const match = /^t=([0-9]+),v1=([0-9a-f]{64})$/.exec(signature ?? '');
  if (!match) throw new Error('Invalid signature header');
  const timestamp = Number(match[1]);
  const now = Math.floor(Date.now() / 1000);
  if (
    !Number.isSafeInteger(timestamp) ||
    Math.abs(now - timestamp) > tolerance
  ) {
    throw new Error('Stale signature');
  }
  const expected = createHmac('sha256', secret)
    .update(`${match[1]}.`)
    .update(rawBody)
    .digest();
  const received = Buffer.from(match[2], 'hex');
  if (
    received.length !== expected.length ||
    !timingSafeEqual(expected, received)
  ) {
    throw new Error('Invalid signature');
  }
  return JSON.parse(rawBody.toString('utf8'));
}

Acknowledge and process safely

  1. Read raw bytes and verify the signature. Reject an invalid signature.
  2. Parse the verified body and check its id and type.
  3. Atomically persist the event ID and the work to perform. If it was already persisted, acknowledge the duplicate without repeating side effects.
  4. Respond with a short 2xx response, then perform slow work asynchronously. If you cannot durably accept the event, return a failure so it can be retried.

Dedupe by body id, not a delivery attempt or signature. Retain processed IDs long enough to cover retries and manual replay. Ignore unknown event types and acknowledge them with 2xx. Events can arrive out of order; compare data.occurredAt for updates to the same message or confirmation, and use GET to reconcile current state when needed.

Retries, disabling, and replay

Delivery has a 10-second overall timeout, including DNS, connection, TLS, and response reading. Any completed 2xx response is successful; other statuses, timeouts, and connection failures are retried. Respond promptly with a small body. Redirect responses fail.

Outcome events have 8 attempts total. After each failure, the seven retry delays are 1 minute, 5 minutes, 15 minutes, 1 hour, 3 hours, 6 hours, and 12 hours, each with +/-20% jitter. These are delays after the preceding attempt, not guaranteed delivery times.

After 3 consecutive outcome deliveries finish as failed, the endpoint is automatically disabled. A successful delivery resets the failure count. Disabled endpoints get no new outcome deliveries. Fix the receiver, select Enable, then use View deliveries > Replay for retained events you need to recover. Re-enabling clears the failure count; it does not recreate events produced while disabled.

Replay resets a completed delivery for another attempt and preserves its event ID. A pending or delivering row is not restarted. Delivery records are retained for 30 days, so save events you need beyond that period.

Send test event creates a signed ping even for a disabled endpoint. It attempts once and is not automatically retried. A failed ping does not increase the auto-disable count; a successful ping resets it.

On this page