Summary
registerWebhook makes secret optional, and v2 returns only { webhookId }. A client that omits the secret therefore gets a subscription whose deliveries it can never verify: the host signs every delivery with a secret nobody else knows. v1 answered this: the host generates the secret and returns it once at registration (spec/v1/webhooks.md). v2 lost that sentence. This RFC restores it for v2: when the request omits secret, the host MUST generate one and return it as secret in the 201, the only response that carries it. A supplied secret is still never echoed (RFC 0201 §B.6).
Motivation
- The v2 text had no answer.
webhooks.md§Surfaces gave the response as201 { webhookId }, and the OpenAPI request description says the secret is "never echoed in any response". Read together, a secret-less registration produces an unverifiable subscription, and the text did not say whether that was allowed. - Hosts split. openwop-app followed v1 and returned the generated secret once. The v2 reference host generated one and never returned it, so every delivery to a secret-less subscription was unverifiable (openwop-examples #98 fixes it).
- Found by a reader. Rewriting the quickstarts for v2 (openwop #1654) surfaced it: the guide had to tell readers to always send their own secret, because the spec could not promise they would ever see one otherwise.
Proposal
§A. The generated secret
One rule in spec/v2/core/webhooks.md §Surfaces, with the table cell becoming 201 { webhookId, secret? }:
When
registerWebhookomitssecret, the host MUST generate one and return it assecretin the201. That response is the only one that carries it. A supplied secret MUST NOT be echoed.
- Why return it, not require the client to send one. Making
secretREQUIRED would refuse registrations v2 accepts today, which is a breaking change. Returning the generated secret is additive, matches v1 and matches openwop-app. - Why only once. There is no v2 read of a subscription, and
rotateWebhookSecret(RFC 0201 §E) already returns no secret. The201is the one moment the subscriber can capture it. - Why no fingerprint. v1 also returned a
secretFingerprint. Nothing in v2 reads it, so it is not carried over.
Examples
Conforming. POST /webhooks { url, events: ["run.completed"] } → 201 { webhookId, secret: "…" }, and each delivery's OpenWOP-Signature verifies under that secret. POST /webhooks { url, events, secret: "s3cr3t…" } → 201 { webhookId }.
Non-conforming. 201 { webhookId } for a secret-less registration. A returned secret that the deliveries are not signed with. A 201 that echoes a supplied secret.
Compatibility
additive. The 201 schema gains an optional secret member, and clients that ignore unknown members are unaffected. A host that already returns the generated secret (openwop-app) conforms unchanged. A host that generates and withholds it (the v2 reference host before #98) was producing unverifiable subscriptions, and now fails the row. No request, error code or status changes.
Conformance
v2-webhook-generated-secret.test.ts, gated on the webhooks family and the conformance-noop fixture:
- Register without a secret: the
201must carry a non-emptysecret. - Run the noop fixture: the
run.completeddelivery must verify (OpenWOP-Signature, schemev1) under that secret. - Register with a secret: the
201must not echo it.
Witnessed on the v2 reference host: executed-pass (6 assertions) with #98, and executed-fail on its previous code, which withheld the secret.
Falsifiability — one row per normative requirement
| Requirement | Observable — what an outside party sees | Who can cause the condition | Verdict |
|---|---|---|---|
§A the generated secret is returned, deliveries are signed with it, and a supplied one is never echoed (openwop.requirement.0221.generated-secret-returned) | the 201 of a secret-less registration carries secret; a real delivery verifies under it; the 201 of a registration with a secret does not contain it | the suite, gated on the webhooks family | witnessable — gated |
Alternatives considered
- Make
secretREQUIRED in v2. It is simpler to state, but it refuses registrations v2 accepts today (breaking), and it discards the v1 behaviour openwop-app already implements. - Allow unverifiable subscriptions and document it. Rejected: a signed delivery nobody can verify is a signature in name only, and the subscriber has no way to tell that from a forgery.
- Do nothing. Hosts stay split, and the quickstart has to keep telling readers to send their own secret.
Unresolved questions
None.
Implementation notes (non-normative)
- openwop-app: already conforms (
routes/webhooks.tsreturns the generated secret when the request omitted one). - v2 reference host: openwop-examples #98.
- MyndHyve: not yet measured; the row will say so on its next cut.
Acceptance criteria
- [x]
Active: thewebhooks.mdrule, the201schema member, and the scenario (suite 2.42.8), sabotage-proved on the v2 reference host. - [ ]
openwop.requirement.0221.generated-secret-returnedexecuted-passon a committed certified host bundle.
References
spec/v1/webhooks.md(the secret "is returned once at registration time").- RFC 0201 §B.6 (a supplied secret is never echoed) and §E (rotation returns no secret).
- openwop #1654 (the v2 quickstarts), openwop-examples #98.