Skip to content
BuildStackHQHosting
All docs

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_...).

  • t is the current unix time in seconds. Keel rejects timestamps more than five minutes away from its own clock.
  • v2 is 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.