Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions rest/python/server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,30 @@ Each verified request logs
`RFC 9421 signature verified (keyid=..., profile=...)`. Add `--require_signatures`
to reject anything unsigned.

## Webhook Signing & Delivery Retry

Outbound order-event webhooks are signed as the business, per the
specification's `order.md` (Webhook Signature Verification): every delivery
carries `UCP-Agent` (this server's profile URL), `Signature`,
`Signature-Input`, and a `Content-Digest` over the exact raw body bytes. The
signed components cover the full request-signing table (`@method`,
`@authority`, `@path`, `@query` when the platform URL has one,
`content-digest`, `content-type`, `idempotency-key`, `ucp-agent`) plus the
Standard Webhooks event headers (`webhook-id`, `webhook-timestamp`). The
matching public JWK is published in the served profile's `signing_keys[]`
(and mirrored into `ucp.keys[]`) so platforms can verify.

Failed deliveries — transport errors or a 5xx from the receiver — are retried
with exponential backoff, as `order.md` requires; a 4xx is treated as a
permanent rejection and is not retried. Retried attempts reuse the same
`Webhook-Id` and `Idempotency-Key`, so receivers can deduplicate.

| Flag | Default | Effect |
| --------------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--webhook_signing_key` | (ephemeral) | Path to a PEM private key (EC P-256 or Ed25519) to sign webhooks with. When unset, an ephemeral demo key is generated at startup and published in the profile. |
| `--webhook_delivery_attempts` | `3` | Total delivery attempts per webhook (initial attempt plus retries). |
| `--webhook_retry_backoff_seconds` | `0.5` | Delay before the first retry; doubles on each subsequent retry. |

## Run a Simple Client

Exercise a simple checkout path: Once the server is running, execute the simple
Expand Down
31 changes: 31 additions & 0 deletions rest/python/server/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,30 @@ def get_server_version() -> str:
"signer keys. For localhost demos and CI only; never enable in "
"production, as it disables SSRF protections.",
)
flags.DEFINE_string(
"webhook_signing_key",
None,
"Path to a PEM private key (EC P-256 or Ed25519) used to sign outbound "
"order-event webhooks as this business (order.md, Webhook Signature "
"Verification). When unset, an ephemeral demo key is generated at "
"startup; either way the public JWK is published in the served "
"profile's signing_keys[].",
)
flags.DEFINE_integer(
"webhook_delivery_attempts",
3,
"Total delivery attempts per order-event webhook (the initial attempt "
"plus retries). order.md requires failed deliveries to be retried; the "
"bound keeps the retry finite.",
lower_bound=1,
)
flags.DEFINE_float(
"webhook_retry_backoff_seconds",
0.5,
"Delay before the first webhook retry, doubling on each subsequent "
"retry (exponential backoff).",
lower_bound=0.0,
)
except flags.DuplicateFlagError:
pass

Expand All @@ -87,6 +111,13 @@ def get_server_version() -> str:
async def lifespan(app: FastAPI):
"""Shared lifespan manager for initializing databases."""
del app # Unused.
# Load (and thereby validate) the webhook-signing identity up front: a
# misconfigured --webhook_signing_key must abort the boot loudly, not
# surface as a swallowed per-delivery error that silently degrades every
# webhook. Imported lazily; webhook_signer imports this module.
import webhook_signer

webhook_signer.signing_key()
# In tests or if flags aren't set, these might be None, handled by caller
if FLAGS.products_db_path and FLAGS.transactions_db_path:
await db.manager.init_dbs(
Expand Down
Loading
Loading