Skip to main content
SenddDocumentation

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.

Example · json
{
  "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> (two v1 values during a secret rotation).
  • Sendd-Event-Id and Sendd-Event-Type: copies of the event's id and type.
  • Sendd-Delivery-Id and Sendd-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-Signature on commas into t and one or more v1 values.
  • 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 v1 matches, compared in constant time, and reject a t more than 5 minutes from your clock.
Example · javascript
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).

Next: Quickstart