Skip to content

feat(webhooks): HMAC/bearer outbound delivery + Docker localhost normalization for parity with operator-shaped webhook senders #478

Description

@bokelley

Context

The SDK already ships a complete asymmetric-signing webhook delivery stack:

  • adcp.webhooks.WebhookSender (602 LOC) — owns marshaling + JWK-based signing + SSRF + POST. Signed bytes match sent bytes by construction (eliminates sign-vs-body divergence bugs at the receiver).
  • adcp.webhooks.WebhookDeliverySupervisor Protocol with InMemoryWebhookDeliverySupervisor (664 LOC) and PG-backed (810 LOC) — retry policy + circuit breaker + delivery log + durable queue.
  • adcp.webhooks high-level helpers — sign_webhook, get_adcp_signed_headers_for_webhook, create_mcp_webhook_payload, create_a2a_webhook_payload, generate_webhook_idempotency_key, extract_webhook_result_data.
  • adcp.signing primitives — webhook_signer, webhook_hmac (already exists for the AdCP-native HMAC flavor), webhook_verifier.

What it doesn't cover, and what operators need for parity with legacy buyer integrations:

  1. HMAC-shared-secret outbound signing as a first-class delivery mode — current WebhookSender is JWK-only. Operators delivering to buyers who don't (yet) do JWK lookup need HMAC. adcp.signing.webhook_hmac exists at the primitive layer but isn't wired into the delivery API.
  2. Bearer-token auth as a delivery option — for buyers who accept signed webhooks via Authorization header rather than via signature header.
  3. Docker localhost normalizationlocalhost / 127.0.0.1 rewritten to host.docker.internal (or container-network equivalent) when the sender runs inside Docker.
  4. Standard Webhooks spec alignmentstandardwebhooks.com defines a canonical HMAC scheme + headers. Adopting it for the HMAC mode lets buyers using Svix/Resend/etc. verify our webhooks without custom code.

This is independently buildable — does not depend on capability projection or the multi-platform proof — and is generic across all AdCP roles (sellers, buyers, signals agents, creative agents, governance agents). Anyone using pushNotificationConfig benefits.

Goal

Three additive features on the existing webhook delivery stack:

  1. HMAC-shared-secret delivery mode wired into WebhookSender
  2. Bearer-token auth delivery mode wired into WebhookSender
  3. Docker localhost normalization as an opt-in transport hook
  4. Standard Webhooks signing scheme support (mode flag on the HMAC path)

Plus migration documentation for operators with multiple legacy senders.

Non-goals

  • NOT building a new WebhookGateway class — the existing WebhookSender is the gateway. We extend it.
  • NOT replacing AdCP-native asymmetric signing — that's the recommended path for AdCP-conformant buyers. HMAC is the back-compat mode.
  • NOT building a new supervisor — WebhookDeliverySupervisor already covers retry / circuit breaker / durable queue.
  • NOT shipping a managed delivery service. We document Svix as a SaaS off-ramp for operators who want managed.

Scope

F1. HMAC delivery mode

Add a WebhookSender.from_hmac_secret(secret: bytes, *, key_id: str, scheme: HmacScheme) constructor. HmacScheme is an enum:

  • HmacScheme.STANDARD_WEBHOOKS — adopts the standardwebhooks.com canonical scheme. Headers webhook-id, webhook-timestamp, webhook-signature: v1,{sig}. Signed payload f"{webhook_id}.{webhook_timestamp}.{body}". Svix/Resend interop format.
  • HmacScheme.ADCP_LEGACY — current adcp.signing.webhook_hmac shape (already exists; just wires it into the delivery API).

Same send_mcp / send_a2a / resend API. Same idempotency-key reuse semantics.

F2. Bearer-token auth mode

WebhookSender.from_bearer_token(token: str) constructor. POSTs the body with Authorization: Bearer <token> header. No body signing. Marshaling still owned by the sender.

F3. Docker localhost normalization (transport hook)

WebhookSender(... transport_hooks=[DockerLocalhostRewrite()]) — opt-in URL rewrite hook that runs before SSRF validation. Resolves localhost / 127.0.0.1 to host.docker.internal (Linux: 172.17.0.1 or container gateway). Disabled by default; activated by adopters running e2e tests against host services from inside containers.

Land the TransportHook Protocol now; ship DockerLocalhostRewrite as the reference impl. Future hooks (HostnameAllowlist, RetryAfterPropagation) land per demand.

F4. Standard Webhooks scheme primitives

adcp.signing.standard_webhooks — pure-Python implementation of the standardwebhooks.com signing/verifying. ~50 LOC. Independent of the delivery API so adopters can verify Svix-signed inbound webhooks too. Don't depend on the standardwebhooks PyPI package — its deps aren't worth it for this size.

F5. Migration doc

docs/webhooks/migration-from-fragmented-senders.md — translation table for operators with multiple legacy senders:

Legacy pattern New equivalent
requests.post(url, json=payload, headers={"Authorization": f"Bearer {token}"}) WebhookSender.from_bearer_token(token).send_raw(url, payload)
hmac_sha256(secret, json.dumps(payload, separators=(',',':'))) + requests.post(url, json=payload, ...) (sign-vs-body bug) WebhookSender.from_hmac_secret(secret, scheme=HmacScheme.ADCP_LEGACY).send_raw(url, payload)
Custom Docker rewrite logic transport_hooks=[DockerLocalhostRewrite()]
Per-call retry loop WebhookDeliverySupervisor.deliver(...)
Per-call SSRF check automatic (always runs)

Acceptance criteria

  • WebhookSender.from_hmac_secret(...) delivers via Standard Webhooks scheme; round-trip verified against svix Python verifier in CI
  • WebhookSender.from_hmac_secret(... scheme=ADCP_LEGACY) round-trips against existing webhook_hmac.py verifier (no regression)
  • WebhookSender.from_bearer_token(...) delivers with Authorization: Bearer <token> header
  • DockerLocalhostRewrite rewrites localhosthost.docker.internal when wired; transparent when not
  • All three modes go through the same SSRF validation path
  • All three modes work with WebhookDeliverySupervisor for retry / circuit-breaker
  • Migration doc has worked examples for each legacy sender pattern
  • No regressions in existing JWK delivery path

Open design questions

  1. HMAC scheme default: when an adopter calls from_hmac_secret(secret) without specifying a scheme, what's the default? Lean: explicit kwarg with no default, force a choice. Naming conservatism wins.
  2. Bearer + signing combo: some operators want both (bearer auth at gateway, signature for end-to-end integrity). Lean: single mode per WebhookSender instance; operators who want both create two senders. Keeps the API simple.
  3. Standard Webhooks lib: implement in-house or depend on the standardwebhooks PyPI package? Lean: in-house (~50 LOC, deps unnecessary).

Out of scope (separate issues)

  • Multi-tenant signing-key rotation policy
  • mTLS-based webhook auth
  • Webhook payload encryption (E2E) — not in AdCP spec today

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