Webhooks and signatures
Receive status changes as signed HTTP requests, and verify every one.
Endpoints
Owners and developers register endpoints under Developers, Webhooks; API keys cannot. The URL must use https and a public host; Sendd never follows redirects. An organization has at most 10 endpoints.
Each endpoint chooses its event types (message.status, campaign.scheduled, campaign.sending, campaign.completed, campaign.cancelled, topup.completed) and its mode. Its signing secret (whsec_...) is shown once.
The event
The body is a JSON envelope with id, type, created_at, mode and data. data is a snapshot taken when the change was committed: ids, statuses, your client_ref, the recipient and money in cents. It never carries the message text.
{
"id": "0192f7a4-5c1e-7b3a-9d2e-4f6a8b0c1d2e",
"type": "message.status",
"created_at": "2026-10-14T09:30:00Z",
"mode": "live",
"data": { "...": "a snapshot: ids, statuses, client_ref, the recipient" }
}Headers
Sendd-Signature:t=<unix seconds>,v1=<hex>(twov1values during a secret rotation).Sendd-Event-IdandSendd-Event-Type: copies of the event'sidandtype.Sendd-Delivery-IdandSendd-Attempt: which delivery and which attempt this is.
Verifying a signature
Only the timestamp and the body are signed, so check the signature before you trust anything in the request:
- Read the raw body as bytes, before any JSON parsing.
- Split
Sendd-Signatureon commas intotand one or morev1values. - The HMAC key is the secret's 32 bytes: base64url-decode the text after
whsec_(no padding). - Compute HMAC-SHA256 of
<t>.<raw body>with that key, as lowercase hex. - Accept if any
v1matches, compared in constant time, and reject atmore than 5 minutes from your clock.
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 5 * 60;
// header: the Sendd-Signature header; rawBody: the request body as received (a Buffer or string).
export function verifySenddSignature(header, rawBody, secret, nowSeconds = Math.floor(Date.now() / 1000)) {
if (!secret.startsWith("whsec_")) throw new Error("not a Sendd signing secret");
const key = Buffer.from(secret.slice("whsec_".length), "base64url"); // 32 bytes
let t = null;
const signatures = [];
for (const part of String(header ?? "").split(",")) {
const [name, value] = part.trim().split("=", 2);
if (name === "t" && /^[0-9]+$/.test(value ?? "")) t = Number(value);
if (name === "v1" && /^[0-9a-f]{64}$/.test(value ?? "")) signatures.push(Buffer.from(value, "hex"));
}
if (t === null || signatures.length === 0) return false;
if (Math.abs(nowSeconds - t) > TOLERANCE_SECONDS) return false;
const expected = createHmac("sha256", key).update(`${t}.`).update(rawBody).digest();
return signatures.some((sig) => timingSafeEqual(sig, expected));
}Rotating the secret
Rotating issues a new secret, shown once. For 24 hours every delivery carries two v1 signatures, one per secret, so you can switch without downtime; after that only the new secret signs.
Retries and duplicates
Any 2xx answer is success. Anything else, including a timeout or a redirect, is retried about 1 minute, 5 minutes, 30 minutes, 2, 6, 12, 24 and 24 hours later (each ±20%). After the 9th attempt the delivery is exhausted; an endpoint with no success for 5 days is switched off.
Delivery is at least once and order is not guaranteed. De-duplicate on the event id inside the signed body (the header copy is not signed), and order by created_at. Answer 2xx quickly and do the work afterwards.
Events, deliveries and attempts are kept 30 days (provisional).