Summary
trigger-bridge.md §F.2 hands a webhook subscriber a binding.ingestUrl and, once, a secret. It never says what a sender POSTs there, how the post is authenticated, what makes two posts the same event, or what the host answers. So no suite can drive an inbound delivery portably. Every existing scenario POSTs a host test seam instead, and openwop.floor.trigger-bridge-delivery is unwitnessable on any production host, which correctly withholds seams. This RFC pins the sender-facing contract for a host that opts in:
- Signing: Standard Webhooks signing, reusing RFC 0201's wording.
- Authentication: the signature authenticates the sender, with no OpenWOP credential.
- Identity:
webhook-idis the inbound event identity that dedup keys on. - Response: a small response shape.
Motivation
- Measured. On app.openwop.dev's certification cuts (b29427fef, 2273db9c6),
openwop.floor.trigger-bridge-deliveryrecordsexecuted-failwith "host advertises openwop-trigger-bridge but the event-log seam is absent". The host implements §B/§C. The suite has no normative way to deliver an event to it. - The three gaps, each of which blocks a portable witness:
1. Identity. §C-1/§F.4 dedup on a dedupKey = hash(subscriptionId + inbound event id), but "inbound event id" is undefined for webhook/form. Two hosts can key it differently, and a suite cannot know what makes two posts the same event. 2. Authentication. §F.2 returns a secret once but does not say how a sender uses it. openwop-app's ingest route today requires an OpenWOP API credential (runs:create), which a third-party sender (GitHub, Stripe, a form service) does not have. 3. Response. Nothing says what the ingest answers. openwop-app answers 200 / 422 with { outcome, runId?, reason? }; another host may answer differently. A suite that needs the started runId (for causation) has nowhere normative to read it.
Proposal
A. Opt-in facet
A host MAY advertise capabilities.triggerBridge.ingestion.inboundSigning: an array of scheme ids, of which this RFC defines exactly one, "standard-webhooks-1". The v2 mirror is under the triggerBridge record, ingestion.inboundSigning. Every rule below binds only a host whose inboundSigning lists standard-webhooks-1, and only for webhook subscriptions. email/form/stream/change are unchanged.
"ingestion": { "type": "object", "properties": {
"externalSources": { … },
+ "inboundSigning": {
+ "type": "array", "items": { "type": "string", "enum": ["standard-webhooks-1"] },
+ "uniqueItems": true,
+ "description": "RFC 0230. Sender-facing ingest schemes this host implements for `webhook` subscriptions."
+ },
B. Registration
For a webhook registration on such a host, the 201 response's binding MUST carry signingSecret, a whsec_-prefixed Standard Webhooks secret, exactly once, beside ingestUrl and secretFingerprint. Re-reading the subscription MUST NOT return it (SR-1, unchanged). ingestUrl MUST be an absolute https URL, or a path resolvable against the discovery base.
C. The ingest request
- Body. A sender POSTs the raw event body to
ingestUrlwith the three Standard Webhooks headers:webhook-id,webhook-timestamp(Unix seconds), andwebhook-signature(space-separatedv1,<base64(HMAC-SHA256(key, "{webhook-id}.{webhook-timestamp}.{rawBody}"))>entries,key= base64-decode of the secret afterwhsec_). This is RFC 0201's construction, applied inbound. - Authentication is the signature. The host MUST NOT require an OpenWOP credential (bearer, API key or session) on
ingestUrl. The route MUST be reachable by a sender holding only the secret. - Verification, under the subscription's
verification.mode:
- required: a missing or invalid signature, or a webhook-timestamp more than 300 seconds from the host's clock, MUST NOT start a run. It is the §F.2 signature-invalid dead-letter (unchanged). - best-effort: the host delivers and stamps TriggerEvent.verified accordingly. - none: the host does not verify.
- Identity. The inbound event identity is
webhook-id. It MUST match^[A-Za-z0-9_-]{16,128}$(RFC 0201). WithdedupEnabled, the host MUST derivededupKeyfrom(subscriptionId, webhook-id), host-opaque as §F.4 already requires. A post whosewebhook-idrepeats one already delivered within the §C-1 retention window MUST start no new run. - A sender cannot change a subscription's state (§C.1).
ingestUrlneeds no OpenWOP credential, so anyone who learns it can post to it. A verification failure, a malformed post or any other ingest refusal MUST NOT transition the subscription out ofactive, and MUST NOT emittrigger.subscription.state.changed. Only the dead-letteredtrigger.delivery.attemptedfor that post is recorded. Otherwise one unauthenticated post with a garbage signature would disable a working integration. On an opted-in host this refines §F.2'sstate.changed.reason: signature-invalid, which predates an uncredentialed ingest: that reason applies to the delivery, not to the subscription's state.
D. The ingest response
| Outcome | Status | Body |
|---|---|---|
| Delivered: a run was started | 202 | { "outcome": "delivered", "runId": "<id>" } |
Dedup no-op: webhook-id seen within retention | 200 | { "outcome": "duplicate", "runId": "<prior id>" } (§F.4 "returning the prior runId") |
Verification failure under required | 401 | the error envelope with error: "signature_invalid", and no runId. The delivery is dead-lettered (§F.2); the subscription's state MUST NOT change (§C.1). |
Subscription not active (paused / dead-lettered / failed) | 409 | the error envelope with error: "subscription_not_active" |
runId is tenant-bound on the v2 wire (identity.md §5). The body carries no inbound content (SR-1).
E. v2
spec/v2/core/webhooks.md §Inbound triggers gains the same rules as a bulleted block under "On an active subscription", with the facet advertised on the v2 triggerBridge record.
Compatibility
Additive. Every rule binds only a host advertising inboundSigning: ["standard-webhooks-1"]. No host advertises it today, so no existing conformance pass changes. A host that never opts in is untouched, and its openwop.floor.trigger-bridge-delivery stays seam-witnessed.
Migration for openwop-app (the only host known to serve a public ingest route), before it may advertise the facet:
- Authentication. Drop the
runs:createcredential requirement on…/ingestin favour of signature authentication. - Body. Accept the raw body plus
webhook-*headers in place of the wrappedcoerceIngressbody. The wrapped form MAY stay on the seam. - Response. Answer
202/200/401/409per §D in place of200/422. - Dedup. Key dedup on
webhook-id. - Registration. Return the secret as
whsec_…undersigningSecret(it already returnssigningSecret).
Until then it simply does not advertise the facet.
Conformance
The existing trigger-bridge-delivery.test.ts keeps its seam path as the primary witness. When the seams are absent AND the host advertises inboundSigning: ["standard-webhooks-1"], each leg runs a normative-surface path, noting observed: normative-surface path on its row:
- Leg 1, dedup. Register a webhook subscription (
verification.mode: none) and POST twice with the samewebhook-id. The first answers202with arunId; the second answers200 {outcome: "duplicate", runId}with the samerunId. - Leg 2, dead-letter. Register with
verification.mode: requiredand POST with a badwebhook-signature:401 signature_invalid, norunId.GET /v1/trigger-subscriptions/{id}still showsstate: active(§C.1). A correctly signed post afterwards answers202with arunId, proving the bad post did not disable the subscription. - Leg 3, causation. POST a signed event and read
runIdfrom the202. OnGET /v1/runs/{runId}/events/poll:run.started.causationIdis present, and equals thetrigger.delivery.attempted{delivered}event's id when that event is on the run's log. The delivered event is content-free.
The run-less terminal events (the dead-lettered trigger.delivery.attempted, trigger.subscription.state.changed) are not on any run's log, so their content-freeness stays seam-witnessed. That becomes its own requirement id, not a qualifier on a passing row (RFC 0174 §B.1). Dedup likewise becomes its own requirement id, and both remain in the floor.
Falsifiability
| Requirement | Observable | Who can cause it | Verdict |
|---|---|---|---|
| §C no OpenWOP credential on ingest | the ingest answers without Authorization | the suite | witnessable |
§C required + bad signature → no run | 401 signature_invalid, no runId | the suite | witnessable |
§C.1 a refused post leaves the subscription active | GET shows active; a signed post next answers 202 | the suite | witnessable |
| §C timestamp skew > 300 s → no run | as above, with a stale webhook-timestamp | the suite | witnessable |
§C dedup on webhook-id | second post 200 duplicate with the same runId | the suite | witnessable |
§D 202 + runId on delivery | status + body | the suite | witnessable |
§D 409 on a non-active subscription | status + envelope | the suite (pause via the existing operator surface) | witnessable |
§B signingSecret once, never on re-read | absent on GET | the suite | witnessable |
Alternatives considered
- Keep the ingest host-defined; witness dedup via seams only. This is today's position. The floor stays unwitnessable on every production host, forever. Rejected.
- Let the suite send a per-host "event id" header named in discovery. This moves the portability problem into discovery without fixing it, and a real sender still has no standard to follow. Standard Webhooks is what senders already emit.
- Authenticate the ingest with OpenWOP credentials. A third-party sender has no such credential. That is the gap openwop-app's route shows.
Unresolved questions
Decided at the Active flip. See §Decisions.
- Should
formingest adopt the same signing? This RFC leavesformalone because a browser form cannot sign. - Is 300 s the right skew bound? It is Standard Webhooks' recommended tolerance; RFC 0201's outbound rule uses ±5 min.
- Should
401for a verification failure instead be202(accepted, dead-lettered), so the sender cannot probe signatures?401is chosen because the sender is the secret holder and a silent accept hides misconfiguration.
Decisions (2026-09-30, at the Active flip)
Each question is decided per the RFC's own lean. Each stays open to the RFC 0156 §B review.
formsigning: not in scope.formingest is unchanged. A browser-submitted form cannot hold the secret, so signing it would be decorative;form-originverification (RFC 0099) remains its authenticity check.- Skew bound: 300 seconds. This is Standard Webhooks' recommended tolerance and matches the ±5 minute window
webhooks.mdalready uses outbound. A tighter bound would convict hosts with ordinary clock drift. - Bad signature under
required:401 signature_invalid, and the delivery is dead-lettered. Both happen; they are not alternatives. The sender is the secret holder, so a visible refusal surfaces a misconfiguration that a silent202would hide. The §F.2 dead-letter still records the attempt. Signature probing is bounded by the secret's entropy, not by hiding the status.
Implementation notes (non-normative)
- openwop-app gains the facet only after the migration above. The conformance scenario change lands in the same PR as, or after, this RFC reaching
Active. - Until then
openwop.floor.trigger-bridge-deliverykeeps one honest red row on production hosts (dedup, seam-absent). It does not narrow.
Acceptance criteria
- [x]
Active(2026-09-30): comment window waived by steward override of RFC 0147 §A.6 (see Updated). The §B–§D contract, the capability facet (v1 + v2) and thetrigger-bridge.md§F.6 / v2webhooks.mdtext land together. - [ ]
trigger-bridge.md§F.2 / §F.4 and v2webhooks.md§Inbound triggers text merged. - [ ]
capabilitiesschemas (v1 and v2) carryingestion.inboundSigning. - [ ]
trigger-bridge-delivery.test.tsgains the normative-surface path, with dedup and run-less-event content-freeness split into their own requirement ids and kept in the floor. - [ ] Sabotage-proven on the non-seam path:
- two runs on a duplicate webhook-id → fail; - a run on a bad signature, or a subscription left non-active by it → fail; - a missing causationId → fail.
- [ ] A host (openwop-app) advertises the facet and passes the path in strict mode on a production cut.
- [ ] CHANGELOG entries.
References
- RFC 0083 (durable trigger bridge), RFC 0099 (external-event ingestion), RFC 0201 (Standard Webhooks, outbound), RFC 0147 §A.6, RFC 0174 §B.1.
- Standard Webhooks 1.0.0.
- app.openwop.dev certification bundles for b29427fef and 2273db9c6 (major 1,
openwop.floor.trigger-bridge-delivery).