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
- At least once. An event can arrive more than once. Skip repeats by
webhook-id. - 10 seconds. A 2xx answer within 10 seconds is success. Any other status, a timeout or a failed connection is a failure. Answer first and do slow work afterwards.
410disables the endpoint at once, and your app's members get an email. Answer410only when you want no more deliveries; answer a delivery that fails verification with400.- Redirects are never followed. A
3xxanswer is a failure, so register the final URL. - One at a time per stream. Each endpoint has at most one delivery in flight per stream (a run's
events are one stream), so a run's events normally arrive in
stream.seqorder. - A 2-minute window for strict order. If the oldest undelivered event of a stream has kept failing for 2 minutes, it moves to the dead-letter queue and the stream's next event is delivered.
- Dead letters keep retrying, out of order, after 1 m, 5 m, 30 m, 2 h, 6 h, 12 h and then every 12 h, until 72 hours have passed; then they stop.
- The delivery log.
GET /v1/webhook_deliverieslists an endpoint's deliveries with their status, attempts and last status code, dead-lettered ones included.POST /v1/webhook_deliveries/{id}/retrydelivers one again. - Auto-disable. An endpoint that answers no delivery with a 2xx for 72 hours is disabled, and your app's members get an email.
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:
- An event at or below it is a repeat or stale. Drop it.
- An event more than one above it may follow only events outside your subscription, or events still on
their way or dead-lettered. Do not hold it to wait for the missing numbers, which may never come. Read
the run's history after the highest
stream.seqyou applied, withGET /v1/runs/{run}/events?after=. It returns events of every type, instream.seqorder and including the one that arrived: apply the ones you use, in that order, and skip the rest. Whilehas_moreis true, pass itsnext_afterback asafter.
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"
- Resume where you stopped with
?after=or theLast-Event-IDheader, set to the lastid:you received. : pingcomes every 15 seconds to keep the connection open. It is an SSE comment; skip it.- The stream closes after 60 minutes, or when its key is revoked. Reconnect with
Last-Event-IDto carry on. - Caps: up to 5 per run and 200 per app open at once. These are upper bounds, not a promise: the
API counts streams on each of its server processes, so one can be answered
429rate_limitedbefore the total is reached. Open one stream per run, and after a429wait as long asRetry-Aftersays. - Durable events only: the events of the run's history, never partial text.
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.