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
| Area | Events |
|---|---|
| Asking a person | approval.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 gate | flow.completed, gate.denied |
| Publishing | report.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 asapproval.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.viewedcounts 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
2xxis 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
4xxother than408or429is 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" }
}| Request | Events |
|---|---|
| Approval | approval.approved, approval.rejected, approval.expired, approval.question |
| Review | review.approved, review.changes_requested, review.expired |
| Request for information | collection.completed, collection.expired |
approval.questionmeans 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 orconstruct_callback(...)in Python. Both return the envelope or throw. - The CLI's
deliverd ask --callback <url>prints the secret once, before it waits.