Skip to content
Merged
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
48 changes: 48 additions & 0 deletions docs/handler-authoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
31 changes: 26 additions & 5 deletions examples/hello_seller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
58 changes: 58 additions & 0 deletions examples/hello_seller_with_webhooks.py
Original file line number Diff line number Diff line change
@@ -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=<your-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 <token>``.

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=<real-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,
)
Loading