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
- Open Developers > Webhooks and select Add endpoint.
- Enter the URL, such as
https://example.com/webhooks/watification, and select Add endpoint. - Use Copy webhook secret to save the one-time secret in your backend's secret storage.
- 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
| Type | Produced when |
|---|---|
message.sent | OTP first reaches sent, delivered, read, or played. |
message.failed | OTP first reaches failed. |
cod.confirmed | Customer confirms an order, including a later answer change. |
cod.cancelled | Customer cancels an order, including a later answer change. |
cod.reschedule_requested | Reschedule completes with choices, a note, or partial expiry. |
cod.no_response | Initial confirmation-answer deadline passes. |
cod.failed | Submitted confirmation or reminder fails while waiting for an answer. |
cod.refusal_confirmed | Customer's refusal reason/note step completes, including partial expiry. |
cod.refusal_denied | Customer says they did not refuse delivery. |
cod.refusal_no_response | Initial refusal-answer deadline passes. |
cod.refusal_failed | Refusal initial or reminder message fails while waiting for an answer. |
ping | You 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.
{
"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
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | Watification-Webhooks/1 |
Watification-Event-Id | Same event UUID as body id. |
Watification-Event-Type | Same event type as body type. |
Watification-Signature | t=<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.
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
- Read raw bytes and verify the signature. Reject an invalid signature.
- Parse the verified body and check its
idandtype. - Atomically persist the event ID and the work to perform. If it was already persisted, acknowledge the duplicate without repeating side effects.
- Respond with a short
2xxresponse, 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.