Webhooks and callbacks

Be told instead of polling: signed organisation webhooks for every event, and per-request callbacks that wake an agent when a person answers.

Deliverd can tell you when something happens in two ways. Webhooks belong to the organisation: an administrator adds an endpoint and every event goes to it. Callbacks belong to one request: your agent names an address when it asks, and that address is told when a person answers. Both are signed the same way.

Webhooks

Add an endpoint under Admin → Webhooks, choosing the events it receives. Subscribing to none means all of them, including any added later. Each event arrives as a signed POST, queued and retried independently of whatever caused it, so a publish or a decision never waits on your endpoint.

An endpoint has a format: the signed JSON envelope below, a Slack incoming webhook, or a Microsoft Teams workflow. Slack and Teams endpoints receive a native message with a button to the right page; the signature headers are still sent.

Events

AreaEvents
Asking a personapproval.requested, approval.approved, approval.rejected, approval.expired, approval.escalated, approval.question, approval.answered, review.requested, review.approved, review.changes_requested, collection.requested, collection.completed
Flows and the gateflow.completed, gate.denied
Publishingreport.published, report.updated, report.shared, report.viewed, comment.created, access.requested, schedule.due, publish.held
  • There is no gate.allowed: it would fire on every action a rule let through. A gate that needs a person raises an ordinary approval, so it arrives as approval.requested.
  • An individual review verdict or reply is audited rather than sent. A channel wants to know the answer is in, not to be told once per person.
  • report.viewed counts a viewer at most once per 30 minutes per report.

The delivery

POST https://hooks.example.com/deliverd
Content-Type: application/json
Deliverd-Event: approval.approved
Deliverd-Delivery: 7f3c…
Deliverd-Signature: t=1800000000,v1=5257a869e7…

{ "event": "approval.approved", "sentAt": "…", "data": { … } }

Deliverd-Delivery identifies the delivery, so a receiver can ignore one it has already processed. A delivery sent with the Send test control carries "test": true beside event.

Verifying a signature

The endpoint's address is all an attacker needs to post convincing fakes, so check every delivery. The signature is HMAC-SHA256 over <t>.<raw body> with the endpoint's signing secret, and t is inside the signed material, so a captured delivery cannot be replayed with a fresh timestamp. The SDKs do it in one call:

import { constructEvent } from "@deliverd/sdk";

export async function POST(request: Request) {
  // signingSecret: the endpoint's secret, from wherever you keep secrets.
  // The body raw, exactly as it arrived: a re-encoded object will not verify.
  const event = constructEvent(
    signingSecret,
    await request.text(),
    request.headers.get("deliverd-signature"),
  );

  if (event.event === "approval.approved") {
    // Acknowledge first; do the slow work afterwards.
  }
  return new Response(null, { status: 204 });
}

constructEvent() throws on a body that does not verify, and refuses a timestamp more than five minutes old. In Python it is construct_event(), imported from deliverd. Without an SDK:

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

function verify(secret, body, header, toleranceSeconds = 300) {
  const parts = new Map(header.split(",").map((p) => {
    const at = p.indexOf("=");
    return [p.slice(0, at).trim(), p.slice(at + 1).trim()];
  }));
  const t = Number(parts.get("t"));
  const v1 = parts.get("v1");
  if (!Number.isFinite(t) || !v1) return false;
  if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;

  const expected = createHmac("sha256", secret).update(`${t}.${body}`).digest("hex");
  const a = Buffer.from(expected), b = Buffer.from(v1);
  return a.length === b.length && timingSafeEqual(a, b);
}

Retries

  • Any 2xx is delivered. Acknowledge first and do your work afterwards: a delivery times out after ten seconds.
  • Anything else is retried, 5 attempts in all, 1, 4, 16 and 64 minutes apart.
  • A 4xx other than 408 or 429 is not retried: the request is wrong and will still be wrong later. Redirects are not followed.
  • After 20 consecutive failures the endpoint is switched off, and Admin → Webhooks says so.
  • The Send test control posts an event immediately with the same signature and headers. It never retries and never counts toward switching the endpoint off.

Callbacks

A callback lets a serverless function or a durable workflow sleep until a person answers, instead of holding a process open to poll. Name a callbackUrl when you create an approval, a review or a request for information:

curl -X POST "$DELIVERD_URL/api/v1/approvals" \
  -H "Authorization: Bearer $DELIVERD_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Refund £1,240",
    "approvers": ["Finance team"],
    "callbackUrl": "https://agent.example.com/hooks/deliverd"
  }'

The response carries a callbackSecret — on this response only. Keep it with whatever receives the callback. Each POST to your address is signed exactly as a webhook is, with that secret, and carries an envelope naming the request:

{
  "event": "approval.approved",
  "sentAt": "2026-09-23T08:00:00.000Z",
  "requestType": "approval",
  "requestId": "3f2c…",
  "data": { "…": "the same object the organisation's webhook carries" }
}
RequestEvents
Approvalapproval.approved, approval.rejected, approval.expired, approval.question
Reviewreview.approved, review.changes_requested, review.expired
Request for informationcollection.completed, collection.expired
  • approval.question means the approver asked something and nobody will decide until you answer it.
  • The address must be HTTPS on a public host. Private, loopback and internal addresses are refused when the request is made.
  • Retries follow the same ladder as webhooks.
  • Verify with constructCallback(callbackSecret, rawBody, signature) in TypeScript or construct_callback(...) in Python. Both return the envelope or throw.
  • The CLI's deliverd ask --callback <url> prints the secret once, before it waits.