Skip to content

Webhook wiring: boot-time fail-fast + brighter docs when auto_emit_completion_webhooks is on without a sender #546

@bokelley

Description

@bokelley

Summary

Adopters writing their first DecisioningPlatform tend to run with the defaults: auto_emit_completion_webhooks=True (the framework default), no webhook_sender=, no webhook_supervisor=. That combination silently no-ops every completion webhook — buyers who registered push_notification_config.url get no notifications, and the seller has no signal that anything is wrong.

Compounding this: examples/hello_seller.py opts out via auto_emit_completion_webhooks=False rather than demonstrating the wired path, because there's no universal "default sender" — the existing WebhookSender requires a signing key, which a hello-world doesn't have. So new adopters see the canonical example with the buyer-facing webhook feature off and have to figure out from scratch how to turn it on correctly.

Background

Originally filed as item #19 in salesagent's SDK_FEEDBACK round 2 ("ship a DefaultWebhookSender derived from the supervisor"). After working through the existing surface, that ask isn't quite the right shape:

  • WebhookSender requires a private key (RFC 9421 JWK signing) or a bearer/HMAC secret. There's no universal default that's safe in production.
  • A DefaultWebhookSender that signs nothing would be a footgun.
  • The WebhookSenderWebhookDeliverySupervisor relationship is well-designed once you know the pattern; the friction is for adopters who don't know it yet.

So the actionable shape is two smaller changes that together make the silent-no-op impossible.

Proposed changes

1. Boot-time fail-fast (or loud warning) on the silent-no-op combo

In adcp.decisioning.serve.serve() (or adcp.server.serve.serve() — wherever the auto-emit gate lives), when:

  • auto_emit_completion_webhooks=True (default), AND
  • webhook_sender is None, AND
  • webhook_supervisor is None

…raise AdcpError at boot with a structured message:

auto_emit_completion_webhooks=True but no webhook_sender or webhook_supervisor is wired. Buyers who register push_notification_config.url will not receive notifications. Either wire a sender (see WebhookSender.from_jwk / from_bearer_token / from_standard_webhooks_secret), or set auto_emit_completion_webhooks=False if you intentionally emit webhooks manually inside your handlers.

A loud WARNING log is acceptable for the soft-mode rollout; raise is the right end state. Mirrors the existing ADCP_DECISIONING_STRICT_VALIDATE_PLATFORM ratchet pattern.

2. Brighter hello_seller.py documentation of the opt-out

Today (examples/hello_seller.py):

```python
serve(HelloSeller(), name="hello-seller", auto_emit_completion_webhooks=False)
```

…with a brief docstring note. Adopters reading the canonical example may copy this verbatim into production, where auto_emit_completion_webhooks=False is rarely the right answer (most sellers want completion webhooks).

Suggestion: expand the inline comment to spell out the why of the opt-out and link to a docs/handler-authoring.md#webhooks section that walks through the three sender constructors with a worked example for each. Something like:

```python

Production sellers WANT webhooks (auto_emit_completion_webhooks=True

is the default). hello-seller opts out only because it has no

signing key — webhooks need either:

- WebhookSender.from_jwk(private_key=..., key_id=..., alg="ES256")

(RFC 9421, recommended)

- WebhookSender.from_bearer_token(token=...) (simplest; agreed shared secret)

- WebhookSender.from_standard_webhooks_secret(secret=...)

(compatible with standardwebhooks.com receivers)

See docs/handler-authoring.md#webhooks for the wiring recipe.

serve(HelloSeller(), name="hello-seller", auto_emit_completion_webhooks=False)
```

3. (Optional) Adopter-friendly variant of hello_seller.py

A second hello-world example (examples/hello_seller_with_webhooks.py) that wires WebhookSender.from_bearer_token against a fixture token + an InMemoryWebhookDeliverySupervisor. ~30 LOC, gives copy-paste-ready code for the dominant case.

Memory profile note

The existing WebhookSender reuses one httpx.AsyncClient per instance — good. But the framework doesn't appear to have a clear contract for per-tenant senders (different signing key per tenant). Multi-tenant adopters may construct one sender per tenant, and if those aren't cached the httpx.AsyncClient instances accumulate.

If multi-tenant signing-key isolation is an expected use case, a MultiTenantWebhookSender or WebhookSenderFactory would help. Currently flagged in salesagent's slow-leak-investigation lens; not yet confirmed as a leak source there but worth a docs answer either way.

Context

Working through the salesagent greenfield rebuild on adcp 4.3 main. SDK_FEEDBACK round-2 doc (the source of items #16-#24 from salesagent's perspective) is at `core/SDK_FEEDBACK.md` in the salesagent repo if useful for triage; this issue captures the deferred #19 specifically.

Related shipped: #544 CallableSubdomainTenantRouter (round-2 #20), #545 BearerTokenAuthMiddleware header customization (round-2 #18). Both PR'd in the past hour.

🤖 Filed via Claude Code

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions