Status: Stable.
Normative home:
webhooks,triggerBridge.
Why this exists
A client registers a URL and an event filter once; the host POSTs matching events, signed, as they happen, and durably.
Surfaces
A host that advertises webhooks (capabilities.md) serves registerWebhook (POST /webhooks) and unregisterWebhook (DELETE /webhooks/{webhookId}) from api/v2/openapi.yaml. The facet (spec/v2/facets/webhooks.schema.json) carries signatureAlgorithms[], which MUST list "v1".
| Operation | Request | Response |
|---|---|---|
registerWebhook | { url, events[], secret?, tags?, signatureAlgorithms? } | 201 { webhookId, secret? } |
unregisterWebhook | path webhookId | 204; 404 when unknown; 403 when the caller is outside the subscription's tenant |
On registerWebhook, url MUST be https:// and events[] MUST be non-empty v2 event type names (events.md). events[] MAY name a durable host-event type the host advertises; an ephemeral or unadvertised one MUST be refused 400 validation_error.
When registerWebhook omits secret, the host MUST generate one and return it as secret in the 201. That response is the only one that carries it. A supplied secret MUST NOT be echoed.
A subscription MUST receive only events from runs within its tenant scope, and only host events of its tenant; cross-tenant delivery is a protocol violation whatever the filter says (invariant webhook-cross-tenant-isolation). tags narrows delivery to runs whose options carry an overlapping tag.
Delivery
The delivery envelope is generated from the event's payload definition (events.md §Payloads). The body is { runId, workspaceId?, event }, where event is the verbatim run event. A durable host event's body is { hostEvent }, deduplicated on (OpenWOP-Webhook-Id, eventId); its webhook-id MUST NOT change across attempts.
- The body MUST validate against
schemas/v2/webhook-delivery.schema.json. workspaceIdis present exactly whenRunSnapshot.owner.workspaceis (identity.md §1); a host MUST NOT substitute its tenant id for an absent workspace.- The envelope's
runIdMUST use the tenant-bound form of identity.md §5, matching the nested event and every response representation.
Headers
| Header | Value |
|---|---|
OpenWOP-Webhook-Id | the subscription id |
OpenWOP-Event-Type | the v2 event type |
OpenWOP-Timestamp | Unix seconds at signing |
OpenWOP-Signature | sha256={hex}, HMAC-SHA256 over the signed bytes |
OpenWOP-Signature-Algorithm | v1 |
A host MUST send all five on every delivery. The signed bytes are {timestamp}.{rawBody}, where rawBody is the exact bytes delivered. Scheme v1 is HMAC-SHA256 with the subscription secret (hs256).
Verification
A subscriber MUST verify before acting:
- Reject a timestamp more than ±5 minutes from its clock.
- Compute
HMAC-SHA256({timestamp}.{rawBody}, secret). - Compare in constant time.
A subscriber MUST reject an unrecognized OpenWOP-Signature-Algorithm value, and SHOULD track (OpenWOP-Webhook-Id, runId, sequence) for at-least-once deduplication. A host MUST NOT log the secret.
Standard Webhooks
standard-webhooks-1 names the symmetric scheme of Standard Webhooks 1.0.0. Its in-header v1, token is that standard's, not the OpenWOP scheme id v1; neither is read as the other, and OpenWOP-Signature-Algorithm stays v1.
Opt-in. A subscription carries the scheme only when registerWebhook sends signatureAlgorithms listing it and v1, with a secret of the form whsec_<base64 of 24–64 bytes>; otherwise, or when the host does not advertise the id, 400 validation_error. The 201 echoes the applied list. Every other subscription is unchanged.
Endpoint verification. Before answering 201, a host MUST send the request of schemas/v2/webhook-verification.schema.json to url under §Egress, signed as below. Unless a 2xx arrives within 10 s whose JSON challenge equals the one sent, it MUST refuse 400 webhook_endpoint_unverified, persisting nothing. A host MUST NOT verify a subscription that did not opt in.
Delivery. Each delivery to an opted-in subscription adds webhook-id, webhook-timestamp (equal to OpenWOP-Timestamp) and webhook-signature: space-separated v1,<base64 HMAC-SHA256> entries over {webhook-id}.{webhook-timestamp}.{rawBody}, keyed by the decoded secret. webhook-id matches ^[A-Za-z0-9_-]{16,128}$, MUST be identical on every attempt of one (webhookId, runId, sequence) and MUST differ across them.
Rotation. A host advertising webhooks.secretRotation serves rotateWebhookSecret. For overlapSeconds after a rotation, webhook-signature MUST carry one entry per secret and OpenWOP-Signature stays on the previous secret; afterwards only the new secret signs.
Dual emission through the overlap
- A host advertising both majors MUST send, on every delivery, the
X-openwop-family alongside theOpenWOP-family with identical values. This adds no signature scheme. - A v2 receiver MUST accept a delivery carrying only the
X-openwop-*family under schemev1, verifying the same bytes. - Per-subscription secrets are unchanged across the cut. Deliveries queued before the cut drain under their own retry policy with the payload they were serialized with (persistence.md).
- The
X-openwop-*family is removed on its register date.
Durability
Durable delivery is an obligation of the webhooks surface (security-defaults.md). A host MUST:
- retry a failed attempt per its advertised
retryPolicy(maxAttempts,backoff ∈ none | fixed | exponential, optionalmaxElapsedMs) with backoff between attempts, and when it advertisesmaxElapsedMs, dead-letter an exhausted delivery within that many milliseconds of the first attempt's start; - route a delivery whose retries are exhausted to the dead-letter sink (§Dead letters), rather than drop it;
- deliver each matching event at least once; a receiver MAY observe the same event more than once;
- not make the start of an attempt to one subscription wait for an attempt to a different subscription to finish (answered, failed, or timed out) (§Delivery isolation);
- sustain that while at least 8 subscriptions have attempts outstanding that their receivers have not answered;
- after
unregisterWebhookanswers204, not start any further attempt for that subscription, including attempts already scheduled for retry (invariantwebhook-unregister-stops-delivery); - dead-letter a
payload_unprojectabledelivery (events.md §Era-2) on the first attempt, never retry it.
A 3xx response is a delivery failure, retried under the same policy (webhook-durable-delivery).
Delivery isolation
One subscription's slow or dead receiver MUST NOT delay another subscription's deliveries (invariant webhook-delivery-isolation). A sequential loop over a batch does not meet it.
A host MAY bound concurrent attempts beyond the floor of 8, and SHOULD NOT let one tenant's unanswered attempts occupy capacity another tenant's deliveries need.
Unregister
An attempt whose request the host had begun sending before unregisterWebhook answered 204 MAY complete. Its undelivered events need not reach the dead-letter sink, which serves a live subscription's exhausted deliveries.
Dead letters
Not the run sink of the deadLetter family (runs.md §Dead letters):
- A host advertising
webhooks.deadLetterMUST serveGET /webhooks/{webhookId}/dead-letters. - A record names a delivery and MUST NOT carry the delivered body, the delivery headers, or the subscription secret.
- The read belongs to a live subscription: after
unregisterWebhookanswers204,GET /webhooks/{webhookId}/dead-lettersfor that id MUST answer404 not_found, exactly as for a same-tenant id the host never minted. The host MAY discard that subscription's records at the unregister. expiresAtbounds a record's retention only while its subscription exists.
Replay
A replay fork's re-emitted history is never delivered (replay.md §Suppression); a branch fork's events are.
Egress
At registration a host MUST reject (400 webhook_url_rejected) non-https:// URLs, RFC 1918 and loopback and link-local ranges, IPv6 ULA, cloud metadata hosts, and localhost.
Address classification:
- An IPv4-mapped IPv6 address (
::ffff:0:0/96) MUST be judged by the IPv4 address it embeds, whatever its spelling. - An address embedding IPv4 in another standard translation form (IPv4-compatible
::/96, NAT6464:ff9b::/96, 6to42002::/16) SHOULD be judged the same way, and those prefixes SHOULD NOT be denied wholesale. - A host SHOULD refuse every destination the IANA special-purpose address registries mark not globally reachable.
At delivery time a host MUST re-resolve the hostname, validate every resolved address against the same denied ranges plus its own denylist, connect to the validated address without re-resolving, and refuse to follow redirects (invariant webhook-delivery-egress-revalidation, reference-impl tier). These delivery-time rules bind an A2A push identically (interop.md §"A2A push delivery").
Inbound triggers
A host advertising triggerBridge runs inbound work through subscriptions (schemas/v2/trigger-subscription.schema.json), and subscriptionStates lists the states it implements:
active;paused— not delivering; a schedule skips ticks;failed;dead-lettered— terminal; its deliveries are dead-lettered (below), not held by thedeadLetterrun sink.
On an active subscription the host:
- with
dedup, MUST answer adedupKeyrepeated within retention (at least 24 hours) with the priorrunId; - retries a failed delivery per
retryPolicy, then dead-letters it without starting a run; - MUST set the delivery id as
causationIdonrun.started.
A host whose triggerBridge.ingestion.inboundSigning lists standard-webhooks-1 also serves webhook subscriptions this way:
- registration returns a
whsec_signingSecretonce; - a sender signs the raw body with the
webhook-*headers of §"Standard Webhooks", and that signature, not an OpenWOP credential, authenticatesingestUrl; - under
requiredverification a bad signature, or a timestamp more than 300 seconds off, fails the check (below); webhook-idis the identity dedup keys on;- the ingest answers
202(delivered,runId),200(duplicate, priorrunId),401 signature_invalid, or409 subscription_not_active.
A source in triggerBridge.sources MUST move through these states and emit trigger.subscription-state-changed and trigger.delivery-attempted. These events MUST NOT carry inbound content or credentials (schemas/v2/run-event-payloads.schema.json). A dead-lettered attempt or a state change belongs to no run; for it, emit means the host keeps a content-free record of that payload.
A host advertising triggerBridge.deadLetter MUST serve GET /trigger-subscriptions/{subscriptionId}/dead-letters:
- a record carries the dead-lettered attempt's payload fields and, if the dead-lettering changed the subscription's state, that state change;
- a record MUST NOT carry the inbound body, headers, signature, or secret;
- a delivery refused by a
requiredcheck appears withreason: "verification_failed"and no state change; - a cursor minted for another subscription MUST be refused
400 validation_error.
With triggerBridge.ingestion, each externalSources entry MUST turn an external event into a TriggerEvent (schemas/v2/trigger-event.schema.json, whose rules bind) and start a run. The host:
- MUST verify per
verificationbefore delivery. A failedrequiredcheck starts no run and dead-letters only that delivery. A refused event MUST NOT change the subscription's state or emittrigger.subscription-state-changed; - returns a binding secret or URL once;
streamandchangebindings are empty; - MUST pass the event only as
ctx.triggerData, never in an event, and replay it from cache (invarianttrigger-ingestion-content-redaction); - MUST refuse private, link-local and loopback targets and cap the body on any ingestion fetch, and never hand the run a URL (invariant
trigger-ingestion-ssrf); - SHOULD key
streamby topic, partition and offset,changeby table and changelog id; a key MUST survive broker redelivery.
Sources: RFCs 0053, 0083, 0099, 0127, 0165, 0171, 0173, 0176, 0188, 0196, 0201, 0215, 0217, 0230, 0232, 0236.