Module adcp.decisioning.webhook_emit
Auto-emit completion webhook on sync-success arm of mutating tools.
When a buyer supplies push_notification_config.url on a request and
the seller answers via the sync fast path (NOT a :class:TaskHandoff),
the framework fires a completion webhook to that URL after the response
so buyers get consistent notification regardless of how the seller
routed the call. Without this, a buyer registering a webhook URL would
get notifications only on the HITL path — sync responses would leave
them polling.
Mirrors the JS-side emitSyncCompletionWebhook at
src/lib/server/decisioning/runtime/from-platform.ts (commits
8dc427f9 and 7a887dfa). Wire-format is identical: same
task_type, status: 'completed', result field carrying the
projected sync response, and an echoed token if the buyer
registered one. task_id is synthesized as f"sync-{uuid4()}"
since sync responses don't allocate a registry task; buyers correlate
via the resource ids embedded in result.
Fire-and-forget. Webhook delivery runs in a background asyncio
task; the sync response returns inline immediately. A buyer-supplied
slowloris webhook URL must not be able to hold the seller's request
worker for the full retry budget — the JS round-2 fix (7a887dfa)
addressed this DoS vector and Python preserves the same posture.
_BACKGROUND_WEBHOOK_TASKS strong-refs in-flight emissions so the
asyncio loop's weak-ref behavior doesn't garbage-collect them
mid-flight.
Spec gate. Only tools in :data:SPEC_WEBHOOK_TASK_TYPES (the
closed enum from schemas/cache/enums/task-type.json)
emit. Spec-validating webhook receivers reject envelopes with
non-spec task_type values; tools the framework dispatches that
aren't in the spec enum (adopter-only specialism methods) skip
delivery and rely on publishStatusChange for state updates.
Adopters who emit webhooks manually inside their handlers pass
auto_emit_completion_webhooks=False to
:func:serve() to avoid duplicate delivery.
Global variables
var SPEC_WEBHOOK_TASK_TYPES : frozenset[str]-
Drift policy: bump this constant AND the JS
SPEC_WEBHOOK_TASK_TYPESin lockstep when the spec enum widens. A unit test pins this to the on-disk enum so out-of-band drift surfaces in CI.
Functions
async def emit_terminal_completion_webhook(*,
target: DeliveryTarget | None,
enabled: bool,
method_name: str,
params: Any,
status: str,
task_id: str,
result: Any = None) ‑> None-
Expand source code
async def emit_terminal_completion_webhook( *, target: DeliveryTarget | None, enabled: bool, method_name: str, params: Any, status: str, task_id: str, result: Any = None, ) -> None: """Deliver the terminal completion / failure webhook for an async task. Fired from the BACKGROUND completion path of :func:`adcp.decisioning.dispatch._project_handoff` — once, after the registry has recorded the terminal state. This is the async-path counterpart to :func:`maybe_emit_sync_completion`: when a seller returns a ``Submitted`` envelope (the request handed off to a task) AND the buyer supplied ``push_notification_config``, the spec (AdCP, adcp#5389) requires the seller to deliver at least the terminal completion / failure notification to that webhook. Buyers who registered a push config get notified without polling ``tasks/get``. Unlike the sync gate, this coroutine is already running inside the background task — there is no inline buyer response to protect, so the delivery is awaited directly rather than scheduled fire-and- forget. The whole body is wrapped in ``try/except Exception`` and logged-and-swallowed: a webhook delivery failure must never crash the background task or block the registry's terminal-state record (which the buyer can still read via ``tasks/get``). Skips silently when: * ``enabled`` is False (operator opted out via ``auto_emit_completion_webhooks=False`` — they emit manually). * ``method_name`` isn't in :data:`SPEC_WEBHOOK_TASK_TYPES`. This gate runs FIRST, before any target check. SDK-internal, non-spec task types (e.g. ``finalize_proposal``, an interception of ``get_products`` in ``proposal_dispatch.py``) flow through ``_project_handoff`` like any async task but legitimately have no webhook target wired; per the spec-gate rule above :data:`SPEC_WEBHOOK_TASK_TYPES`, they skip delivery and rely on ``tasks/get`` polling / ``publishStatusChange``. Returning here before the ``target is None`` branch keeps a correctly-configured server from logging a spurious "silently dropped" WARNING on every async non-spec task. * The request didn't carry ``push_notification_config.url`` (polling-only via ``tasks/get`` — the spec permits this). Logs a WARNING when: * ``target`` is None but the buyer DID register a push config for a SPEC-eligible task type — their terminal notification is being silently dropped, the same misconfig the sync gate warns on. :param status: ``'completed'`` on success or ``'failed'`` on a terminal failure. The wire ``GeneratedTaskStatus`` enum. :param result: On success, the projected terminal artifact (the same shape persisted to the registry). On failure, the structured error wire dict (``error.to_wire()``) so the buyer sees the failure inline. ``operation_id`` is echoed verbatim from ``push_notification_config.operation_id`` and ``task_id`` is the registry-minted id. """ try: if not enabled: return # Spec gate FIRST — before any target / config inspection. Task # types outside the closed spec enum (SDK-internal interceptions # like ``finalize_proposal``) are not webhook-eligible; they skip # silently and rely on ``tasks/get`` / ``publishStatusChange``. # Running this ahead of the ``target is None`` branch is what # stops a correctly-configured server from emitting a spurious # "silently dropped" WARNING on every async non-spec task. The # sync emitter (:func:`maybe_emit_sync_completion`) gates the # same way. if method_name not in SPEC_WEBHOOK_TASK_TYPES: return config = getattr(params, "push_notification_config", None) if config is None and isinstance(params, dict): config = params.get("push_notification_config") if config is None: return # buyer didn't register — polling-only, nothing to do if target is None: # Buyer registered a push config but no sender / supervisor is # wired. Without this branch the terminal notification quietly # disappears — surfacing a warning gives the adopter a fast # path to the misconfig (mirrors the sync gate). try: url_for_log = getattr(config, "url", None) if url_for_log is None and isinstance(config, dict): url_for_log = config.get("url") except Exception: url_for_log = None logger.warning( "[adcp.decisioning] buyer registered push_notification_config " "(url=%s) for async %s (task_id=%s) but neither webhook_sender " "nor webhook_supervisor is wired — terminal %s webhook silently " "dropped. Pass one to " "adcp.decisioning.serve.create_adcp_server_from_platform, or set " "auto_emit_completion_webhooks=False to silence this warning.", url_for_log if url_for_log else "<unextractable>", method_name, task_id, status, ) return extracted = _extract_push_notification_url_and_token(params) if extracted is None: return url, token = extracted operation_id = _extract_push_operation_id(params) # Defense-in-depth: strip credentials from the artifact BEFORE the # webhook target sees it. The dispatcher already strips before # persisting to the registry (:func:`_project_handoff`); this is a # second pass at the delivery boundary. Method-gated — non-account # tools short-circuit without walking the result. Failure payloads # (error wire dicts) never carry credentials but pass through the # same gate harmlessly. if result is not None: result = strip_credentials_from_wire_result(method_name, result) await target.send_mcp( url=url, task_id=task_id, status=status, task_type=method_name, result=result, operation_id=operation_id, token=token, ) except Exception: # Logged-and-swallowed: the background task's terminal state is # already recorded in the registry; the buyer can read it via # tasks/get regardless of webhook delivery outcome. logger.warning( "[adcp.decisioning] terminal %s webhook for async %s " "(task_id=%s) failed; registry terminal state already recorded", status, method_name, task_id, exc_info=True, )Deliver the terminal completion / failure webhook for an async task.
Fired from the BACKGROUND completion path of :func:
adcp.decisioning.dispatch._project_handoff— once, after the registry has recorded the terminal state. This is the async-path counterpart to :func:maybe_emit_sync_completion(): when a seller returns aSubmittedenvelope (the request handed off to a task) AND the buyer suppliedpush_notification_config, the spec (AdCP, adcp#5389) requires the seller to deliver at least the terminal completion / failure notification to that webhook. Buyers who registered a push config get notified without pollingtasks/get.Unlike the sync gate, this coroutine is already running inside the background task — there is no inline buyer response to protect, so the delivery is awaited directly rather than scheduled fire-and- forget. The whole body is wrapped in
try/except Exceptionand logged-and-swallowed: a webhook delivery failure must never crash the background task or block the registry's terminal-state record (which the buyer can still read viatasks/get).Skips silently when:
enabledis False (operator opted out viaauto_emit_completion_webhooks=False— they emit manually).method_nameisn't in :data:SPEC_WEBHOOK_TASK_TYPES. This gate runs FIRST, before any target check. SDK-internal, non-spec task types (e.g.finalize_proposal, an interception ofget_productsinproposal_dispatch.py) flow through_project_handofflike any async task but legitimately have no webhook target wired; per the spec-gate rule above :data:SPEC_WEBHOOK_TASK_TYPES, they skip delivery and rely ontasks/getpolling /publishStatusChange. Returning here before thetarget is Nonebranch keeps a correctly-configured server from logging a spurious "silently dropped" WARNING on every async non-spec task.- The request didn't carry
push_notification_config.url(polling-only viatasks/get— the spec permits this).
Logs a WARNING when:
targetis None but the buyer DID register a push config for a SPEC-eligible task type — their terminal notification is being silently dropped, the same misconfig the sync gate warns on.
:param status:
'completed'on success or'failed'on a terminal failure. The wireGeneratedTaskStatusenum. :param result: On success, the projected terminal artifact (the same shape persisted to the registry). On failure, the structured error wire dict (error.to_wire()) so the buyer sees the failure inline.operation_idis echoed verbatim frompush_notification_config.operation_idandtask_idis the registry-minted id. def maybe_emit_sync_completion(*,
sender: WebhookSender | None,
enabled: bool,
method_name: str,
params: Any,
result: Any,
supervisor: WebhookDeliverySupervisor | None = None) ‑> None-
Expand source code
def maybe_emit_sync_completion( *, sender: WebhookSender | None, enabled: bool, method_name: str, params: Any, result: Any, supervisor: WebhookDeliverySupervisor | None = None, ) -> None: """Fire-and-forget auto-emit gate. Called by handler shims after the sync-success arm of mutating tools. Skips silently when: * ``enabled`` is False (operator opted out). * The request didn't carry ``push_notification_config.url``. Logs a WARNING when: * ``sender`` is None but the buyer DID register ``push_notification_config.url`` — the buyer's notification registration is being silently dropped, which the adopter almost certainly didn't intend. Wire ``webhook_sender`` into :func:`adcp.decisioning.serve` or pass ``auto_emit_completion_webhooks=False`` to silence this. * ``method_name`` isn't in :data:`SPEC_WEBHOOK_TASK_TYPES` (the adopter extended the tool surface beyond the spec enum). Schedules the actual delivery via the running event loop's ``create_task`` so the sync response path is non-blocking. **Exception isolation.** The gate runs AFTER the platform method's successful return. ANY exception in here — extraction quirk on a weird ``params`` shape, ``loop.create_task`` failure — must NOT propagate to the handler shim, which would lose the buyer's sync response. The whole body is wrapped in ``try/except Exception``; logged-and-swallowed. """ try: if not enabled: return # Cheap pre-check: did the buyer register ANY # ``push_notification_config``? Done BEFORE the full # extraction so the sender=None warning fires even on weird # ``params`` shapes that would have made # ``_extract_push_notification_url_and_token`` raise. The # outer ``try/except Exception`` would otherwise swallow such # extraction errors and we'd reproduce the very silent-drop # behavior this gate is supposed to eliminate. config = getattr(params, "push_notification_config", None) if config is None and isinstance(params, dict): config = params.get("push_notification_config") if config is None: return # buyer didn't register — nothing to do target = supervisor or sender if target is None: # Buyer registered a webhook config but the adopter didn't # wire a sender. Without this branch, the buyer's # notification quietly disappears — they think they # registered for completion webhooks and just never # receive any. Surfacing a warning on first call gives # the adopter a fast path to the misconfig. # # Try to surface the URL for actionable error context; # fall back to the config repr when extraction raises # mid-traversal (still better than silent skip). try: url_for_log = getattr(config, "url", None) if url_for_log is None and isinstance(config, dict): url_for_log = config.get("url") except Exception: url_for_log = None logger.warning( "[adcp.decisioning] buyer registered " "push_notification_config (url=%s) for %s but auto-emit " "has neither webhook_sender nor webhook_supervisor — " "webhook silently dropped. Pass one to " "adcp.decisioning.serve.create_adcp_server_from_platform, " "or set auto_emit_completion_webhooks=False to silence " "this warning.", url_for_log if url_for_log else "<unextractable>", method_name, ) return extracted = _extract_push_notification_url_and_token(params) if extracted is None: return url, token = extracted # Defense-in-depth: strip credentials from the result BEFORE the # webhook target sees it. The dispatcher already strips on the # synchronous return path (:func:`_invoke_platform_method`); # this is a second pass so the strip fires regardless of how # the result reached this gate (direct adopter call, custom # shim, future plumbing). Method-gated — non-account tools # short-circuit without walking the result. result = strip_credentials_from_wire_result(method_name, result) if method_name not in SPEC_WEBHOOK_TASK_TYPES: logger.warning( "[adcp.decisioning] sync completion webhook for %s skipped — " "tool not in spec task-type enum (closed set per " "schemas/cache/enums/task-type.json). Use " "publishStatusChange for long-running %s state.", method_name, method_name, ) return try: loop = asyncio.get_running_loop() except RuntimeError: # Production code that lands here is mis-wired (handler # shim called outside an event loop); bump to warning so # it's visible. Cost of one warning per misuse is # negligible vs. the cost of silent webhook loss. logger.warning( "[adcp.decisioning] sync completion webhook for %s " "skipped — no running event loop. The handler shim is " "expected to run inside an asyncio task; this branch " "fires when sync test code calls into the handler " "outside ``asyncio.run`` or ``pytest.mark.asyncio``.", method_name, ) return bg = loop.create_task( _emit_sync_completion_webhook( target=target, url=url, token=token, method_name=method_name, result=result, ), name=f"adcp-sync-completion-{method_name}", ) _BACKGROUND_WEBHOOK_TASKS.add(bg) bg.add_done_callback(_BACKGROUND_WEBHOOK_TASKS.discard) except Exception: # Last-line defense: an unexpected exception in the gate # itself (extraction quirk, scheduler error) must never # propagate to the handler shim, which has already produced # a successful sync response for the buyer. logger.warning( "[adcp.decisioning] sync completion webhook gate raised " "for %s; sync response unaffected", method_name, exc_info=True, )Fire-and-forget auto-emit gate. Called by handler shims after the sync-success arm of mutating tools.
Skips silently when:
enabledis False (operator opted out).- The request didn't carry
push_notification_config.url.
Logs a WARNING when:
senderis None but the buyer DID registerpush_notification_config.url— the buyer's notification registration is being silently dropped, which the adopter almost certainly didn't intend. Wirewebhook_senderinto :func:serve()or passauto_emit_completion_webhooks=Falseto silence this.method_nameisn't in :data:SPEC_WEBHOOK_TASK_TYPES(the adopter extended the tool surface beyond the spec enum).
Schedules the actual delivery via the running event loop's
create_taskso the sync response path is non-blocking.Exception isolation. The gate runs AFTER the platform method's successful return. ANY exception in here — extraction quirk on a weird
paramsshape,loop.create_taskfailure — must NOT propagate to the handler shim, which would lose the buyer's sync response. The whole body is wrapped intry/except Exception; logged-and-swallowed. def validate_webhook_sender_for_platform(*,
advertised_tools: frozenset[str] | set[str],
sender: Any,
auto_emit: bool,
supervisor: Any = None) ‑> None-
Expand source code
def validate_webhook_sender_for_platform( *, advertised_tools: frozenset[str] | set[str], sender: Any, auto_emit: bool, supervisor: Any = None, ) -> None: """Server-boot fail-fast for the F12 misconfig (Emma sales-direct P0 root cause). When an adopter claims a specialism whose tool surface includes any spec-eligible webhook task type (e.g., ``create_media_buy``, ``activate_signal``, ``acquire_rights``) AND auto-emit is on AND neither ``webhook_sender`` nor ``webhook_supervisor`` is wired, every buyer who registers ``push_notification_config.url`` would have their notification silently dropped. The runtime gate at :func:`maybe_emit_sync_completion` warns on the FIRST call, but by then the buyer has already burned a request and the adopter has shipped without webhook wiring. This validator surfaces the misconfig at server boot — same posture as ``dispatch.validate_platform``'s governance opt-in gate. Keeps the runtime warning as the second line of defense (covers tool surfaces that can't be statically resolved). :raises AdcpError: ``code='INVALID_REQUEST'`` when the configuration would silently drop webhooks. Matches the exception class :func:`validate_platform` raises for sibling boot-time misconfigs (governance opt-in, missing required methods) so adopter ``except AdcpError`` clauses catch all platform-config failures uniformly. """ if not auto_emit: return if sender is not None or supervisor is not None: return eligible = SPEC_WEBHOOK_TASK_TYPES & set(advertised_tools) if not eligible: return from adcp.decisioning.types import AdcpError raise AdcpError( "INVALID_REQUEST", message=( "auto_emit_completion_webhooks is enabled and the platform's " "claimed specialisms expose webhook-eligible tools " 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." ), recovery="terminal", details={ "missing": "webhook_sender_or_supervisor", "webhook_eligible_tools": sorted(eligible), }, )Server-boot fail-fast for the F12 misconfig (Emma sales-direct P0 root cause).
When an adopter claims a specialism whose tool surface includes any spec-eligible webhook task type (e.g.,
create_media_buy,activate_signal,acquire_rights) AND auto-emit is on AND neitherwebhook_sendernorwebhook_supervisoris wired, every buyer who registerspush_notification_config.urlwould have their notification silently dropped. The runtime gate at :func:maybe_emit_sync_completion()warns on the FIRST call, but by then the buyer has already burned a request and the adopter has shipped without webhook wiring.This validator surfaces the misconfig at server boot — same posture as
dispatch.validate_platform's governance opt-in gate. Keeps the runtime warning as the second line of defense (covers tool surfaces that can't be statically resolved).:raises AdcpError:
code='INVALID_REQUEST'when the configuration would silently drop webhooks. Matches the exception class :func:validate_platformraises for sibling boot-time misconfigs (governance opt-in, missing required methods) so adopterexcept AdcpErrorclauses catch all platform-config failures uniformly. def validate_webhook_signing_for_capabilities(*,
capabilities: DecisioningCapabilities,
sender: WebhookSender | None,
supervisor: WebhookDeliverySupervisor | None = None) ‑> None-
Expand source code
def validate_webhook_signing_for_capabilities( *, capabilities: DecisioningCapabilities, sender: WebhookSender | None, supervisor: WebhookDeliverySupervisor | None = None, ) -> None: """Server-boot fail-fast for the #384 capabilities-vs-wiring invariant. When the platform's :class:`DecisioningCapabilities` declares ``webhook_signing.supported=True``, the AdCP capabilities schema binds the seller to producing RFC 9421 ``Signature`` headers on EVERY outbound webhook — the schema description on the ``supported`` field reads "When false or absent, ... receivers MUST NOT expect a Signature header," so by contrapositive when ``true`` they MUST. There is no per-delivery opt-out in AdCP 3.x; ``legacy_hmac_fallback`` is a downgrade switch for receivers that have NOT adopted RFC 9421, not a substitute for the seller's RFC 9421 capability. The wired :class:`~adcp.webhook_sender.WebhookSender` MUST therefore be configured with a JWK signing key whose ``alg`` is also present in the advertised ``algorithms`` list. A bearer-only or HMAC sender, or a JWK sender whose alg is not advertised, would emit deliveries that conformant verifiers reject — silent blackout for any buyer enforcing RFC 9421. The check keys on the capability advertisement, not on ``reporting_delivery_methods=["webhook"]``: 3.x explicitly permits HMAC/Bearer-only delivery via ``legacy_hmac_fallback``, so the delivery-method axis is a poor gate. ``webhook_signing.supported`` is the self-consistency contract the spec supports directly. Sender resolution: this validator introspects the supervisor's ``_sender`` attribute when ``sender`` is ``None`` — both :class:`~adcp.webhook_supervisor.InMemoryWebhookDeliverySupervisor` and :class:`~adcp.webhook_supervisor_pg.PgWebhookDeliverySupervisor` expose it. Custom Protocol-only supervisors without an introspectable sender log a WARNING and skip validation; operators wiring those impls own the contract themselves but the gap is observable in boot logs. :raises AdcpError: ``code='INVALID_REQUEST'`` when capabilities declare RFC 9421 signing support but no sender (or a non-JWK sender, or a JWK sender whose alg doesn't match the advertised algorithms) is wired. Matches the recovery posture of sibling boot-time validators (terminal). """ webhook_signing = getattr(capabilities, "webhook_signing", None) if webhook_signing is None or not getattr(webhook_signing, "supported", False): return adopter_managed = getattr(capabilities, "webhook_signing_managed_externally", False) from adcp.decisioning.types import AdcpError if not isinstance(adopter_managed, bool): raise AdcpError( "INVALID_REQUEST", message=( "DecisioningCapabilities.webhook_signing_managed_externally " "must be a bool. Non-bool values are rejected so a mistyped " "configuration cannot bypass SDK webhook-signing validation." ), recovery="terminal", details={ "field": "webhook_signing_managed_externally", "value_type": type(adopter_managed).__name__, }, ) if adopter_managed is True and sender is None and supervisor is None: logger.info( "[adcp.decisioning] capabilities.webhook_signing.supported=True " "and DecisioningCapabilities.webhook_signing_managed_externally=True; " "skipping SDK WebhookSender validation. Operator owns the RFC 9421 " "delivery contract for outbound webhooks." ) return resolved_sender: Any = sender if resolved_sender is None and supervisor is not None: # Both reference supervisors store the underlying WebhookSender # on ``_sender``. Custom Protocol-only impls (Celery/Kafka # queue-only adopters) may not — log a WARNING so the gap is # observable in boot logs, then skip rather than fail-noisy on # an unknowable structure. resolved_sender = getattr(supervisor, "_sender", None) if resolved_sender is None: logger.warning( "[adcp.decisioning] capabilities.webhook_signing.supported=True " "but supervisor %s has no introspectable _sender attribute; " "boot validator cannot verify the wired sender produces RFC 9421 " "headers. Operator owns the contract — confirm out-of-band that " "outbound deliveries from this supervisor carry Signature / " "Signature-Input.", type(supervisor).__name__, ) return if resolved_sender is None: raise AdcpError( "INVALID_REQUEST", message=( "capabilities.webhook_signing.supported=True declares this " "platform signs outbound webhooks per RFC 9421, but neither " "webhook_sender nor webhook_supervisor was wired. Buyers " "enforcing RFC 9421 verification on inbound webhooks would " "see every delivery from this seller fail signature check. " "Either wire a WebhookSender via WebhookSender.from_jwk(...) " "or WebhookSender.from_pem(...), or remove " "webhook_signing.supported from the capabilities declaration." ), recovery="terminal", details={ "missing": "webhook_sender_with_rfc9421_key", "capabilities_webhook_signing_supported": True, }, ) if not getattr(resolved_sender, "signs_with_rfc9421", False): raise AdcpError( "INVALID_REQUEST", message=( "capabilities.webhook_signing.supported=True declares this " "platform signs outbound webhooks per RFC 9421, but the " "wired WebhookSender is not configured for JWK signing " "(bearer-token, AdCP-legacy HMAC, and Standard-Webhooks " "HMAC senders do not produce RFC 9421 Signature / " "Signature-Input headers). Reconstruct the sender via " "WebhookSender.from_jwk(...) or WebhookSender.from_pem(...), " "or remove webhook_signing.supported from the capabilities " "declaration if this seller does not in fact sign per " "RFC 9421." ), recovery="terminal", details={ "missing": "webhook_sender_with_rfc9421_key", "capabilities_webhook_signing_supported": True, "sender_auth_mode": type(getattr(resolved_sender, "_auth", None)).__name__, }, ) # Cross-check the wired sender's signature algorithm against the # advertised set. A seller declaring ``algorithms=["ed25519"]`` and # wiring an ES256 sender would emit deliveries pinned verifiers # reject — same silent-blackout failure mode the supported-check # closes, one axis deeper. ``algorithms`` is optional on the wire; # skip the cross-check when omitted (no advertisement to violate). advertised_algorithms = getattr(webhook_signing, "algorithms", None) if advertised_algorithms: sender_alg = getattr(getattr(resolved_sender, "_auth", None), "alg", None) advertised_alg_values = [getattr(a, "value", a) for a in advertised_algorithms] if sender_alg not in advertised_alg_values: raise AdcpError( "INVALID_REQUEST", message=( "capabilities.webhook_signing.algorithms advertises " f"{advertised_alg_values!r} but the wired WebhookSender " f"signs with {sender_alg!r}. Buyers pinning their RFC 9421 " "verifier to the advertised algorithms reject every " "delivery whose Signature-Input ``alg=`` is outside the " "set. Align the sender's alg with the capability " "declaration, or widen ``algorithms`` to include the " "sender's value." ), recovery="terminal", details={ "missing": "webhook_signing_algorithm_alignment", "advertised_algorithms": advertised_alg_values, "sender_alg": sender_alg, }, )Server-boot fail-fast for the #384 capabilities-vs-wiring invariant.
When the platform's :class:
DecisioningCapabilitiesdeclareswebhook_signing.supported=True, the AdCP capabilities schema binds the seller to producing RFC 9421Signatureheaders on EVERY outbound webhook — the schema description on thesupportedfield reads "When false or absent, … receivers MUST NOT expect a Signature header," so by contrapositive whentruethey MUST. There is no per-delivery opt-out in AdCP 3.x;legacy_hmac_fallbackis a downgrade switch for receivers that have NOT adopted RFC 9421, not a substitute for the seller's RFC 9421 capability.The wired :class:
~adcp.webhook_sender.WebhookSenderMUST therefore be configured with a JWK signing key whosealgis also present in the advertisedalgorithmslist. A bearer-only or HMAC sender, or a JWK sender whose alg is not advertised, would emit deliveries that conformant verifiers reject — silent blackout for any buyer enforcing RFC 9421.The check keys on the capability advertisement, not on
reporting_delivery_methods=["webhook"]: 3.x explicitly permits HMAC/Bearer-only delivery vialegacy_hmac_fallback, so the delivery-method axis is a poor gate.webhook_signing.supportedis the self-consistency contract the spec supports directly.Sender resolution: this validator introspects the supervisor's
_senderattribute whensenderisNone— both :class:~adcp.webhook_supervisor.InMemoryWebhookDeliverySupervisorand :class:~adcp.webhook_supervisor_pg.PgWebhookDeliverySupervisorexpose it. Custom Protocol-only supervisors without an introspectable sender log a WARNING and skip validation; operators wiring those impls own the contract themselves but the gap is observable in boot logs.:raises AdcpError:
code='INVALID_REQUEST'when capabilities declare RFC 9421 signing support but no sender (or a non-JWK sender, or a JWK sender whose alg doesn't match the advertised algorithms) is wired. Matches the recovery posture of sibling boot-time validators (terminal).