OpenWOP openwop.dev

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".

OperationRequestResponse
registerWebhook{ url, events[], secret?, tags?, signatureAlgorithms? }201 { webhookId, secret? }
unregisterWebhookpath webhookId204; 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.
  • workspaceId is present exactly when RunSnapshot.owner.workspace is (identity.md §1); a host MUST NOT substitute its tenant id for an absent workspace.
  • The envelope's runId MUST use the tenant-bound form of identity.md §5, matching the nested event and every response representation.

Headers

HeaderValue
OpenWOP-Webhook-Idthe subscription id
OpenWOP-Event-Typethe v2 event type
OpenWOP-TimestampUnix seconds at signing
OpenWOP-Signaturesha256={hex}, HMAC-SHA256 over the signed bytes
OpenWOP-Signature-Algorithmv1

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:

  1. Reject a timestamp more than ±5 minutes from its clock.
  2. Compute HMAC-SHA256({timestamp}.{rawBody}, secret).
  3. 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 the OpenWOP- family with identical values. This adds no signature scheme.
  • A v2 receiver MUST accept a delivery carrying only the X-openwop-* family under scheme v1, 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, optional maxElapsedMs) with backoff between attempts, and when it advertises maxElapsedMs, 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 unregisterWebhook answers 204, not start any further attempt for that subscription, including attempts already scheduled for retry (invariant webhook-unregister-stops-delivery);
  • dead-letter a payload_unprojectable delivery (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.deadLetter MUST serve GET /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 unregisterWebhook answers 204, GET /webhooks/{webhookId}/dead-letters for that id MUST answer 404 not_found, exactly as for a same-tenant id the host never minted. The host MAY discard that subscription's records at the unregister.
  • expiresAt bounds 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, NAT64 64:ff9b::/96, 6to4 2002::/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 the deadLetter run sink.

On an active subscription the host:

  • with dedup, MUST answer a dedupKey repeated within retention (at least 24 hours) with the prior runId;
  • retries a failed delivery per retryPolicy, then dead-letters it without starting a run;
  • MUST set the delivery id as causationId on run.started.

A host whose triggerBridge.ingestion.inboundSigning lists standard-webhooks-1 also serves webhook subscriptions this way:

  • registration returns a whsec_ signingSecret once;
  • a sender signs the raw body with the webhook-* headers of §"Standard Webhooks", and that signature, not an OpenWOP credential, authenticates ingestUrl;
  • under required verification a bad signature, or a timestamp more than 300 seconds off, fails the check (below);
  • webhook-id is the identity dedup keys on;
  • the ingest answers 202 (delivered, runId), 200 (duplicate, prior runId), 401 signature_invalid, or 409 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 required check appears with reason: "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 verification before delivery. A failed required check starts no run and dead-letters only that delivery. A refused event MUST NOT change the subscription's state or emit trigger.subscription-state-changed;
  • returns a binding secret or URL once; stream and change bindings are empty;
  • MUST pass the event only as ctx.triggerData, never in an event, and replay it from cache (invariant trigger-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 stream by topic, partition and offset, change by 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.

Next in Core.

Edit this page on GitHub ↗

  • Persistence and CoexistenceHow a v2 host reads what a v1 host wrote, handles a run in flight at the cut, and maps…
  • RunsCreating, reading, streaming, cancelling, pausing, forking and diffing a run. Every host…
  • Replay and ForkPOST /runs/{runId}:fork re-executes a past run state: a replay reproduces recorded…
  • Discovery and CapabilitiesA host advertises what it supports in one discovery document: one record type per family…