Skip to content
Closed
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
66 changes: 66 additions & 0 deletions docs/handler-authoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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_<base64> 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.
Expand Down
17 changes: 13 additions & 4 deletions examples/hello_seller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
14 changes: 8 additions & 6 deletions examples/hello_seller_creative.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
76 changes: 76 additions & 0 deletions examples/hello_seller_with_webhooks.py
Original file line number Diff line number Diff line change
@@ -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,
)
22 changes: 16 additions & 6 deletions src/adcp/decisioning/webhook_emit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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={
Expand Down
Loading