Skip to content
BuildStackHQHosting
All docs

Docs

Outbound callbacks

Having the platform call you when a run finishes, with retries and signatures.

When a run reaches a terminal state (succeeded, failed, timed_out, rejected, canceled) and the run carries a callback URL, Keel POSTs a compact JSON payload to that URL from a worker. Delivery never happens inside the run's execution path, so a slow endpoint cannot hold a run open.

Payload

{
  "deliveryId": "dlv_2f6d5f0e-0d2b-4b9c-8c3a-1a4f6f0b7e21",
  "runId": "2f6d5f0e-0d2b-4b9c-8c3a-1a4f6f0b7e21",
  "agentId": "6c0a1b2d-3e4f-4a5b-8c6d-7e8f9a0b1c2d",
  "status": "succeeded",
  "errorClass": null,
  "startedAt": "2026-09-16T09:58:00.000Z",
  "endedAt": "2026-09-16T10:00:00.000Z",
  "output": { "total": 7 },
  "traceUrl": "https://api.keel.dev/v1/runs/2f6d5f0e-0d2b-4b9c-8c3a-1a4f6f0b7e21/steps"
}

output is the agent's inline result, or null when the agent produced none. The full step trace is never inlined; fetch it from traceUrl.

Headers

Header Meaning
Content-Type application/json
Keel-Delivery-Id Stable per callback. Redeliveries and retries reuse the same id, so deduplicate on it.
Keel-Signature t=<unix seconds>,v2=<hex>,v2=<hex>,v1=<hex>,v1=<hex>. One v2 and one v1 entry per active signing secret (up to two secrets during rotation).
User-Agent Keel-Callbacks/1

Verify v2. Its signed material is <t>.<Keel-Delivery-Id>.<raw body>: the timestamp from the header, a literal dot, the delivery id from the header, another dot, and the exact request body bytes. The signature is HMAC-SHA256 with your organization's signing secret, hex encoded. Because the delivery id is inside the signed material, a captured delivery cannot be replayed to you under a different id.

v1 signs only <t>.<raw body> and is deprecated. Keel keeps sending v1 entries next to v2 until 2027-03-31 so existing receivers keep working; after that date only v2 is sent. Receivers that still verify v1 must deduplicate on Keel-Delivery-Id and reject old timestamps, which is the only protection that scheme offers against replays.

Verifying

Compute the HMAC over the raw body (not a re-serialized object) together with the timestamp and the delivery id, compare in constant time against every v2 entry, and reject timestamps older than five minutes.

Node

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

const toleranceSeconds = 300;

export function verifyKeelSignature(rawBody, signatureHeader, deliveryId, secret, now = Date.now()) {
  const entries = signatureHeader.split(",").map((entry) => entry.trim().split("="));
  const timestamp = Number(entries.find(([key]) => key === "t")?.[1]);
  const signatures = entries.filter(([key]) => key === "v2").map(([, value]) => value);
  if (!Number.isInteger(timestamp) || signatures.length === 0 || !deliveryId) {
    return false;
  }
  if (Math.abs(now / 1000 - timestamp) > toleranceSeconds) {
    return false;
  }
  const expected = createHmac("sha256", secret).update(`${timestamp}.${deliveryId}.${rawBody}`).digest();
  return signatures.some((candidate) => {
    const received = Buffer.from(candidate, "hex");
    return received.length === expected.length && timingSafeEqual(received, expected);
  });
}

Express example, reading the raw body before any JSON middleware:

app.post("/keel/callback", express.raw({ type: "application/json" }), (req, res) => {
  const rawBody = req.body.toString("utf8");
  if (!verifyKeelSignature(rawBody, req.get("Keel-Signature") ?? "", req.get("Keel-Delivery-Id") ?? "", process.env.KEEL_SIGNING_SECRET)) {
    return res.status(401).end();
  }
  const event = JSON.parse(rawBody);
  if (alreadyHandled(req.get("Keel-Delivery-Id"))) {
    return res.status(200).end();
  }
  handle(event);
  res.status(200).end();
});

Python

import hashlib
import hmac
import time

TOLERANCE_SECONDS = 300


def verify_keel_signature(raw_body: bytes, signature_header: str, delivery_id: str, secret: str, now: float | None = None) -> bool:
    entries = [entry.strip().split("=", 1) for entry in signature_header.split(",")]
    timestamps = [value for key, value in entries if key == "t"]
    signatures = [value for key, value in entries if key == "v2"]
    if len(timestamps) != 1 or not timestamps[0].isdigit() or not signatures or not delivery_id:
        return False
    timestamp = int(timestamps[0])
    if abs((now or time.time()) - timestamp) > TOLERANCE_SECONDS:
        return False
    material = f"{timestamp}.{delivery_id}.".encode("utf-8") + raw_body
    expected = hmac.new(secret.encode("utf-8"), material, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, candidate) for candidate in signatures)

Flask example:

@app.post("/keel/callback")
def keel_callback():
    if not verify_keel_signature(request.get_data(), request.headers.get("Keel-Signature", ""), request.headers.get("Keel-Delivery-Id", ""), os.environ["KEEL_SIGNING_SECRET"]):
        abort(401)
    if already_handled(request.headers.get("Keel-Delivery-Id")):
        return "", 200
    handle(request.get_json())
    return "", 200

Rotation

Each organization has up to two active signing secrets. Rotating installs a new current secret and keeps the previous one active, so every delivery is signed with both and you can switch your endpoint over without a gap. Rotate again once your endpoint only knows the new secret; the oldest secret is dropped at that point.

Retries

Any response outside 2xx, a connection failure, or no response within ten seconds counts as a failure. Failed deliveries retry with exponential backoff and full jitter, starting at up to thirty seconds and capped at four hours per gap, for roughly twenty-four hours, after which the callback is marked exhausted. Deliveries are at-least-once: a success we never observed is retried, which is why Keel-Delivery-Id matters.

Every attempt is recorded with the response status and the first kilobyte of the response body, visible in delivery history (GET /v1/runs/{id}/callbacks), and an exhausted or delivered callback can be redelivered manually (POST /v1/callbacks/{id}/redeliver). Signing secrets are listed by fingerprint at GET /v1/organizations/me/signing-secrets and rotated with POST /v1/organizations/me/signing-secrets/rotate, which returns the new secret once.

Locally, with REAPER=inprocess, the API delivers callbacks itself every WORKER_POLL_INTERVAL_MS with WORKER_CALLBACK_CONCURRENCY in flight; in production the callback-deliverer worker role does.

Destinations

Callback URLs must be https. At every attempt Keel resolves the host and refuses to connect to loopback, private (RFC 1918), link-local, cloud metadata, carrier-grade NAT, unique-local, multicast, and reserved ranges, including IPv4-mapped IPv6 forms. The connection is pinned to the address that passed the check, so a DNS answer that changes between resolution and connect cannot redirect the request.