Docs
Webhook triggers
Starting a run from an inbound request, and how it is authenticated.
A webhook trigger (POST /v1/agents/{id}/triggers with kind: "webhook") gives an agent a public endpoint, POST /hooks/{triggerId}, that queues a run with the request body as the trigger payload. The endpoint is unauthenticated; every request proves it came from the holder of the trigger secret with a signature.
Request
| Part | Requirement |
|---|---|
| Body | JSON, at most 1 MiB. It is stored verbatim as the run's trigger payload. |
Keel-Delivery-Id |
Required, at most 255 characters. Your own identifier for this event. The same id on the same trigger answers duplicate: true and never starts a second run. |
Keel-Signature |
t=<unix seconds>,v2=<hex hmac>. See below. |
The answer is 202 { runId, deliveryId, duplicate }. A disabled trigger answers 409 trigger_disabled, a bad or missing signature 401, and more than HOOK_RATE_LIMIT_PER_MINUTE requests per trigger per minute (default 600) answer 429 rate_limited with a Retry-After header.
Signing
Sign with the secret returned once when the trigger was created or rotated (whsec_...).
tis the current unix time in seconds. Keel rejects timestamps more than five minutes away from its own clock.v2is HMAC-SHA256, hex encoded, over<t>.<Keel-Delivery-Id>.<raw body>: the timestamp, a dot, the delivery id exactly as sent in the header, a dot, and the exact body bytes.
Node:
import { createHmac } from "node:crypto";
export function keelSignatureHeader(rawBody, deliveryId, secret, now = Date.now()) {
const timestamp = Math.floor(now / 1000);
const signature = createHmac("sha256", secret).update(`${timestamp}.${deliveryId}.${rawBody}`).digest("hex");
return `t=${timestamp},v2=${signature}`;
}
const body = JSON.stringify({ order: 42 });
const deliveryId = "order-42-created";
await fetch(`https://api.keel.dev/hooks/${triggerId}`, {
method: "POST",
headers: {
"content-type": "application/json",
"keel-delivery-id": deliveryId,
"keel-signature": keelSignatureHeader(body, deliveryId, process.env.KEEL_WEBHOOK_SECRET),
},
body,
});
Python:
import hashlib
import hmac
import time
def keel_signature_header(raw_body: bytes, delivery_id: str, secret: str, now: float | None = None) -> str:
timestamp = int(now or time.time())
material = f"{timestamp}.{delivery_id}.".encode("utf-8") + raw_body
signature = hmac.new(secret.encode("utf-8"), material, hashlib.sha256).hexdigest()
return f"t={timestamp},v2={signature}"
Rotation
POST /v1/triggers/{id}/rotate-secret installs a new secret and keeps the previous one verifying for a grace window, so switch the sender over, then rotate again once the old secret is out of use. Keel verifies a signature against every active secret.
The v1 scheme and its deprecation
Before 2026-09 the signed material was <t>.<raw body> under the v1 key. That scheme does not bind the delivery id, so a captured request could be replayed with a fresh Keel-Delivery-Id and start another run. Keel still verifies v1 until 2027-03-31 (legacySignatureSunset in the domain), but a v1 signed request is additionally deduplicated on (trigger, t, signature): the replay answers duplicate: true and starts nothing. From that date on v1 entries are ignored and only v2 verifies. Move senders to v2 before then.
Operational notes
- Secrets live in the tenant secret store and are cached in memory per
(trigger, secret id)for 60 seconds; rotation invalidates the cache immediately. - Every accepted delivery is recorded in the trigger firing ledger keyed by delivery id, which is what makes redeliveries idempotent.