Skip to content

Commit 6cf71cb

Browse files
bokelleyclaude
andauthored
docs(examples): webhook wiring recipe — handler-authoring section + expanded hello_seller + new with_webhooks example (#551)
* docs(examples): webhook wiring recipe in handler-authoring + hello_seller + new with_webhooks example Closes #546 Three changes to eliminate the silent-no-op trap for first-time DecisioningPlatform adopters: 1. docs/handler-authoring.md: adds a ## Webhooks section (sender constructors table, sender-vs-supervisor explanation, wiring snippet, link to migration guide) so the doc anchor used in example comments actually exists. 2. examples/hello_seller.py: expands the bare "wire webhook_sender= in production" note into a full three-constructor menu with import lines and a link to docs/handler-authoring.md#webhooks. 3. examples/hello_seller_with_webhooks.py: new ~40-LOC example showing the canonical bearer-token + InMemoryWebhookDeliverySupervisor wiring pattern — the first copy-paste-ready path to production webhooks. The boot-time fail-fast (validate_webhook_sender_for_platform) was already shipping as a hard raise; this PR closes the documentation gap that let adopters reach that error without knowing how to resolve it. https://claude.ai/code/session_01Ay2SJvRpBg8EP9nG9DgQMy * fix(examples): correct from_jwk call signature + warn on missing WEBHOOK_BEARER_TOKEN Pre-PR review blockers: - from_jwk takes a JWK dict; kid/alg/adcp_use live inside the dict, not as separate kwargs. Fix the doc table and hello_seller.py comment accordingly. - hello_seller_with_webhooks.py now warns (instead of silently continuing) when WEBHOOK_BEARER_TOKEN is unset, so developers discover the dev-fixture default rather than running a production server with it. https://claude.ai/code/session_01Ay2SJvRpBg8EP9nG9DgQMy --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 98fd456 commit 6cf71cb

3 files changed

Lines changed: 132 additions & 5 deletions

File tree

docs/handler-authoring.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1033,6 +1033,54 @@ All three Phase-2 A2A hooks (#224 TaskStore, #225 PushNotificationConfigStore,
10331033
#226 SkillMiddleware) have landed. A2A adoption now reaches parity with
10341034
MCP for production agents.
10351035

1036+
## Webhooks
1037+
1038+
When `auto_emit_completion_webhooks=True` (the default), the framework fires a
1039+
sync-completion webhook after every successfully-dispatched tool call whose task
1040+
type is in the spec's webhook-eligible set (`create_media_buy`, `activate_signal`,
1041+
and their siblings). Buyers who register `push_notification_config.url` receive
1042+
these notifications automatically.
1043+
1044+
The framework requires a sender or supervisor at boot — it raises `AdcpError`
1045+
rather than silently dropping notifications if neither is wired and auto-emit is on.
1046+
Set `auto_emit_completion_webhooks=False` only if you emit webhooks manually inside
1047+
your platform methods.
1048+
1049+
### Sender constructors
1050+
1051+
Pick one per `WebhookSender` instance. All three share the same
1052+
`send_mcp(url, task_id, status, ...)` delivery API.
1053+
1054+
| Constructor | Auth mode | When to use |
1055+
|---|---|---|
1056+
| `WebhookSender.from_jwk(jwk)` | RFC 9421 HTTP-signature | AdCP-conformant buyers; spec baseline (`kid`/`alg`/`adcp_use` live in the JWK dict) |
1057+
| `WebhookSender.from_bearer_token(token)` | `Authorization: Bearer` | Simplest; no key management; requires TLS |
1058+
| `WebhookSender.from_standard_webhooks_secret(secret, key_id=...)` | Standard Webhooks v1 | Svix / Resend / standardwebhooks.com receivers |
1059+
1060+
### Sender vs. supervisor
1061+
1062+
`WebhookSender` is the transport layer — it constructs and signs one HTTP POST.
1063+
`InMemoryWebhookDeliverySupervisor` wraps a sender and adds retry with exponential
1064+
backoff, per-endpoint circuit breakers, and an audit log. Pass
1065+
`webhook_supervisor=` in production so transient receiver outages don't cause
1066+
missed notifications.
1067+
1068+
```python
1069+
import os
1070+
from adcp.webhook_sender import WebhookSender
1071+
from adcp.webhook_supervisor import InMemoryWebhookDeliverySupervisor
1072+
from adcp.decisioning import serve
1073+
1074+
sender = WebhookSender.from_bearer_token(os.environ["WEBHOOK_BEARER_TOKEN"])
1075+
supervisor = InMemoryWebhookDeliverySupervisor(sender=sender)
1076+
serve(my_platform, webhook_supervisor=supervisor)
1077+
```
1078+
1079+
For the full constructor reference and a migration table from legacy HMAC / bare
1080+
`requests.post` patterns, see
1081+
[`docs/webhooks/migration-from-fragmented-senders.md`](webhooks/migration-from-fragmented-senders.md).
1082+
See `examples/hello_seller_with_webhooks.py` for a runnable end-to-end wiring example.
1083+
10361084
## Testing
10371085

10381086
The integration test pattern in `tests/test_mcp_middleware_composition.py`

examples/hello_seller.py

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -334,9 +334,30 @@ def _get_packages(req: Any) -> list[dict[str, Any]]:
334334
# server. Default port 3001 over streamable-http; override via
335335
# ``serve(seller, port=...)``.
336336
#
337-
# ``auto_emit_completion_webhooks=False`` opts out of the F12
338-
# sync-completion webhook auto-emit so the example boots without
339-
# a ``webhook_sender``. Wire ``webhook_sender=`` in production so
340-
# buyers who register ``push_notification_config.url`` get
341-
# notifications.
337+
# ``auto_emit_completion_webhooks=False`` opts out here because this
338+
# example has no signing key. Production sellers want webhooks on so
339+
# buyers who register ``push_notification_config.url`` get sync-
340+
# completion notifications. Pick a constructor and pass
341+
# ``webhook_supervisor=`` (retry + circuit breaker, recommended) or
342+
# ``webhook_sender=`` (transport only):
343+
#
344+
# from adcp.webhook_sender import WebhookSender
345+
# from adcp.webhook_supervisor import InMemoryWebhookDeliverySupervisor
346+
#
347+
# # RFC 9421 JWK signing — AdCP spec baseline (recommended).
348+
# # signing_jwk must be a dict with kid, alg, and adcp_use="webhook-signing":
349+
# sender = WebhookSender.from_jwk(signing_jwk)
350+
#
351+
# # Shared bearer token — no key management, requires TLS:
352+
# sender = WebhookSender.from_bearer_token(os.environ["WEBHOOK_BEARER_TOKEN"])
353+
#
354+
# # Standard Webhooks v1 — Svix / Resend / standardwebhooks.com interop:
355+
# sender = WebhookSender.from_standard_webhooks_secret(
356+
# os.environ["WHSEC"], key_id="whsec_v1",
357+
# )
358+
#
359+
# supervisor = InMemoryWebhookDeliverySupervisor(sender=sender)
360+
# serve(HelloSeller(), name="hello-seller", webhook_supervisor=supervisor)
361+
#
362+
# See docs/handler-authoring.md#webhooks for the full wiring recipe.
342363
serve(HelloSeller(), name="hello-seller", auto_emit_completion_webhooks=False)
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
"""Hello-seller-with-webhooks — canonical ``WebhookSender`` + supervisor wiring.
2+
3+
Extends ``hello_seller.py`` with a wired :class:`InMemoryWebhookDeliverySupervisor`
4+
so sync-completion webhooks are delivered to buyers who register
5+
``push_notification_config.url``. Uses :meth:`WebhookSender.from_bearer_token`
6+
as the auth mode — no key management, simplest first step.
7+
8+
Run::
9+
10+
WEBHOOK_BEARER_TOKEN=<your-token> uv run python examples/hello_seller_with_webhooks.py
11+
12+
The server boots on http://localhost:3001/mcp. Any buyer that registers
13+
``push_notification_config.url`` on a ``create_media_buy`` request receives a
14+
completion notification POSTed with ``Authorization: Bearer <token>``.
15+
16+
To use RFC 9421 JWK signing instead (AdCP spec baseline, required for buyers
17+
that verify body signatures), swap :meth:`~WebhookSender.from_bearer_token`
18+
for :meth:`~WebhookSender.from_jwk`. See ``docs/handler-authoring.md#webhooks``
19+
for the full constructor comparison.
20+
"""
21+
22+
from __future__ import annotations
23+
24+
import os
25+
import sys
26+
from pathlib import Path
27+
28+
# Allow importing hello_seller as a sibling module when run as a script.
29+
sys.path.insert(0, str(Path(__file__).parent))
30+
31+
from hello_seller import HelloSeller # type: ignore[import] # noqa: E402
32+
33+
from adcp.decisioning import serve
34+
from adcp.webhook_sender import WebhookSender
35+
from adcp.webhook_supervisor import InMemoryWebhookDeliverySupervisor
36+
37+
if __name__ == "__main__":
38+
token = os.environ.get("WEBHOOK_BEARER_TOKEN", "")
39+
if not token:
40+
import warnings
41+
42+
warnings.warn(
43+
"WEBHOOK_BEARER_TOKEN is not set; using 'dev-fixture-token'. "
44+
"Set WEBHOOK_BEARER_TOKEN=<real-token> before connecting real buyers.",
45+
category=UserWarning,
46+
stacklevel=1,
47+
)
48+
token = "dev-fixture-token"
49+
sender = WebhookSender.from_bearer_token(token)
50+
# InMemoryWebhookDeliverySupervisor wraps the sender with retry
51+
# (exponential backoff, 3 attempts) and per-endpoint circuit breakers.
52+
# Pass webhook_supervisor= rather than webhook_sender= in production.
53+
supervisor = InMemoryWebhookDeliverySupervisor(sender=sender)
54+
serve(
55+
HelloSeller(),
56+
name="hello-seller-with-webhooks",
57+
webhook_supervisor=supervisor,
58+
)

0 commit comments

Comments
 (0)