Webhooks and the event stream

Your app learns what happens in its runs in three ways. Webhooks push each event to an HTTPS endpoint of yours as it happens. The event feed, GET /v1/events, is the record to reconcile against. The SSE stream follows one run live. All three carry the same event envelopes, which the event catalog describes type by type; every endpoint on this page is in the reference.

New fields, event types and enum values are added within /v1 without a new version. Clients must ignore unknown fields, event types and enum values.

Verify every delivery

A delivery is a POST whose body is one event envelope, compact UTF-8 JSON, signed with the Standard Webhooks headers. Nothing in the headers names Forgeling (the User-Agent is webhooks/1.0).

Header Value
webhook-id The event's id, the same on every attempt to deliver it.
webhook-timestamp When the attempt was sent, in unix seconds.
webhook-signature v1, and the base64 HMAC-SHA256 of <webhook-id>.<webhook-timestamp>.<raw request body>, keyed with your endpoint's secret: the base64 after whsec_, decoded. While a secret is being rotated there are two, separated by a space.

Before you act on a delivery, check all three: refuse it if a header is missing, if the timestamp is more than 5 minutes from your clock in either direction (an old delivery may be a replay), or if no v1, signature matches the one you compute. Compare signatures in constant time.

Verify the raw request body, byte for byte as it arrived, and parse the JSON only afterwards. A body parsed and serialized again is different bytes (escaped non-ASCII text such as ñ, other spacing, another key order), and its signature does not match. Each framework has a way to read the raw body:

Framework The raw request body
FastAPI, Starlette await request.body()
Flask request.get_data()
Express express.raw({type: "application/json"}) on the route, then req.body, a Buffer; not express.json(), which parses it

The functions below do the whole check. Copy one into your app. Each raises or throws a WebhookVerificationError whose reason says which check failed:

reason Meaning
missing_header webhook-id, webhook-timestamp or webhook-signature is missing or empty.
invalid_timestamp webhook-timestamp is not a whole number of seconds.
timestamp_too_old Sent more than 5 minutes ago: a replay, or your clock is ahead.
timestamp_too_new Sent more than 5 minutes in the future: your clock is behind.
no_matching_signature No v1, signature matches: the wrong secret, a body changed before it was verified, or a request that is not from Forgeling.

Answer a delivery that fails the check with 400 and do nothing else with it.

Python

Python 3.10 or later, standard library only. headers may be any mapping of the request's headers, such as FastAPI's request.headers or Flask's.

"""Verify a Forgeling webhook (Standard Webhooks) before trusting it. Python 3.10+, standard library only."""

import base64
import hashlib
import hmac
import re
import time
from collections.abc import Mapping

# Whole unix seconds. At most 15 digits, so every language reads the same number from it.
_TIMESTAMP = re.compile(r"[0-9]{1,15}")


class WebhookVerificationError(Exception):
    """The request is not a delivery to trust. `reason` names the check it failed."""

    def __init__(self, reason: str) -> None:
        super().__init__(reason)
        self.reason = reason


def verify_webhook(
    secret: str,
    headers: Mapping[str, str],
    body: bytes,
    *,
    now: int | None = None,
    tolerance_s: int = 300,
) -> None:
    """Raise WebhookVerificationError unless `body` is signed with `secret` and was sent within `tolerance_s`
    seconds of `now` (unix seconds; default: the current time).

    `secret` is the endpoint's signing secret (`whsec_...`). `headers` are the request's, in any letter
    case. `body` is the raw request body, byte for byte as received: parse it only after this returns.
    """
    lowered = {name.lower(): value for name, value in headers.items()}
    msg_id = lowered.get("webhook-id")
    timestamp = lowered.get("webhook-timestamp")
    signatures = lowered.get("webhook-signature")
    if not (msg_id and timestamp and signatures):
        raise WebhookVerificationError("missing_header")
    if not _TIMESTAMP.fullmatch(timestamp):
        raise WebhookVerificationError("invalid_timestamp")
    sent_at = int(timestamp)
    now = int(time.time()) if now is None else now
    if now - sent_at > tolerance_s:
        raise WebhookVerificationError("timestamp_too_old")
    if sent_at - now > tolerance_s:
        raise WebhookVerificationError("timestamp_too_new")

    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed = f"{msg_id}.{timestamp}.".encode() + body
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest())
    # One `v1,<signature>` per signing secret (two while a secret is being rotated); one match is enough.
    candidates = [entry[3:].encode() for entry in signatures.split(" ") if entry.startswith("v1,")]
    if not any(hmac.compare_digest(candidate, expected) for candidate in candidates):
        raise WebhookVerificationError("no_matching_signature")

TypeScript

Node.js, node:crypto only. Node's req.headers can be passed unchanged. The file runs on Node.js 22.6 or later with type stripping, or compile it with the rest of your TypeScript.

// Verify a Forgeling webhook (Standard Webhooks) before trusting it. Node.js, node:crypto only.
import { createHmac, timingSafeEqual } from "node:crypto";

// Whole unix seconds. At most 15 digits, so every language reads the same number from it.
const TIMESTAMP = /^[0-9]{1,15}$/;

// The request is not a delivery to trust. `reason` names the check it failed.
export class WebhookVerificationError extends Error {
  reason: string;

  constructor(reason: string) {
    super(reason);
    this.name = "WebhookVerificationError";
    this.reason = reason;
  }
}

// Throws WebhookVerificationError unless `body` is signed with `secret` and was sent within `toleranceSeconds`
// (default 300) of `now` (unix seconds; default: the current time). `secret` is the endpoint's signing secret
// (`whsec_...`). `headers` are the request's, in any letter case (Node's `req.headers` as it is). `body` is
// the raw request body, byte for byte as received: parse it only after this returns.
export function verifyWebhook(
  secret: string,
  headers: Record<string, string | string[] | undefined>,
  body: Uint8Array,
  options?: { now?: number; toleranceSeconds?: number },
): void {
  const lowered = new Map<string, string>();
  for (const [name, value] of Object.entries(headers)) {
    if (value !== undefined) lowered.set(name.toLowerCase(), Array.isArray(value) ? value.join(" ") : value);
  }
  const id = lowered.get("webhook-id");
  const timestamp = lowered.get("webhook-timestamp");
  const signatures = lowered.get("webhook-signature");
  if (!id || !timestamp || !signatures) throw new WebhookVerificationError("missing_header");
  if (!TIMESTAMP.test(timestamp)) throw new WebhookVerificationError("invalid_timestamp");
  const sentAt = Number(timestamp);
  const now = options?.now ?? Math.floor(Date.now() / 1000);
  const tolerance = options?.toleranceSeconds ?? 300;
  if (now - sentAt > tolerance) throw new WebhookVerificationError("timestamp_too_old");
  if (sentAt - now > tolerance) throw new WebhookVerificationError("timestamp_too_new");

  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const hmac = createHmac("sha256", key).update(`${id}.${timestamp}.`, "utf8").update(body);
  const expected = Buffer.from(hmac.digest("base64"));
  // One `v1,<signature>` per signing secret (two while a secret is being rotated); one match is enough.
  const matched = signatures.split(" ").some((entry) => {
    if (!entry.startsWith("v1,")) return false;
    const candidate = Buffer.from(entry.slice(3));
    return candidate.length === expected.length && timingSafeEqual(candidate, expected);
  });
  if (!matched) throw new WebhookVerificationError("no_matching_signature");
}

The official Standard Webhooks libraries verify the same signatures, if you would rather add a dependency than copy a function.

Delivery is at least once, so the same event can arrive more than once. Use webhook-id as an idempotency key: record each one you have processed and skip a repeat. Keep them for at least 72 hours, the longest Forgeling retries a delivery by itself.

A valid signature proves that Forgeling sent the event, not that what it reports is so. Events prove origin, not truth: an agent controls fields such as the result and the reported cost_usd of turn.completed, so re-check external state, such as the pull request on GitHub, before you act on them in a way you cannot undo.

Rotate the secret

POST /v1/webhook_endpoints/{id}/rotate_secret makes a new whsec_… secret for the endpoint. The answer is the endpoint with its new secret, which is shown only in that answer.

curl -X POST "https://api.forgeling.io/v1/webhook_endpoints/$ENDPOINT/rotate_secret" \
  -H "Authorization: Bearer $FORGELING_API_KEY"

For the next 24 hours both secrets sign: each delivery carries two v1, signatures, one per secret, separated by a space, and the functions above accept a delivery when either matches the secret they are given. Deploy the new secret within those 24 hours; after them only the new one signs. If the old secret leaked, deploy the new one as soon as you have it: from then on your receiver accepts only what the new secret signs.

Delivery rules

curl "https://api.forgeling.io/v1/webhook_deliveries?endpoint=$ENDPOINT&status=dead_lettered" \
  -H "Authorization: Bearer $FORGELING_API_KEY"

curl -X POST "https://api.forgeling.io/v1/webhook_deliveries/$DELIVERY/retry" \
  -H "Authorization: Bearer $FORGELING_API_KEY"

Choose the events

An endpoint created without events gets the default subscription: run.*, turn.*, approval.*, output.*, budget.*, secret.*, workspace.* and bundle.*. The agent's own output is opt-in: message.completed and tool.* (tool.started, tool.completed). To get them, give events: a list of exact types, group.* patterns or * for every type. A list replaces the default, so name the default groups you still want:

curl https://api.forgeling.io/v1/webhook_endpoints \
  -H "Authorization: Bearer $FORGELING_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/webhooks", "events": ["run.*", "turn.*", "approval.*", "output.*", "budget.*", "secret.*", "workspace.*", "bundle.*", "tool.completed"]}'

The answer carries the endpoint's secret, shown only then. An endpoint keeps the api_version it was created with, and POST /v1/webhook_endpoints/{id}/test sends it a signed ping. Webhook endpoints and the delivery log belong to the whole app: a key restricted to some workspaces gets 404 on them.

A subscription narrower than * leaves gaps in the stream.seq values the endpoint sees, where the types it leaves out would be. Order and reconcile says how to handle them.

Order and reconcile

The events of a run carry stream.seq, which goes up by one with each event of the run, with no gaps. That count runs over every event type, tool.* and message.completed included, but an endpoint receives only the types it subscribes to. With the default subscription, or any events list narrower than *, the types left out leave gaps in the stream.seq values your endpoint sees: every tool call leaves one where its tool.* events are, and the events in such a gap never arrive. A gap alone does not prove that a delivery is missing.

Keep the highest stream.seq you have applied for each run:

curl "https://api.forgeling.io/v1/runs/$RUN/events?after=$LAST_SEQ" \
  -H "Authorization: Bearer $FORGELING_API_KEY"

Only an endpoint that receives every type (*) sees every number, so only for it does a gap mean a delivery that is late or dead-lettered. Read the history then too, rather than wait: a dead-lettered event comes back only after minutes or hours of retries, and after 72 hours it is no longer retried.

Webhooks are the nudge; the source of truth is the event feed, GET /v1/events. It lists every event of your app in the order Forgeling stored them, and takes workspace= and type= to narrow it. Its after= is an opaque cursor: pass back the cursor the previous page gave you (next_after), and never build one from an event's id or stream.seq. Read the feed when your app starts, after downtime, and whenever a webhook seems late.

curl "https://api.forgeling.io/v1/events?after=$CURSOR" \
  -H "Authorization: Bearer $FORGELING_API_KEY"

Follow a run live

GET /v1/runs/{run}/events/stream is a server-sent events stream of one run. It replays the stored events after your cursor, then follows the run live. Each message is one event envelope, and its id: is the event's stream.seq.

curl -N "https://api.forgeling.io/v1/runs/$RUN/events/stream?after=$LAST_SEQ" \
  -H "Authorization: Bearer $FORGELING_API_KEY"

Open the stream from your server: an API key never belongs in a browser.

Verify the credential-refresh call

When a run secret marked refreshable is about to expire, Forgeling calls your app's credential-refresh URL for a new value. The call is signed the same way, with the same three headers, so the same function verifies it. Two things differ: it carries webhook-id: crf_…, not an event id, and it is signed with the credential-refresh URL's own whsec_… secret, not with a webhook endpoint's. Its body and the answer it expects are in the coding-run guide.