diff --git a/docs/handler-authoring.md b/docs/handler-authoring.md index 4d7c8e7a..11a33875 100644 --- a/docs/handler-authoring.md +++ b/docs/handler-authoring.md @@ -22,6 +22,8 @@ to rebuild middleware that already exists. [Multi-tenant typing](#multi-tenant-typing) section. The idempotency middleware uses `(tenant_id, caller_identity)` for scope isolation — populating `tenant_id` is required for cross-tenant safety. +- **Need completion webhooks?** → Wire a `WebhookSender` before calling + `serve()`. See [Completion webhooks](#completion-webhooks) below. - **Full context?** → Keep reading. ## The one-file starting point @@ -1203,12 +1205,76 @@ to avoid leaking server internals. Return `adcp_error("AUTH_REQUIRED")` instead; the SDK maps it to an authenticated-but-rejected error shape the client can handle programmatically. +## Completion webhooks + +When a buyer registers `push_notification_config.url` on a mutating +request (`create_media_buy`, `activate_signal`, etc.), the framework +can fire a completion webhook automatically on sync success — this is +`auto_emit_completion_webhooks`, which defaults to `True`. + +**The boot-time check.** If your platform's advertised tools include any +spec-eligible webhook type (the 20-value task-type enum: `create_media_buy`, +`activate_signal`, etc.) AND `auto_emit_completion_webhooks=True` AND no +sender is wired, `serve()` raises `AdcpError` at boot rather than silently +dropping buyer notifications at runtime. A platform advertising only +non-webhook-eligible tools (discovery-only agents, read-only analytics +handlers) passes the boot check cleanly. + +**Choosing a sender constructor:** + +```python +from adcp.webhook_sender import WebhookSender + +# RFC 9421 HTTP Signatures — spec-conformant, recommended for production. +# Requires a JWK with adcp_use="webhook-signing" (distinct from your +# request-signing key). Generate with: adcp-keygen --purpose webhook-signing +sender = WebhookSender.from_jwk(my_private_jwk) + +# PEM file alternative — same RFC 9421 signing, different key loading path. +# from_pem and from_jwk produce identical wire output. +# sender = WebhookSender.from_pem("keys/webhook.pem", key_id="wh-1") + +# Bearer token — simplest. No body signing; receiver authenticates at gateway. +# Requires TLS/mTLS at the transport layer for security. +sender = WebhookSender.from_bearer_token("my-shared-token") + +# Standard Webhooks (Svix / Resend interop). +# Pass the literal whsec_ string distributed by your buyer's platform. +sender = WebhookSender.from_standard_webhooks_secret("whsec_...", key_id="wh-1") +``` + +**Adding retry and circuit breaker (recommended for production):** + +```python +from adcp.webhook_supervisor import InMemoryWebhookDeliverySupervisor + +supervisor = InMemoryWebhookDeliverySupervisor(sender) +serve(platform, webhook_supervisor=supervisor) +# supervisor takes precedence over webhook_sender when both are passed. +# In-process state only — not durable across restarts. Use +# PgWebhookDeliverySupervisor for durable retry queues. +``` + +**Opting out explicitly** (acceptable when you fire webhooks manually +inside your platform methods, or for a minimal example that has no +signing key): + +```python +serve(platform, auto_emit_completion_webhooks=False) +``` + +See `examples/hello_seller_with_webhooks.py` for a copy-paste-ready +demonstration and `docs/webhooks/migration-from-fragmented-senders.md` +for porting an existing webhook stack. + ## Where to look next - `examples/minimal_sales_agent.py` — handler-only starting point. - `examples/mcp_with_auth_middleware.py` — full auth + typed context via `BearerTokenAuthMiddleware`. Foundation for Pattern 2b; bring your own subdomain-routing middleware on top. +- `examples/hello_seller_with_webhooks.py` — minimal sender + supervisor + wiring for the dominant production case. - `src/adcp/server/responses.py` — response builder reference. - `src/adcp/server/helpers.py` — error codes, state machine, account resolution. diff --git a/examples/hello_seller.py b/examples/hello_seller.py index 507e2b6d..d56c0c8c 100644 --- a/examples/hello_seller.py +++ b/examples/hello_seller.py @@ -335,8 +335,17 @@ def _get_packages(req: Any) -> list[dict[str, Any]]: # ``serve(seller, port=...)``. # # ``auto_emit_completion_webhooks=False`` opts out of the F12 - # sync-completion webhook auto-emit so the example boots without - # a ``webhook_sender``. Wire ``webhook_sender=`` in production so - # buyers who register ``push_notification_config.url`` get - # notifications. + # sync-completion webhook auto-emit so this example boots without + # a signing key. Production sellers WANT this feature (True is + # the default) — without it, buyers who register + # ``push_notification_config.url`` get no notifications. + # + # Wire a sender before removing this flag. Three constructors: + # + # WebhookSender.from_jwk(jwk) # RFC 9421 — spec-conformant, recommended + # WebhookSender.from_bearer_token(token) # simplest; gateway validates the token + # WebhookSender.from_standard_webhooks_secret(secret) # Svix / Resend interop + # + # Pair with InMemoryWebhookDeliverySupervisor for retry + circuit breaker. + # See docs/handler-authoring.md#webhooks for the full wiring recipe. serve(HelloSeller(), name="hello-seller", auto_emit_completion_webhooks=False) diff --git a/examples/hello_seller_creative.py b/examples/hello_seller_creative.py index e3beaa93..5d424687 100644 --- a/examples/hello_seller_creative.py +++ b/examples/hello_seller_creative.py @@ -107,13 +107,15 @@ def main() -> None: * ``tools/call build_creative`` returns the synthesized manifest. The ``auto_emit_completion_webhooks=False`` opt-out keeps this - example minimal. In production, wire ``webhook_sender=`` so - buyers who register ``push_notification_config.url`` get - completion notifications: + example minimal. In production, wire a sender so buyers who + register ``push_notification_config.url`` get completion + notifications. Three constructor options: - from adcp.webhook_sender import WebhookSender - sender = WebhookSender.from_jwk(...) - serve(HelloCreativeSeller(), webhook_sender=sender) + WebhookSender.from_jwk(jwk) # RFC 9421, spec-conformant + WebhookSender.from_bearer_token(token) # simplest + WebhookSender.from_standard_webhooks_secret(secret) # Svix/Resend + + See docs/handler-authoring.md#webhooks for the full recipe. """ serve(HelloCreativeSeller(), auto_emit_completion_webhooks=False) diff --git a/examples/hello_seller_with_webhooks.py b/examples/hello_seller_with_webhooks.py new file mode 100644 index 00000000..3ff5f02e --- /dev/null +++ b/examples/hello_seller_with_webhooks.py @@ -0,0 +1,76 @@ +"""hello_seller_with_webhooks — minimal seller with completion webhook wiring. + +Demonstrates ``WebhookSender`` + ``InMemoryWebhookDeliverySupervisor`` so buyers +who register ``push_notification_config.url`` actually receive completion +notifications. + +Run from the repo root:: + + uv run python examples/hello_seller_with_webhooks.py + +The seller boots on port 3001. Send a ``create_media_buy`` request with +``push_notification_config.url`` set to see the webhook fire after the +sync response returns. + +See ``docs/handler-authoring.md#webhooks`` for the full wiring guide. +""" + +from __future__ import annotations + +import os +from typing import Any + +from adcp.decisioning import ( + DecisioningCapabilities, + DecisioningPlatform, + SingletonAccounts, + serve, +) +from adcp.webhook_sender import WebhookSender +from adcp.webhook_supervisor import InMemoryWebhookDeliverySupervisor + + +class _WebhookSeller(DecisioningPlatform): + """Minimal sales-non-guaranteed seller used by this example.""" + + capabilities = DecisioningCapabilities( + specialisms=["sales-non-guaranteed"], + channels=["display"], + pricing_models=["cpm"], + ) + accounts = SingletonAccounts(account_id="hello") + + def get_products(self, params: Any, context: Any = None) -> dict[str, Any]: + return {"products": [{"product_id": "display-rotation", "name": "Display rotation"}]} + + def create_media_buy(self, params: Any, context: Any = None) -> dict[str, Any]: + return {"media_buy_id": "mb-1", "status": "active", "packages": []} + + def update_media_buy(self, params: Any, context: Any = None) -> dict[str, Any]: + return {"media_buy_id": "mb-1", "status": "active", "packages": []} + + def sync_creatives(self, params: Any, context: Any = None) -> dict[str, Any]: + return {"creatives": []} + + def get_media_buy_delivery(self, params: Any, context: Any = None) -> dict[str, Any]: + return {"delivery": []} + + +if __name__ == "__main__": + # Bearer-token sender — simplest constructor, no private key needed. + # Swap for WebhookSender.from_jwk(my_jwk) in production for RFC 9421 + # body signing, or from_standard_webhooks_secret("whsec_...", key_id="wh-1") + # for Svix / Resend interop. + token = os.environ.get("WEBHOOK_BEARER_TOKEN", "dev-fixture-token") + sender = WebhookSender.from_bearer_token(token) + + # Supervisor adds retry (3 attempts, exponential backoff) and per-endpoint + # circuit breaking. In-process state only; use PgWebhookDeliverySupervisor + # for durable retry across restarts. + supervisor = InMemoryWebhookDeliverySupervisor(sender) + + serve( + _WebhookSeller(), + name="hello-seller-webhooks", + webhook_supervisor=supervisor, + ) diff --git a/src/adcp/decisioning/webhook_emit.py b/src/adcp/decisioning/webhook_emit.py index 68729efe..1064e995 100644 --- a/src/adcp/decisioning/webhook_emit.py +++ b/src/adcp/decisioning/webhook_emit.py @@ -373,12 +373,22 @@ def validate_webhook_sender_for_platform( f"{sorted(eligible)!r}, but neither webhook_sender nor " "webhook_supervisor was wired. Buyers who register " "push_notification_config.url on these tools would have their " - "notifications silently dropped. Pass a configured " - "WebhookSender (transport only) or InMemoryWebhookDeliverySupervisor " - "(retry + circuit breaker) to " - "adcp.decisioning.serve.create_adcp_server_from_platform, " - "or set auto_emit_completion_webhooks=False if you handle " - "webhooks manually inside your platform methods." + "notifications silently dropped. " + "Wire a sender before calling serve():\n\n" + " # JWK signing (RFC 9421, spec-conformant):\n" + " from adcp.webhook_sender import WebhookSender\n" + " sender = WebhookSender.from_jwk(my_jwk)\n\n" + " # Bearer token (simplest — gateway-validated):\n" + " sender = WebhookSender.from_bearer_token('my-token')\n\n" + " # Standard Webhooks / Svix / Resend:\n" + " sender = WebhookSender.from_standard_webhooks_secret('whsec_...', key_id='wh-1')\n\n" + " # With retry + circuit breaker:\n" + " from adcp.webhook_supervisor import InMemoryWebhookDeliverySupervisor\n" + " supervisor = InMemoryWebhookDeliverySupervisor(sender)\n" + " serve(platform, webhook_supervisor=supervisor)\n\n" + "Or set auto_emit_completion_webhooks=False if you handle " + "webhooks manually inside your platform methods. " + "See docs/handler-authoring.md#webhooks for wiring recipes." ), recovery="terminal", details={