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 Organisation → Integrations & webhooks → Webhooks, choosing the events it receives. Subscribing to none means all of them, including any added later, except a gate's step-by-step events below. 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, approval.changes_requested, review.requested, review.approved, review.changes_requested, collection.requested, collection.completed
Flows and the gateflow.completed, gate.denied, gate.created, gate.approved, gate.rejected, gate.changes_requested, gate.expired, gate.cancelled, gate.revoked, gate.executed
Taskstask.completed, task.blocked, side_task.completed
AI riskrisk_indicator.breached
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.
  • task.blocked and side_task.completed also go only to an endpoint that names them: an agent working through a problem can finish many side tasks in an hour. task.completed, an objective finishing, goes to every endpoint. Each carries workspaceId, so a relay into a wider channel can leave a workspace's work out.
  • gate.created through gate.executed follow one gate from request to receipt. They go only to an endpoint that ticks them by name, because a channel subscribed to everything already hears about the same request as approval.*.
  • 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.
  • risk_indicator.breached is sent when an AI risk indicator is red for a month, at once for a zero-tolerance one, with the figure, its limits, the owner and a link to the stop controls. It never stops an agent itself.

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 the Webhooks tab under Organisation → Integrations & 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.

Managing endpoints from code

Everything the Webhooks screen does is also an API, for setting endpoints up from a script or a deploy rather than by hand. It takes a key with the webhooks:manage scope, and the key has to belong to an owner or an administrator — the people the screen admits — which is checked on every call, so a member's key is refused as 403 not_permitted whatever its scopes. An agent key is refused as 403 agent_not_permitted, reads included: an endpoint receives every event in the organisation, and a Slack or Teams webhook address is itself a credential for posting into that channel.

curl -X POST "$DELIVERD_URL/api/v1/webhook-endpoints" \
  -H "Authorization: Bearer $DELIVERD_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://hooks.example.com/deliverd",
    "events": ["approval.approved", "approval.rejected"],
    "description": "Ops relay"
  }'

The response is the endpoint with a secret — on this response only. No other call returns it, and it cannot be retrieved afterwards: keep it with your receiver and pass it to constructEvent(). A retry carrying the same Idempotency-Key gets this same response back rather than a second endpoint.

RouteDoes
GET /api/v1/webhook-endpointsList them, newest first, with limit and cursor like every list. Never their secrets.
POST /api/v1/webhook-endpointsAdd one: url, and optionally events, description and format (json, slack or teams). Answers 201 with secret.
GET /api/v1/webhook-endpoints/{id}One, with its delivery health: enabled, failureCount, lastSuccessAt, lastError.
PATCH /api/v1/webhook-endpoints/{id}Change url, events, description or format, or set enabled. Only what you send changes.
DELETE /api/v1/webhook-endpoints/{id}Remove it, and its delivery log with it.
POST /api/v1/webhook-endpoints/{id}/testSend one event now, report.published unless the body names another, and answer with delivered, responseStatus and error.
  • The address follows the screen's rules: HTTPS, on a public host. A private, loopback or internal address is refused as 400 validation_error, as is an event the server does not send.
  • Leave events out, or send [], for every event including ones added later — except gate.created, gate.approved, gate.rejected, gate.changes_requested, gate.expired, gate.cancelled, gate.revoked, gate.executed, task.blocked, side_task.completed, context.changed, audit.event, which arrive only when named.
  • Setting enabled back to true clears the run of failures, as the screen's switch does, so an endpoint switched off after 20 failures in a row is not switched off again by the next one.
  • The secret cannot be changed. Delete the endpoint and add it again for a new one.
  • A test send is the Send test control: one shot, never retried, never counted toward switching the endpoint off. delivered: false is an answer about your endpoint, not a failure of the call.
  • Each change is in the audit log under the same names as the screen's (webhook.created, webhook.updated, webhook.deleted, webhook.test_sent), with the key as the actor.

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.changes_requested, approval.expired, approval.question
Reviewreview.approved, review.changes_requested, review.expired
Request for informationcollection.completed, collection.expired
Gategate.approved, gate.rejected, gate.changes_requested, gate.expired, gate.cancelled, gate.revoked, gate.executed
  • 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.

Try it on your own agent.

Free to start, no card. Sign up with Google, Microsoft or GitHub, and the first page after hands you an API key.