Skip to content

fix(rest/python): sign order-event webhooks (RFC 9421) and retry failed deliveries - #169

Open
vishkaty wants to merge 1 commit into
Universal-Commerce-Protocol:mainfrom
vishkaty:fix-webhook-signing-retry
Open

fix(rest/python): sign order-event webhooks (RFC 9421) and retry failed deliveries#169
vishkaty wants to merge 1 commit into
Universal-Commerce-Protocol:mainfrom
vishkaty:fix-webhook-signing-retry

Conversation

@vishkaty

@vishkaty vishkaty commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Fixes #163.

Adds RFC 9421 signing and delivery retry to order-event webhooks in the Python reference server, per the 2026-04-08 order.md MUSTs — the fix @carolinerg1 welcomed on #163. This is what conformance#73's webhook signing checks expect.

What was missing

_notify_webhook posted the order body with no signature and no retry: order.md ("Webhook Signature Verification") requires deliveries to carry UCP-Agent, Signature, Signature-Input, and Content-Digest and to be signed; order.md also requires failed deliveries to be retried.

Signing

Reuses the RFC 9421 machinery already in the server from #122 (no new crypto). The order is serialized once and those exact bytes go on the wire, so Content-Digest (sha-256 over the raw body, per signatures.md) covers what is sent. The server signs as the business: UCP-Agent: profile="{base_url}/.well-known/ucp". Signed component set = the required floor (@method/@authority/@path/@query/content-digest/content-type/idempotency-key/ucp-agent, per the signatures.md table — idempotency-key is required for POST, and it makes retries deduplicable) plus webhook-id, webhook-timestamp, and x-event-type so every header the delivery carries is covered. The signing JWK is published at /.well-known/ucp.

Key config: --webhook_signing_key loads an operator PEM (EC P-256 / Ed25519), validated at startup so a bad path fails fast; the default is an ephemeral ES256 demo key (there is no persisted demo key upstream and detect-private-key forbids committing one). kid is the RFC 7638 thumbprint.

Retry

3 attempts by default (--webhook_delivery_attempts), exponential backoff (--webhook_retry_backoff_seconds, 0.5s doubling), retrying on transport error or 5xx; a 4xx is permanent. Webhook-Id / Timestamp / Idempotency-Key stay fixed across attempts (so a deduplicating receiver treats them as the same event); created is re-stamped per attempt. Delivery never raises into the order flow; worst-case added latency is bounded.

Key-discovery note for reviewers

order.md and the profile schema place signing keys at top-level signing_keys[]; this server's own verifier (added in #122, following ucp#566) reads ucp.keys[]. The profile is published with the key in BOTH locations (both are schema-valid — verified against the pinned discovery schema), so either verifier convention resolves it. Happy to drop to one if you have a preference.

Verification

…ed deliveries

Observed: checkout_service._notify_webhook POSTed the order entity with a
single unsigned httpx request — no UCP-Agent, Signature, Signature-Input,
or Content-Digest header — and never inspected the response, so a 5xx or
connection failure silently dropped the event.

Expected (2026-04-08 order.md, "Webhook Signature Verification" and
"Guidelines — Business"): webhook payloads MUST be signed by the business
with all four headers on every delivery, the sha-256 Content-Digest MUST
cover the raw body bytes (signatures.md / RFC 9530), and failed deliveries
MUST be retried.

Signing (reuses the RFC 9421 machinery merged in Universal-Commerce-Protocol#122; no new crypto):

* _notify_webhook now serializes the order exactly once and signs those
  same bytes via ucp_signing.sign_request, sending UCP-Agent
  (profile="<base_url>/.well-known/ucp" — the business's own profile),
  Signature, Signature-Input, and Content-Digest on every delivery. The
  signed components are the signatures.md request-signing table (@method,
  @authority, @path, @query when the platform URL carries one,
  content-digest, content-type, idempotency-key, ucp-agent) plus
  webhook-id, webhook-timestamp, and x-event-type — every header this
  server adds to a delivery is signed, binding the event identity into
  the signature. sign_request gains an opt-in extra_components parameter
  for those; RFC 9421 permits covering any component beyond the required
  floor.
* Webhook POSTs are state-changing requests, so they now carry an
  Idempotency-Key (the event id): the signed-component table requires it,
  and retries reuse it so receivers can deduplicate.
* New webhook_signer module owns the business signing identity: a
  --webhook_signing_key flag loads an operator PEM (EC P-256 or Ed25519);
  unset, an ephemeral ES256 demo key is generated at startup so the sample
  signs correctly out of the box without committing a private key. The kid
  is the RFC 7638 JWK thumbprint, stable across restarts of the same key.
  The key is loaded and validated during app startup (config.lifespan):
  a misconfigured key path aborts the boot loudly instead of degrading
  every delivery into a swallowed per-webhook error.
* The served profile now publishes the public JWK in top-level
  signing_keys[] (discovery profile_schema.json places it as a sibling of
  `ucp`; order.md's platform verification reads it there) and mirrors it
  into ucp.keys[], the JWK Set this server's own verifier resolves.

Retry (bounded, config-driven):

* A transport error or 5xx now retries with exponential backoff
  (--webhook_delivery_attempts, default 3 total; --webhook_retry_backoff_seconds,
  default 0.5s, doubling). A 4xx is a permanent rejection and is not
  retried. Each attempt is re-signed so `created` reflects its send time;
  Webhook-Id/Webhook-Timestamp/Idempotency-Key stay fixed across attempts.
  The awaited-inline invocation shape and the never-raise contract of
  _notify_webhook are preserved.

Tests (integration_test.py, webhook_signer_test.py, ucp_signing_test.py):
deliveries carry all four signature headers; the Content-Digest matches the
raw wire bytes and the signature round-trip verifies through
ucp_signing.verify_request against the profile-published key (and a
tampered body fails); the signed set covers the required floor plus the
webhook and event-type headers; a 500-then-200 receiver sees a retried,
deduplicable delivery; retries are bounded and 4xx is not retried; a bad
--webhook_signing_key path fails at startup; key loading, kid determinism,
and profile publication are pinned. Existing Universal-Commerce-Protocol#122 request-verification and
Universal-Commerce-Protocol#140/Universal-Commerce-Protocol#146 webhook-body tests are unchanged and still pass (160 passed).

This also makes the conformance#73 webhook signing/retry checks pass
against this server (verified: webhook.signed_rfc9421_verifies,
webhook.signed_components, webhook.query_component_signed,
webhook.idempotency_key_signed, webhook.ucp_agent_signed,
webhook.ucp_agent_header, and webhook.retry_failed_delivery all
clean-pass; they deviate on the unfixed server).

Fixes Universal-Commerce-Protocol#163
@damaz91 damaz91 added the status:needs-triage Signal that the PR is ready for human triage label Aug 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

status:needs-triage Signal that the PR is ready for human triage

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Order webhooks are delivered unsigned and without retry, missing the order.md webhook signing and delivery MUSTs

2 participants