OpenWOP openwop.dev

Status: Stable · v2.1.0 (2026-09-11) · RFC 0165 §C.1, 0173 §B, 0176 §D.2, 0171 §A.4.

Why this exists

Polling a run for progress is inefficient, and SSE cannot reach systems that need server-to-server delivery. A client registers a URL and an event filter once; the host POSTs matching events, signed, as they happen. In v2 durable delivery binds with the surface — a signed event that may be dropped is not a delivery contract.

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) is { signatureAlgorithms[] }, which MUST list "v1"; there is no durable field.

OperationRequestResponse
registerWebhook{ url, events[], secret?, tags? }; url MUST be https://; events[] MUST be non-empty v2 event type names (events.md)201 { webhookId }
unregisterWebhookpath webhookId204; 404 when unknown; 403 when the caller is outside the subscription's tenant

A subscription MUST receive only events from runs within its tenant scope; 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 same payload definition as the event itself and the CloudEvents mapping — one source, three renderings (RFC 0171 §A.4). The body is { runId, workspaceId?, event } where event is the verbatim run event (events.md), and it 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 is tenant-bound (identity.md §5), like every other rendering of a v2 runId. An outbound emission is not a response to a versioned request, so nothing in the request cycle supplies the form — the grammar does. A host that projects on responses and not on emissions hands the subscriber an identifier the client has never seen, and the failure is silent: the subscriber's correlation matches nothing, with no error, no 4xx and no log line. Until 2026-09-04 the nested event.runId was bound by run-event.schema.json while the envelope's own was carried by this paragraph alone, which is how a real host shipped the split.

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: 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. Subscribers SHOULD track (OpenWOP-Webhook-Id, runId, sequence) for at-least-once deduplication. A host MUST NOT log the secret.

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 (RFC 0165 §C.1, RFC 0176 §D.2). A v2 receiver MUST accept a delivery carrying only the X-openwop- family under scheme v1, verifying the same bytes. This adds no signature scheme. Per-subscription secrets are unchanged across the cut; deliveries queued before the cut are drained 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 (RFC 0173 §B; security-defaults.md). A host MUST:

  • retry a failed attempt per its advertised retryPolicy (maxAttempts, backoff ∈ none | fixed | exponential) with backoff between attempts;
  • route a delivery whose retries are exhausted to the dead-letter sink, inspectable for retentionDays, rather than drop it;
  • deliver each matching event at least once; a receiver MAY observe the same event more than once.

Best-effort delivery is not a conforming mode. A 3xx response is a delivery failure and is retried under the same policy. The webhook-durable-delivery scenario observes retry then dead-letter (conformance.md).

Replay

A host MUST NOT deliver events a replay fork re-emits as fixed history; replay-ness is read from the run, never from the event type (replay.md). A branch fork's events are new facts and are delivered.

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

See also: events.md, replay.md, persistence.md, security-defaults.md.