diff --git a/docs/handler-authoring.md b/docs/handler-authoring.md index 4d7c8e7ad..b5958ec0d 100644 --- a/docs/handler-authoring.md +++ b/docs/handler-authoring.md @@ -1033,6 +1033,54 @@ All three Phase-2 A2A hooks (#224 TaskStore, #225 PushNotificationConfigStore, #226 SkillMiddleware) have landed. A2A adoption now reaches parity with MCP for production agents. +## Webhooks + +When `auto_emit_completion_webhooks=True` (the default), the framework fires a +sync-completion webhook after every successfully-dispatched tool call whose task +type is in the spec's webhook-eligible set (`create_media_buy`, `activate_signal`, +and their siblings). Buyers who register `push_notification_config.url` receive +these notifications automatically. + +The framework requires a sender or supervisor at boot — it raises `AdcpError` +rather than silently dropping notifications if neither is wired and auto-emit is on. +Set `auto_emit_completion_webhooks=False` only if you emit webhooks manually inside +your platform methods. + +### Sender constructors + +Pick one per `WebhookSender` instance. All three share the same +`send_mcp(url, task_id, status, ...)` delivery API. + +| Constructor | Auth mode | When to use | +|---|---|---| +| `WebhookSender.from_jwk(jwk)` | RFC 9421 HTTP-signature | AdCP-conformant buyers; spec baseline (`kid`/`alg`/`adcp_use` live in the JWK dict) | +| `WebhookSender.from_bearer_token(token)` | `Authorization: Bearer` | Simplest; no key management; requires TLS | +| `WebhookSender.from_standard_webhooks_secret(secret, key_id=...)` | Standard Webhooks v1 | Svix / Resend / standardwebhooks.com receivers | + +### Sender vs. supervisor + +`WebhookSender` is the transport layer — it constructs and signs one HTTP POST. +`InMemoryWebhookDeliverySupervisor` wraps a sender and adds retry with exponential +backoff, per-endpoint circuit breakers, and an audit log. Pass +`webhook_supervisor=` in production so transient receiver outages don't cause +missed notifications. + +```python +import os +from adcp.webhook_sender import WebhookSender +from adcp.webhook_supervisor import InMemoryWebhookDeliverySupervisor +from adcp.decisioning import serve + +sender = WebhookSender.from_bearer_token(os.environ["WEBHOOK_BEARER_TOKEN"]) +supervisor = InMemoryWebhookDeliverySupervisor(sender=sender) +serve(my_platform, webhook_supervisor=supervisor) +``` + +For the full constructor reference and a migration table from legacy HMAC / bare +`requests.post` patterns, see +[`docs/webhooks/migration-from-fragmented-senders.md`](webhooks/migration-from-fragmented-senders.md). +See `examples/hello_seller_with_webhooks.py` for a runnable end-to-end wiring example. + ## Testing The integration test pattern in `tests/test_mcp_middleware_composition.py` diff --git a/examples/hello_seller.py b/examples/hello_seller.py index 507e2b6d0..f064b892c 100644 --- a/examples/hello_seller.py +++ b/examples/hello_seller.py @@ -334,9 +334,30 @@ def _get_packages(req: Any) -> list[dict[str, Any]]: # server. Default port 3001 over streamable-http; override via # ``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. + # ``auto_emit_completion_webhooks=False`` opts out here because this + # example has no signing key. Production sellers want webhooks on so + # buyers who register ``push_notification_config.url`` get sync- + # completion notifications. Pick a constructor and pass + # ``webhook_supervisor=`` (retry + circuit breaker, recommended) or + # ``webhook_sender=`` (transport only): + # + # from adcp.webhook_sender import WebhookSender + # from adcp.webhook_supervisor import InMemoryWebhookDeliverySupervisor + # + # # RFC 9421 JWK signing — AdCP spec baseline (recommended). + # # signing_jwk must be a dict with kid, alg, and adcp_use="webhook-signing": + # sender = WebhookSender.from_jwk(signing_jwk) + # + # # Shared bearer token — no key management, requires TLS: + # sender = WebhookSender.from_bearer_token(os.environ["WEBHOOK_BEARER_TOKEN"]) + # + # # Standard Webhooks v1 — Svix / Resend / standardwebhooks.com interop: + # sender = WebhookSender.from_standard_webhooks_secret( + # os.environ["WHSEC"], key_id="whsec_v1", + # ) + # + # supervisor = InMemoryWebhookDeliverySupervisor(sender=sender) + # serve(HelloSeller(), name="hello-seller", webhook_supervisor=supervisor) + # + # 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_with_webhooks.py b/examples/hello_seller_with_webhooks.py new file mode 100644 index 000000000..b83d1beb8 --- /dev/null +++ b/examples/hello_seller_with_webhooks.py @@ -0,0 +1,58 @@ +"""Hello-seller-with-webhooks — canonical ``WebhookSender`` + supervisor wiring. + +Extends ``hello_seller.py`` with a wired :class:`InMemoryWebhookDeliverySupervisor` +so sync-completion webhooks are delivered to buyers who register +``push_notification_config.url``. Uses :meth:`WebhookSender.from_bearer_token` +as the auth mode — no key management, simplest first step. + +Run:: + + WEBHOOK_BEARER_TOKEN= uv run python examples/hello_seller_with_webhooks.py + +The server boots on http://localhost:3001/mcp. Any buyer that registers +``push_notification_config.url`` on a ``create_media_buy`` request receives a +completion notification POSTed with ``Authorization: Bearer ``. + +To use RFC 9421 JWK signing instead (AdCP spec baseline, required for buyers +that verify body signatures), swap :meth:`~WebhookSender.from_bearer_token` +for :meth:`~WebhookSender.from_jwk`. See ``docs/handler-authoring.md#webhooks`` +for the full constructor comparison. +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +# Allow importing hello_seller as a sibling module when run as a script. +sys.path.insert(0, str(Path(__file__).parent)) + +from hello_seller import HelloSeller # type: ignore[import] # noqa: E402 + +from adcp.decisioning import serve +from adcp.webhook_sender import WebhookSender +from adcp.webhook_supervisor import InMemoryWebhookDeliverySupervisor + +if __name__ == "__main__": + token = os.environ.get("WEBHOOK_BEARER_TOKEN", "") + if not token: + import warnings + + warnings.warn( + "WEBHOOK_BEARER_TOKEN is not set; using 'dev-fixture-token'. " + "Set WEBHOOK_BEARER_TOKEN= before connecting real buyers.", + category=UserWarning, + stacklevel=1, + ) + token = "dev-fixture-token" + sender = WebhookSender.from_bearer_token(token) + # InMemoryWebhookDeliverySupervisor wraps the sender with retry + # (exponential backoff, 3 attempts) and per-endpoint circuit breakers. + # Pass webhook_supervisor= rather than webhook_sender= in production. + supervisor = InMemoryWebhookDeliverySupervisor(sender=sender) + serve( + HelloSeller(), + name="hello-seller-with-webhooks", + webhook_supervisor=supervisor, + )