Module adcp.webhooks
Webhook creation, signing, and reception for AdCP agents.
Single front door for both senders and receivers. Underlying modules in
adcp.signing.webhook_* and adcp.webhook_receiver are implementation
details kept for internal organization — prefer the re-exports here for
stability.
Which sender helper to use
- :func:
deliver()— one-shot dispatch for legacyauthentication(Bearer or HMAC-SHA256). Collapses the sender's 6-step boilerplate into one call and signs the exact bytes it POSTs. Deprecated with AdCP 4.0; emits a :class:DeprecationWarning. - :class:
WebhookSender— the AdCP 4.0 default. RFC 9421 signing, shared connection pool, byte-identical replay via :meth:WebhookSender.resend(). Use this for any new integration. - :func:
create_mcp_webhook_payload()/ :func:create_a2a_webhook_payload()plus :func:get_adcp_signed_headers_for_webhook()— low-level path for callers who need full control over serialization, headers, or retry logic.
Functions
async def challenge_webhook_destination(*,
url: str | AnyUrl,
account_id: str,
subscriber_id: str,
sender: WebhookSender | None = None,
authentication: AdCPBaseModel | Mapping[str, Any] | None = None,
challenge: str | None = None,
timeout_seconds: float | None = None,
policy: WebhookDestinationPolicy | None = None,
field: str | None = None,
extra_headers: Mapping[str, str] | None = None) ‑> WebhookChallengeResult-
Expand source code
async def challenge_webhook_destination( *, url: str | AnyUrl, account_id: str, subscriber_id: str, sender: WebhookSender | None = None, authentication: AdCPBaseModel | Mapping[str, Any] | None = None, challenge: str | None = None, timeout_seconds: float | None = None, policy: WebhookDestinationPolicy | None = None, field: str | None = None, extra_headers: Mapping[str, str] | None = None, ) -> WebhookChallengeResult: """Validate and prove control of a durable webhook destination. Use before activating a new or changed active ``sync_accounts.accounts[].notification_configs[]`` entry. Inactive configs can be persisted without calling this helper. ``authentication`` follows the durable config's legacy auth selector: when present, the challenge is sent with Bearer or HMAC-SHA256. When omitted, pass an RFC 9421 :class:`WebhookSender`; the helper uses that sender's webhook-signing key and the SDK-managed pinned transport. """ error_url = str(url) if isinstance(url, (str, AnyUrl)) else None if sender is not None and authentication is not None: raise WebhookChallengeError( "pass either sender= for RFC 9421 or authentication= for legacy auth, not both", reason="ambiguous_auth_mode", field=field, url=error_url, ) sender_owns_client = bool(getattr(cast(Any, sender), "_owns_client", False)) sender_transport_hooks = tuple(getattr(cast(Any, sender), "_transport_hooks", ())) if sender is not None and not sender_owns_client: raise WebhookChallengeError( "proof-of-control requires a WebhookSender constructed without client=", reason="unsafe_sender_client", field=field, url=error_url, ) if sender is not None and sender_transport_hooks: raise WebhookChallengeError( "proof-of-control does not support sender transport_hooks", reason="unsupported_sender_hooks", field=field, url=error_url, ) if sender is not None and not sender.signs_with_rfc9421: raise WebhookChallengeError( "proof-of-control requires an RFC 9421 WebhookSender when authentication is omitted", reason="sender_auth_mode_mismatch", field=field, url=error_url, suggestion=( "Use WebhookSender.from_jwk(...) for default durable configs, " "or pass config.authentication for legacy Bearer/HMAC configs." ), ) if sender is None and authentication is None: raise WebhookChallengeError( "webhook challenge requires sender= when authentication is omitted", reason="sender_required", field=field, url=error_url, suggestion=( "Pass the seller's WebhookSender, or pass config.authentication " "for legacy auth." ), ) try: destination = validate_webhook_destination_url(url, policy=policy, field=field) payload = create_webhook_challenge_payload( account_id=account_id, subscriber_id=subscriber_id, challenge=challenge, ) except WebhookDestinationValidationError as exc: raise WebhookChallengeError( str(exc), reason=exc.reason, field=exc.field, url=exc.url, suggestion=exc.suggestion, ) from exc except ValueError as exc: raise WebhookChallengeError( f"webhook challenge configuration is invalid: {exc}", reason="invalid_configuration", field=field, url=error_url, ) from exc challenge_value = payload["challenge"] try: if sender is not None: effective_timeout = ( timeout_seconds if timeout_seconds is not None else float(getattr(cast(Any, sender), "_timeout", _DEFAULT_TIMEOUT_SECONDS)) ) response = await _send_sender_webhook_challenge( url=destination.effective_url, sender=sender, payload=payload, timeout_seconds=effective_timeout, policy=destination.policy, extra_headers=extra_headers, ) status_code = response.status_code response_headers = dict(response.headers) response_body = response.content else: auth_config = _authentication_to_config(cast(Any, authentication)) response = await _send_legacy_webhook_challenge( url=destination.effective_url, authentication=auth_config, payload=payload, extra_headers=extra_headers, timeout_seconds=timeout_seconds, policy=destination.policy, ) status_code = response.status_code response_headers = dict(response.headers) response_body = response.content except httpx.TimeoutException as exc: raise WebhookChallengeError( "webhook challenge timed out", reason="timeout", field=field, url=destination.original_url, ) from exc except httpx.HTTPError as exc: raise WebhookChallengeError( f"webhook challenge request failed: {exc}", reason="request_failed", field=field, url=destination.original_url, ) from exc except ValueError as exc: raise WebhookChallengeError( f"webhook challenge configuration is invalid: {exc}", reason="invalid_configuration", field=field, url=destination.original_url, ) from exc if not 200 <= status_code < 300: raise WebhookChallengeError( f"webhook challenge failed with HTTP {status_code}", reason="http_status", field=field, url=destination.original_url, status_code=status_code, ) echoed_field = validate_webhook_challenge_response( response_body, challenge=challenge_value, field=field, url=destination.original_url, ) return WebhookChallengeResult( challenge=challenge_value, echoed_field=echoed_field, destination=destination, status_code=status_code, response_headers=response_headers, response_body=response_body, )Validate and prove control of a durable webhook destination.
Use before activating a new or changed active
sync_accounts.accounts[].notification_configs[]entry. Inactive configs can be persisted without calling this helper.authenticationfollows the durable config's legacy auth selector: when present, the challenge is sent with Bearer or HMAC-SHA256. When omitted, pass an RFC 9421 :class:WebhookSender; the helper uses that sender's webhook-signing key and the SDK-managed pinned transport. def create_a2a_webhook_payload(task_id: str,
status: GeneratedTaskStatus,
context_id: str,
result: PydanticBaseModel | dict[str, Any],
timestamp: datetime | None = None) ‑> a2a_pb2.Task | a2a_pb2.TaskStatusUpdateEvent-
Expand source code
def create_a2a_webhook_payload( task_id: str, status: GeneratedTaskStatus, context_id: str, result: PydanticBaseModel | dict[str, Any], timestamp: datetime | None = None, ) -> Task | TaskStatusUpdateEvent: """ Create A2A webhook payload (Task or TaskStatusUpdateEvent). Per A2A specification: - Terminated statuses (completed, failed, canceled, rejected): Returns Task with artifacts[].parts[] - Intermediate statuses (working, input-required, submitted, auth-required): Returns TaskStatusUpdateEvent with status.message.parts[] This function helps agent implementations construct properly formatted A2A webhook payloads for sending to clients. Args: task_id: Unique identifier for the task status: Current task status context_id: Session/conversation identifier (required by A2A protocol) timestamp: When the webhook was generated (defaults to current UTC time) result: Task-specific payload — any Pydantic model or plain dict Returns: Task object for terminated statuses, TaskStatusUpdateEvent for intermediate statuses Examples: Create a completed Task webhook: >>> from adcp.webhooks import create_a2a_webhook_payload >>> from adcp.types import GeneratedTaskStatus >>> >>> task = create_a2a_webhook_payload( ... task_id="task_123", ... context_id="ctx_123", ... status=GeneratedTaskStatus.completed, ... result={"products": [...]}, ... ) >>> # task is a Task object with artifacts containing the result Create a working status update: >>> event = create_a2a_webhook_payload( ... task_id="task_456", ... context_id="ctx_456", ... status=GeneratedTaskStatus.working, ... result={"current_step": "processing", "percentage": 30}, ... ) >>> # event is a TaskStatusUpdateEvent with status.message Send A2A webhook via HTTP POST: >>> import httpx >>> from a2a.types import Task >>> >>> payload = create_a2a_webhook_payload(...) >>> # Serialize to dict for JSON >>> if isinstance(payload, Task): ... payload_dict = payload.model_dump(mode='json') ... else: ... payload_dict = payload.model_dump(mode='json') >>> >>> response = await httpx.post(webhook_url, json=payload_dict) """ if timestamp is None: timestamp = datetime.now(timezone.utc) # Convert datetime to ISO string for A2A protocol timestamp_str = timestamp.isoformat() if isinstance(timestamp, datetime) else timestamp timestamp_proto = _isoformat_to_proto_timestamp(timestamp_str) if timestamp_str else None # Map GeneratedTaskStatus to A2A TaskState enum value. # GeneratedTaskStatus is always an Enum so .value is guaranteed. status_value = status.value adcp_to_task_state: dict[str, int] = { "completed": pb.TaskState.TASK_STATE_COMPLETED, "failed": pb.TaskState.TASK_STATE_FAILED, "canceled": pb.TaskState.TASK_STATE_CANCELED, "rejected": pb.TaskState.TASK_STATE_REJECTED, "working": pb.TaskState.TASK_STATE_WORKING, "submitted": pb.TaskState.TASK_STATE_SUBMITTED, # GeneratedTaskStatus enum values are hyphenated ("input-required", # "auth-required"). The underscore forms are accepted as a convenience # for callers passing raw strings rather than enum members. "input_required": pb.TaskState.TASK_STATE_INPUT_REQUIRED, "input-required": pb.TaskState.TASK_STATE_INPUT_REQUIRED, "auth_required": pb.TaskState.TASK_STATE_AUTH_REQUIRED, "auth-required": pb.TaskState.TASK_STATE_AUTH_REQUIRED, } task_state_enum = adcp_to_task_state.get(status_value) if task_state_enum is None: # Falling back to TASK_STATE_UNSPECIFIED (proto3 zero) would be # silently omitted by MessageToDict, producing an invalid wire # shape ``{"status": {}}`` that A2A v0.3 receivers reject as # missing the required ``state`` field. Fail loud at the builder # boundary so callers can't ship a broken envelope. known = [ "submitted", "working", "input-required", "completed", "canceled", "failed", "rejected", "auth-required", ] raise ValueError( f"create_a2a_webhook_payload: unknown status {status_value!r}. " f"Known AdCP→A2A states: {known}. " "Note: 'unknown' has no a2a-sdk 1.0 protobuf constant; build a " "Task manually and pass it through to_wire_dict if you need to " "emit that state." ) # Build parts for the message/artifact. parts: list[pb.Part] = [] # Convert Pydantic model to dict if needed if hasattr(result, "model_dump"): result_dict: dict[str, Any] = result.model_dump(mode="json") else: result_dict = result value = Value() ParseDict(result_dict, value) parts.append(pb.Part(data=value)) # Determine if this is a terminated status (Task) or intermediate (TaskStatusUpdateEvent). # canceled and rejected are terminal: the task will not continue. is_terminated = status in ( GeneratedTaskStatus.completed, GeneratedTaskStatus.failed, GeneratedTaskStatus.canceled, GeneratedTaskStatus.rejected, ) if is_terminated: status_kwargs: dict[str, Any] = {"state": task_state_enum} if timestamp_proto is not None: status_kwargs["timestamp"] = timestamp_proto task_status = pb.TaskStatus(**status_kwargs) artifacts = ( [ pb.Artifact( artifact_id=f"{task_id}_result", parts=parts, ) ] if parts else [] ) return pb.Task( id=task_id, status=task_status, artifacts=artifacts, context_id=context_id, ) # Intermediate status: build a Message carrying the parts and nest it # inside TaskStatus.message so the event mirrors the spec shape. message_obj = None if parts: message_obj = pb.Message( message_id=f"{task_id}_msg", role=pb.Role.ROLE_AGENT, parts=parts, ) status_kwargs = {"state": task_state_enum} if timestamp_proto is not None: status_kwargs["timestamp"] = timestamp_proto if message_obj is not None: status_kwargs["message"] = message_obj task_status = pb.TaskStatus(**status_kwargs) return pb.TaskStatusUpdateEvent( task_id=task_id, status=task_status, context_id=context_id, )Create A2A webhook payload (Task or TaskStatusUpdateEvent).
Per A2A specification: - Terminated statuses (completed, failed, canceled, rejected): Returns Task with artifacts[].parts[] - Intermediate statuses (working, input-required, submitted, auth-required): Returns TaskStatusUpdateEvent with status.message.parts[]
This function helps agent implementations construct properly formatted A2A webhook payloads for sending to clients.
Args
task_id- Unique identifier for the task
status- Current task status
context_id- Session/conversation identifier (required by A2A protocol)
timestamp- When the webhook was generated (defaults to current UTC time)
result- Task-specific payload — any Pydantic model or plain dict
Returns
Task object for terminated statuses, TaskStatusUpdateEvent for intermediate statuses
Examples
Create a completed Task webhook:
>>> from adcp.webhooks import create_a2a_webhook_payload >>> from adcp.types import GeneratedTaskStatus >>> >>> task = create_a2a_webhook_payload( ... task_id="task_123", ... context_id="ctx_123", ... status=GeneratedTaskStatus.completed, ... result={"products": [...]}, ... ) >>> # task is a Task object with artifacts containing the resultCreate a working status update:
>>> event = create_a2a_webhook_payload( ... task_id="task_456", ... context_id="ctx_456", ... status=GeneratedTaskStatus.working, ... result={"current_step": "processing", "percentage": 30}, ... ) >>> # event is a TaskStatusUpdateEvent with status.messageSend A2A webhook via HTTP POST:
>>> import httpx >>> from a2a.types import Task >>> >>> payload = create_a2a_webhook_payload(...) >>> # Serialize to dict for JSON >>> if isinstance(payload, Task): ... payload_dict = payload.model_dump(mode='json') ... else: ... payload_dict = payload.model_dump(mode='json') >>> >>> response = await httpx.post(webhook_url, json=payload_dict) def create_mcp_webhook_payload(task_id: str,
status: GeneratedTaskStatus | str,
task_type: TaskType | str,
*,
result: PydanticBaseModel | dict[str, Any] | None = None,
timestamp: datetime | None = None,
operation_id: str | None = None,
message: str | None = None,
context_id: str | None = None,
protocol: AdcpProtocol | str | None = None,
idempotency_key: str | None = None,
token: str | None = None) ‑> adcp.types.generated_poc.core.mcp_webhook_payload.McpWebhookPayload-
Expand source code
def create_mcp_webhook_payload( task_id: str, status: GeneratedTaskStatus | str, task_type: TaskType | str, *, result: PydanticBaseModel | dict[str, Any] | None = None, timestamp: datetime | None = None, operation_id: str | None = None, message: str | None = None, context_id: str | None = None, protocol: AdcpProtocol | str | None = None, idempotency_key: str | None = None, token: str | None = None, ) -> McpWebhookPayload: """ Build an :class:`McpWebhookPayload` for a tracked async task. Pair with :func:`to_wire_dict` for HTTP transport — Pydantic-typed at construction so the publisher catches schema drift before it leaves the process. ``task_type`` is restricted to the closed :class:`TaskType` enum (the spec's complete set of async/tracked operations). Passing a value not present in the enum produces a validation error before an invalid webhook payload can leave the process. Args: task_id: Unique identifier for the task. status: Current task status. task_type: Type of AdCP async operation (see :class:`TaskType`). result: Task-specific payload — any Pydantic model or plain dict. Plain dicts are validated against :class:`AdcpAsyncResponseData`'s discriminated union. timestamp: When the webhook was generated. Defaults to current UTC. operation_id: Client-generated identifier the buyer embedded in the webhook URL when registering push-notification config. Publishers MUST echo this back so buyers correlate notifications without parsing URL paths. message: Human-readable summary of task state. context_id: Session/conversation identifier. protocol: AdCP protocol this task belongs to (see :class:`AdcpProtocol`). Auto-derived from ``task_type`` when omitted, matching the JS SDK's ``protocolForTool`` so cross-SDK bodies classify operations identically. Pass an explicit value to override. idempotency_key: Sender-generated key stable across retries of the same event. Defaults to a freshly-generated UUID v4 — callers retrying delivery of the same event MUST pass the key from their first attempt; passing None twice mints two keys and defeats dedup. token: Buyer-supplied token from ``push_notification_config.token``, echoed back per spec for authenticity validation. Returns: :class:`McpWebhookPayload` instance. Use :func:`to_wire_dict` (or ``payload.model_dump(mode="json", exclude_none=True)``) to get the JSON-ready dict for HTTP transport. Examples: Create a completed webhook with results: >>> from adcp.webhooks import create_mcp_webhook_payload, to_wire_dict >>> from adcp.types import GeneratedTaskStatus >>> >>> payload = create_mcp_webhook_payload( ... task_id="task_123", ... status=GeneratedTaskStatus.completed, ... task_type="create_media_buy", ... result={"media_buy_id": "mb_1", "buyer_ref": "ref_1"}, ... message="Created campaign" ... ) >>> wire = to_wire_dict(payload) Create a failed webhook with error: >>> payload = create_mcp_webhook_payload( ... task_id="task_456", ... status=GeneratedTaskStatus.failed, ... task_type="create_media_buy", ... result={"errors": [{"code": "INVALID_INPUT", "message": "..."}]}, ... message="Validation failed" ... ) Create a working status update: >>> payload = create_mcp_webhook_payload( ... task_id="task_789", ... status=GeneratedTaskStatus.working, ... task_type="sync_creatives", ... message="Processing 3 of 10 creatives" ... ) """ if timestamp is None: timestamp = datetime.now(timezone.utc) if idempotency_key is None: idempotency_key = generate_webhook_idempotency_key() status_value = status.value if hasattr(status, "value") else str(status) # Auto-derive `protocol` from `task_type` when caller doesn't override. # Matches `protocolForTool` in the JS reference SDK so cross-SDK bodies # classify operations identically. if protocol is None: try: task_type_enum = task_type if isinstance(task_type, TaskType) else TaskType(task_type) except ValueError: # Unknown string — let `model_validate` raise the canonical # task_type error below rather than swallow it here. task_type_enum = None if task_type_enum is not None: protocol = _TASK_TYPE_TO_PROTOCOL.get(task_type_enum) # Foreign BaseModel subclasses (anything outside AdcpAsyncResponseData) # don't match the discriminated-union variants by identity — dump to a # dict so the union picks by shape, matching the dict path. result_value: PydanticBaseModel | dict[str, Any] | None if isinstance(result, PydanticBaseModel): result_value = result.model_dump(mode="json") else: result_value = result payload = McpWebhookPayload.model_validate( { "idempotency_key": idempotency_key, "task_id": task_id, "task_type": task_type, "protocol": protocol, "status": status_value, "timestamp": timestamp, "operation_id": operation_id, "message": message, "context_id": context_id, "token": token, } ) # Preserve task result payloads byte-for-byte. Validating through the # generated AdcpAsyncResponseData union can coerce arbitrary dicts into # typed response models and inject response defaults, changing webhook # bodies before signing. payload.result = result_value # type: ignore[assignment] return payloadBuild an :class:
McpWebhookPayloadfor a tracked async task.Pair with :func:
to_wire_dict()for HTTP transport — Pydantic-typed at construction so the publisher catches schema drift before it leaves the process.task_typeis restricted to the closed :class:TaskTypeenum (the spec's complete set of async/tracked operations). Passing a value not present in the enum produces a validation error before an invalid webhook payload can leave the process.Args
task_id- Unique identifier for the task.
status- Current task status.
task_type- Type of AdCP async operation (see :class:
TaskType). result- Task-specific payload — any Pydantic model or plain dict.
Plain dicts are validated against
:class:
AdcpAsyncResponseData's discriminated union. timestamp- When the webhook was generated. Defaults to current UTC.
operation_id- Client-generated identifier the buyer embedded in the webhook URL when registering push-notification config. Publishers MUST echo this back so buyers correlate notifications without parsing URL paths.
message- Human-readable summary of task state.
context_id- Session/conversation identifier.
protocol- AdCP protocol this task belongs to (see :class:
AdcpProtocol). Auto-derived fromtask_typewhen omitted, matching the JS SDK'sprotocolForToolso cross-SDK bodies classify operations identically. Pass an explicit value to override. idempotency_key- Sender-generated key stable across retries of the same event. Defaults to a freshly-generated UUID v4 — callers retrying delivery of the same event MUST pass the key from their first attempt; passing None twice mints two keys and defeats dedup.
token- Buyer-supplied token from
push_notification_config.token, echoed back per spec for authenticity validation.
Returns
:class:
McpWebhookPayloadinstance. Use :func:to_wire_dict()(orpayload.model_dump(mode="json", exclude_none=True)) to get the JSON-ready dict for HTTP transport.Examples
Create a completed webhook with results:
>>> from adcp.webhooks import create_mcp_webhook_payload, to_wire_dict >>> from adcp.types import GeneratedTaskStatus >>> >>> payload = create_mcp_webhook_payload( ... task_id="task_123", ... status=GeneratedTaskStatus.completed, ... task_type="create_media_buy", ... result={"media_buy_id": "mb_1", "buyer_ref": "ref_1"}, ... message="Created campaign" ... ) >>> wire = to_wire_dict(payload)Create a failed webhook with error:
>>> payload = create_mcp_webhook_payload( ... task_id="task_456", ... status=GeneratedTaskStatus.failed, ... task_type="create_media_buy", ... result={"errors": [{"code": "INVALID_INPUT", "message": "..."}]}, ... message="Validation failed" ... )Create a working status update:
>>> payload = create_mcp_webhook_payload( ... task_id="task_789", ... status=GeneratedTaskStatus.working, ... task_type="sync_creatives", ... message="Processing 3 of 10 creatives" ... ) def create_webhook_challenge_payload(*, account_id: str, subscriber_id: str, challenge: str | None = None) ‑> dict[str, str]-
Expand source code
def create_webhook_challenge_payload( *, account_id: str, subscriber_id: str, challenge: str | None = None, ) -> dict[str, str]: """Build the durable ``notification_configs[]`` challenge payload.""" if not isinstance(account_id, str) or not account_id: raise ValueError("account_id must be a non-empty string") if not isinstance(subscriber_id, str) or not subscriber_id: raise ValueError("subscriber_id must be a non-empty string") challenge_value = generate_webhook_challenge_value() if challenge is None else challenge if not isinstance(challenge_value, str) or not challenge_value: raise ValueError("challenge must be a non-empty string") return { "type": "webhook.challenge", "challenge": challenge_value, "account_id": account_id, "subscriber_id": subscriber_id, }Build the durable
notification_configs[]challenge payload. async def deliver(config: AdCPBaseModel | Mapping[str, Any],
payload: AdCPBaseModel | Task | TaskStatusUpdateEvent | Mapping[str, Any],
*,
client: httpx.AsyncClient | None = None,
extra_headers: Mapping[str, str] | None = None,
timeout_seconds: float | None = None,
token_field: str | None = None,
allow_private: bool = False,
allowed_ports: frozenset[int] | None = None) ‑> httpx.Response-
Expand source code
async def deliver( config: AdCPBaseModel | Mapping[str, Any], payload: AdCPBaseModel | Task | TaskStatusUpdateEvent | Mapping[str, Any], *, client: httpx.AsyncClient | None = None, extra_headers: Mapping[str, str] | None = None, timeout_seconds: float | None = None, token_field: str | None = None, allow_private: bool = False, allowed_ports: frozenset[int] | None = None, ) -> httpx.Response: """Dispatch one legacy-auth webhook in a single call. Collapses the sender's six-step boilerplate (build envelope, serialize, sign, merge headers, POST, echo token) into one call so the signer and the wire see the *same bytes*. The serialization-format drift that plagued the hand-rolled path — ``json=`` in httpx re-serializes the dict and breaks ``Content-Digest`` — is structurally impossible here: the helper JSON-serializes once, signs those bytes, and POSTs those bytes via ``content=``. This helper is for the **legacy** AdCP 3.x authentication schemes (``Bearer`` / ``HMAC-SHA256``) and emits a :class:`DeprecationWarning` on first use. For 4.0+ integrations use :class:`WebhookSender` (RFC 9421). Args: config: A :class:`PushNotificationConfig`, :class:`ReportingWebhook`, or equivalent dict. Must carry ``url`` (``https://`` only) and ``authentication.{schemes, credentials}``. payload: The webhook body. Accepts a Pydantic model (e.g. built via :func:`create_mcp_webhook_payload` / :func:`create_a2a_webhook_payload`), an a2a ``Task`` / ``TaskStatusUpdateEvent``, or a plain dict. Models are dumped with ``mode="json", exclude_none=True``. client: Optional shared ``httpx.AsyncClient``. When supplied, the caller owns SSRF guarantees — the helper trusts the operator's transport completely (typically a vetted egress proxy with mTLS, or an ASGI transport for testing). When omitted, the helper builds a per-request :class:`adcp.signing.IpPinnedTransport` so the URL is resolved, SSRF-validated, and pinned to the resolved IP — same defense applied to :class:`WebhookSender`. allow_private: Forwarded to the per-request pinned transport (owned-client path only). ``False`` (default) rejects URLs whose resolved IP is in a private / loopback / link-local range. Set ``True`` for dev/CI fixtures that post to internal endpoints; production should leave it ``False``. allowed_ports: Forwarded to the per-request pinned transport (owned-client path only). ``None`` (default) imposes no port filter — AdCP doesn't constrain webhook ports. Hardened deployments pass :data:`adcp.signing.DEFAULT_ALLOWED_PORTS` (`{443, 8443}`) or a custom set. extra_headers: Merged last. May not override any of ``Content-Type``, ``Content-Digest``, ``Content-Length``, ``Host``, ``Authorization``, ``Signature``, ``Signature-Input``, ``X-AdCP-Signature``, or ``X-AdCP-Timestamp``. Auth and signature-binding headers are sender-owned so the signer and the wire cannot disagree. timeout_seconds: Per-request timeout applied only when the helper creates its own client. Raises ``ValueError`` if set alongside ``client=`` — configure the timeout on the shared client instead. token_field: Opt-in field name for echoing ``config.token`` into the payload body (top-level for MCP dicts, under ``metadata`` for ``Task`` / ``TaskStatusUpdateEvent``). Default ``None`` disables echo; there is no spec-defined field name, so the caller must pick one the receiver agrees to read. Returns: The raw ``httpx.Response``. Caller is responsible for ``response.status_code`` inspection and retry scheduling. For retry, pass the *same, unmutated* payload again — serialization is deterministic so retries produce byte-identical bodies (spec-correct receiver dedup via ``idempotency_key``). Mutating the payload dict between attempts breaks byte-identity; callers who need byte-identical HTTP envelopes across retries (including headers) should use :class:`WebhookSender` and :meth:`WebhookSender.resend`. There is intentionally no ``resend()`` here — the retry contract is "call ``deliver`` again with the same inputs". Raises: ValueError: missing ``url``, non-HTTPS URL, control characters in header values, missing / unknown ``authentication`` (use :class:`WebhookSender` for RFC 9421), overriding a reserved header, or setting ``timeout_seconds`` alongside ``client``. DeprecationWarning (fires once): ``authentication`` is a 3.x fallback. Security notes: * ``config.url`` is buyer-controlled. The helper enforces HTTPS, rejects control characters, AND (on the owned-client path) builds a per-request IP-pinned transport that runs the full SSRF range check (loopback / RFC 1918 / link-local / CGNAT / IPv6 ULA / multicast / cloud metadata) and pins the connection to the validated IP. Operator-supplied clients skip the SSRF guard — they own egress policy on their transport. * ``config.token`` sits in the request body, so any receiver that logs bodies retains it indefinitely. Treat the token as a medium-sensitivity correlator, not a long-lived secret. * At ``httpx`` DEBUG log level, ``Authorization`` and ``X-AdCP-Signature`` appear in logs — gate DEBUG in production. """ if client is not None and timeout_seconds is not None: raise ValueError( "timeout_seconds cannot be set when client= is provided; " "configure the timeout on your shared httpx.AsyncClient instead." ) url, token, auth_scheme, credentials = _extract_config_fields(config) if auth_scheme is None: raise ValueError( "config.authentication is required for deliver(). " "For RFC 9421 signing (the AdCP 4.0 default), use " "adcp.webhooks.WebhookSender — no helper for unsigned webhooks " "is provided because the spec requires signing." ) if auth_scheme not in ("Bearer", "HMAC-SHA256"): raise ValueError( f"unknown authentication scheme {auth_scheme!r}; " "supported legacy schemes are 'Bearer' and 'HMAC-SHA256'. " "For RFC 9421 use adcp.webhooks.WebhookSender." ) _warn_auth_deprecation_once() # Build the pinned transport up-front (owned-client path). SSRF # validation runs synchronously inside ``build_async_ip_pinned_transport`` # — a hostile URL raises ``SSRFValidationError`` before we serialize # the body or compute the HMAC, so a buyer-supplied 127.0.0.1 URL # does not produce an HMAC-over-buyer-body sitting in process memory # for fault-handlers / custom logging to capture on exception. # Mirrors the WebhookSender._send_bytes ordering. transport: Any = None if client is None: from adcp.signing.ip_pinned_transport import build_async_ip_pinned_transport transport = build_async_ip_pinned_transport( url, allow_private=allow_private, allowed_ports=allowed_ports, ) body_dict = to_wire_dict(payload) if token is not None and token_field is not None: _validate_header_value("config.token", token) _inject_push_token(body_dict, token, payload, token_field) # Compact separators so the signer and the wire see byte-identical # payloads, matching the canonical on-wire form pinned by # adcontextprotocol/adcp#2478. ``_compute_legacy_signature`` returns the # same compact body bytes below — we serialize here for the size check # and Bearer path, which both operate on the final transmitted bytes. body_bytes = json.dumps(body_dict, separators=(",", ":")).encode("utf-8") if len(body_bytes) > _MAX_BODY_BYTES: raise ValueError( f"serialized webhook body is {len(body_bytes):,} bytes, over the " f"{_MAX_BODY_BYTES:,}-byte cap. Split into smaller webhooks or use " "the batch-reporting endpoints — most receivers reject bodies over " "10MB at the reverse proxy anyway." ) headers: dict[str, str] = {"Content-Type": "application/json"} if auth_scheme == "Bearer": if not credentials: raise ValueError( "config.authentication.schemes=['Bearer'] requires " "authentication.credentials (min 32 characters — token " "exchanged out-of-band with the receiver)." ) _validate_header_value("authentication.credentials", credentials) headers["Authorization"] = f"Bearer {credentials}" else: # HMAC-SHA256 if not credentials: raise ValueError( "config.authentication.schemes=['HMAC-SHA256'] requires " "authentication.credentials (min 32 characters — shared " "secret exchanged out-of-band with the receiver)." ) _validate_header_value("authentication.credentials", credentials) get_adcp_signed_headers_for_webhook( headers, secret=credentials, timestamp=str(int(time.time())), payload=body_dict, ) if extra_headers: if len(extra_headers) > _MAX_EXTRA_HEADERS: raise ValueError( f"extra_headers has {len(extra_headers)} entries; " f"helper caps at {_MAX_EXTRA_HEADERS}. Pass only the custom " "headers you actually need (trace IDs, correlation IDs)." ) for key in extra_headers: normalized = str(key).lower() if normalized in _RESERVED_HEADERS or normalized.startswith(":"): raise ValueError(_reserved_header_message(normalized, key)) for key, value in extra_headers.items(): _validate_header_value(f"extra_headers[{key!r}]", value) headers[key] = value effective_timeout = timeout_seconds if timeout_seconds is not None else _DEFAULT_TIMEOUT_SECONDS if client is None: # Owned-client path. ``transport`` was built up-front so SSRF # rejected before signing; here we just construct the per-request # client. ``follow_redirects=False`` closes rebinding-via-redirect; # ``trust_env=False`` blocks ``HTTPS_PROXY`` env-var bypass. # Same shape as ``WebhookSender._send_bytes``. async with httpx.AsyncClient( transport=transport, timeout=effective_timeout, follow_redirects=False, trust_env=False, ) as http_client: return await http_client.post(url, content=body_bytes, headers=headers) # Operator-supplied client: trust them completely; they own SSRF # guarantees on their transport (vetted egress proxy, ASGI test # transport, etc.). return await client.post(url, content=body_bytes, headers=headers)Dispatch one legacy-auth webhook in a single call.
Collapses the sender's six-step boilerplate (build envelope, serialize, sign, merge headers, POST, echo token) into one call so the signer and the wire see the same bytes. The serialization-format drift that plagued the hand-rolled path —
json=in httpx re-serializes the dict and breaksContent-Digest— is structurally impossible here: the helper JSON-serializes once, signs those bytes, and POSTs those bytes viacontent=.This helper is for the legacy AdCP 3.x authentication schemes (
Bearer/HMAC-SHA256) and emits a :class:DeprecationWarningon first use. For 4.0+ integrations use :class:WebhookSender(RFC 9421).Args
config- A :class:
PushNotificationConfig, :class:ReportingWebhook, or equivalent dict. Must carryurl(https://only) andauthentication.{schemes, credentials}. payload- The webhook body. Accepts a Pydantic model (e.g. built via
:func:
create_mcp_webhook_payload()/ :func:create_a2a_webhook_payload()), an a2aTask/TaskStatusUpdateEvent, or a plain dict. Models are dumped withmode="json", exclude_none=True. client- Optional shared
httpx.AsyncClient. When supplied, the caller owns SSRF guarantees — the helper trusts the operator's transport completely (typically a vetted egress proxy with mTLS, or an ASGI transport for testing). When omitted, the helper builds a per-request :class:IpPinnedTransportso the URL is resolved, SSRF-validated, and pinned to the resolved IP — same defense applied to :class:WebhookSender. allow_private- Forwarded to the per-request pinned transport
(owned-client path only).
False(default) rejects URLs whose resolved IP is in a private / loopback / link-local range. SetTruefor dev/CI fixtures that post to internal endpoints; production should leave itFalse. allowed_ports- Forwarded to the per-request pinned transport
(owned-client path only).
None(default) imposes no port filter — AdCP doesn't constrain webhook ports. Hardened deployments pass :data:adcp.signing.DEFAULT_ALLOWED_PORTS({443, 8443}) or a custom set. extra_headers- Merged last. May not override any of
Content-Type,Content-Digest,Content-Length,Host,Authorization,Signature,Signature-Input,X-AdCP-Signature, orX-AdCP-Timestamp. Auth and signature-binding headers are sender-owned so the signer and the wire cannot disagree. timeout_seconds- Per-request timeout applied only when the helper
creates its own client. Raises
ValueErrorif set alongsideclient=— configure the timeout on the shared client instead. token_field- Opt-in field name for echoing
config.tokeninto the payload body (top-level for MCP dicts, undermetadataforTask/TaskStatusUpdateEvent). DefaultNonedisables echo; there is no spec-defined field name, so the caller must pick one the receiver agrees to read.
Returns
The raw
httpx.Response. Caller is responsible forresponse.status_codeinspection and retry scheduling. For retry, pass the same, unmutated payload again — serialization is deterministic so retries produce byte-identical bodies (spec-correct receiver dedup viaidempotency_key). Mutating the payload dict between attempts breaks byte-identity; callers who need byte-identical HTTP envelopes across retries (including headers) should use :class:WebhookSenderand :meth:WebhookSender.resend(). There is intentionally noresend()here — the retry contract is "calldeliver()again with the same inputs".Raises
ValueError- missing
url, non-HTTPS URL, control characters in header values, missing / unknownauthentication(use :class:WebhookSenderfor RFC 9421), overriding a reserved header, or settingtimeout_secondsalongsideclient.
DeprecationWarning (fires once):
authenticationis a 3.x fallback. Security notes: *config.urlis buyer-controlled. The helper enforces HTTPS, rejects control characters, AND (on the owned-client path) builds a per-request IP-pinned transport that runs the full SSRF range check (loopback / RFC 1918 / link-local / CGNAT / IPv6 ULA / multicast / cloud metadata) and pins the connection to the validated IP. Operator-supplied clients skip the SSRF guard — they own egress policy on their transport. *config.tokensits in the request body, so any receiver that logs bodies retains it indefinitely. Treat the token as a medium-sensitivity correlator, not a long-lived secret. * AthttpxDEBUG log level,AuthorizationandX-AdCP-Signatureappear in logs — gate DEBUG in production. def extract_webhook_result_data(webhook_payload: dict[str, Any]) ‑> dict[str, typing.Any] | None-
Expand source code
def extract_webhook_result_data(webhook_payload: dict[str, Any]) -> dict[str, Any] | None: """ Extract result data from webhook payload (MCP or A2A format). This utility function handles webhook payloads from both MCP and A2A protocols, extracting the result data regardless of the webhook format. Useful for quick inspection, logging, or custom webhook routing logic without requiring full client initialization. Protocol Detection: - A2A Task: Has "artifacts" field (terminated statuses: completed, failed, canceled, rejected) - A2A TaskStatusUpdateEvent: Has nested "status.message" structure (intermediate statuses) - MCP: Has "result" field directly Args: webhook_payload: Raw webhook dictionary from HTTP request (JSON-deserialized) Returns: dict[str, Any] containing the extracted AdCP response data, or None if no result is present. For A2A webhooks, unwraps data from artifacts/message parts structure. For MCP webhooks, returns the result field directly. Examples: Extract from MCP webhook: >>> mcp_payload = { ... "task_id": "task_123", ... "task_type": "create_media_buy", ... "status": "completed", ... "timestamp": "2025-01-15T10:00:00Z", ... "result": {"media_buy_id": "mb_123", "buyer_ref": "ref_123", "packages": []} ... } >>> result = extract_webhook_result_data(mcp_payload) >>> print(result["media_buy_id"]) mb_123 Extract from A2A Task webhook: >>> a2a_task_payload = { ... "id": "task_456", ... "context_id": "ctx_456", ... "status": {"state": "completed", "timestamp": "2025-01-15T10:00:00Z"}, ... "artifacts": [ ... { ... "artifact_id": "artifact_456", ... "parts": [ ... { ... "data": { ... "media_buy_id": "mb_456", ... "buyer_ref": "ref_456", ... "packages": [] ... } ... } ... ] ... } ... ] ... } >>> result = extract_webhook_result_data(a2a_task_payload) >>> print(result["media_buy_id"]) mb_456 Extract from A2A TaskStatusUpdateEvent webhook: >>> a2a_event_payload = { ... "task_id": "task_789", ... "context_id": "ctx_789", ... "status": { ... "state": "working", ... "timestamp": "2025-01-15T10:00:00Z", ... "message": { ... "message_id": "msg_789", ... "role": "agent", ... "parts": [ ... {"data": {"current_step": "processing", "percentage": 50}} ... ] ... } ... }, ... "final": False ... } >>> result = extract_webhook_result_data(a2a_event_payload) >>> print(result["percentage"]) 50 Handle webhook with no result: >>> empty_payload = {"task_id": "task_000", "status": "working", "timestamp": "..."} >>> result = extract_webhook_result_data(empty_payload) >>> print(result) None """ # Detect A2A Task format (has "artifacts" field) if "artifacts" in webhook_payload: # Extract from task.artifacts[].parts[] artifacts = webhook_payload.get("artifacts", []) if not artifacts: return None # Use last artifact (most recent) target_artifact = artifacts[-1] parts = target_artifact.get("parts", []) if not parts: return None # Find DataPart (skip TextPart) for part in parts: # Check if this part has "data" field (DataPart) if "data" in part: data = part["data"] # Unwrap {"response": {...}} wrapper if present (A2A convention) if isinstance(data, dict) and "response" in data and len(data) == 1: return cast(dict[str, Any], data["response"]) return cast(dict[str, Any], data) return None # Detect A2A TaskStatusUpdateEvent format (has nested "status.message") status = webhook_payload.get("status") if isinstance(status, dict): message = status.get("message") if isinstance(message, dict): # Extract from status.message.parts[] parts = message.get("parts", []) if not parts: return None # Find DataPart for part in parts: if "data" in part: data = part["data"] # Unwrap {"response": {...}} wrapper if present if isinstance(data, dict) and "response" in data and len(data) == 1: return cast(dict[str, Any], data["response"]) return cast(dict[str, Any], data) return None # MCP format: result field directly return cast(dict[str, Any] | None, webhook_payload.get("result"))Extract result data from webhook payload (MCP or A2A format).
This utility function handles webhook payloads from both MCP and A2A protocols, extracting the result data regardless of the webhook format. Useful for quick inspection, logging, or custom webhook routing logic without requiring full client initialization.
Protocol Detection: - A2A Task: Has "artifacts" field (terminated statuses: completed, failed, canceled, rejected) - A2A TaskStatusUpdateEvent: Has nested "status.message" structure (intermediate statuses) - MCP: Has "result" field directly
Args
webhook_payload- Raw webhook dictionary from HTTP request (JSON-deserialized)
Returns
dict[str, Any] containing the extracted AdCP response data, or None if no result is present. For A2A webhooks, unwraps data from artifacts/message parts structure. For MCP webhooks, returns the result field directly.
Examples
Extract from MCP webhook:
>>> mcp_payload = { ... "task_id": "task_123", ... "task_type": "create_media_buy", ... "status": "completed", ... "timestamp": "2025-01-15T10:00:00Z", ... "result": {"media_buy_id": "mb_123", "buyer_ref": "ref_123", "packages": []} ... } >>> result = extract_webhook_result_data(mcp_payload) >>> print(result["media_buy_id"]) mb_123Extract from A2A Task webhook:
>>> a2a_task_payload = { ... "id": "task_456", ... "context_id": "ctx_456", ... "status": {"state": "completed", "timestamp": "2025-01-15T10:00:00Z"}, ... "artifacts": [ ... { ... "artifact_id": "artifact_456", ... "parts": [ ... { ... "data": { ... "media_buy_id": "mb_456", ... "buyer_ref": "ref_456", ... "packages": [] ... } ... } ... ] ... } ... ] ... } >>> result = extract_webhook_result_data(a2a_task_payload) >>> print(result["media_buy_id"]) mb_456Extract from A2A TaskStatusUpdateEvent webhook:
>>> a2a_event_payload = { ... "task_id": "task_789", ... "context_id": "ctx_789", ... "status": { ... "state": "working", ... "timestamp": "2025-01-15T10:00:00Z", ... "message": { ... "message_id": "msg_789", ... "role": "agent", ... "parts": [ ... {"data": {"current_step": "processing", "percentage": 50}} ... ] ... } ... }, ... "final": False ... } >>> result = extract_webhook_result_data(a2a_event_payload) >>> print(result["percentage"]) 50Handle webhook with no result:
>>> empty_payload = {"task_id": "task_000", "status": "working", "timestamp": "..."} >>> result = extract_webhook_result_data(empty_payload) >>> print(result) None def generate_webhook_challenge_value() ‑> str-
Expand source code
def generate_webhook_challenge_value() -> str: """Generate an opaque random value for a proof-of-control challenge.""" return f"wch_{secrets.token_urlsafe(32)}"Generate an opaque random value for a proof-of-control challenge.
def generate_webhook_idempotency_key() ‑> str-
Expand source code
def generate_webhook_idempotency_key() -> str: """Generate a cryptographically random idempotency_key for a webhook event. Returns a UUID v4 prefixed with ``whk_`` — matches the example format in ``webhooks.mdx`` and stays within the spec's length + charset bounds (``^[A-Za-z0-9_.:-]{16,255}$``). Publishers MUST generate this once per distinct event and reuse the same value when retrying delivery. Do NOT call this function again on retry — it would mint a fresh UUID and defeat the dedup contract. """ return f"whk_{uuid.uuid4()}"Generate a cryptographically random idempotency_key for a webhook event.
Returns a UUID v4 prefixed with
whk_— matches the example format inwebhooks.mdxand stays within the spec's length + charset bounds (^[A-Za-z0-9_.:-]{16,255}$).Publishers MUST generate this once per distinct event and reuse the same value when retrying delivery. Do NOT call this function again on retry — it would mint a fresh UUID and defeat the dedup contract.
def get_adcp_signed_headers_for_webhook(headers: dict[str, Any],
secret: str,
timestamp: str | int | None,
payload: dict[str, Any] | AdCPBaseModel) ‑> dict[str, typing.Any]-
Expand source code
def get_adcp_signed_headers_for_webhook( headers: dict[str, Any], secret: str, timestamp: str | int | None, payload: dict[str, Any] | AdCPBaseModel, ) -> dict[str, Any]: """ Generate AdCP-compliant signed headers for webhook delivery. This function creates a cryptographic signature that proves the webhook came from an authorized agent and protects against replay attacks by including a timestamp in the signed message. The function adds two headers to the provided headers dict: - X-AdCP-Signature: HMAC-SHA256 signature in format "sha256=<hex_digest>" - X-AdCP-Timestamp: Unix timestamp in seconds The signing algorithm: 1. Constructs message as "{timestamp}.{json_payload}" 2. JSON-serializes payload with default separators (matches wire format from json= kwarg) 3. UTF-8 encodes the message 4. HMAC-SHA256 signs with the shared secret 5. Hex-encodes and prefixes with "sha256=" Args: headers: Existing headers dictionary to add signature headers to secret: Shared secret key for HMAC signing timestamp: Unix timestamp in seconds (str or int). If None, uses current time. payload: Webhook payload (dict or Pydantic model - will be JSON-serialized) Returns: The modified headers dictionary with signature headers added Examples: Sign and send an MCP webhook: >>> import time >>> from adcp.webhooks import create_mcp_webhook_payload >>> from adcp.webhooks import get_adcp_signed_headers_for_webhook >>> >>> payload = create_mcp_webhook_payload( ... task_id="task_123", ... status="completed", ... task_type="create_media_buy", ... result={"media_buy_id": "mb_1"}, ... ) >>> headers = {"Content-Type": "application/json"} >>> signed_headers = get_adcp_signed_headers_for_webhook( ... headers, secret="my-webhook-secret", timestamp=str(int(time.time())), ... payload=payload, ... ) >>> >>> # Send webhook with signed headers >>> import httpx >>> response = await httpx.post( ... webhook_url, ... json=payload, ... headers=signed_headers ... ) Headers will contain: >>> print(signed_headers) { "Content-Type": "application/json", "X-AdCP-Signature": "sha256=a1b2c3...", "X-AdCP-Timestamp": "1773185740" } """ signature_headers, _body_bytes = _compute_legacy_signature( secret=secret, timestamp=timestamp, payload=payload ) headers.update(signature_headers) return headersGenerate AdCP-compliant signed headers for webhook delivery.
This function creates a cryptographic signature that proves the webhook came from an authorized agent and protects against replay attacks by including a timestamp in the signed message.
The function adds two headers to the provided headers dict: - X-AdCP-Signature: HMAC-SHA256 signature in format "sha256=
" - X-AdCP-Timestamp: Unix timestamp in seconds The signing algorithm: 1. Constructs message as "{timestamp}.{json_payload}" 2. JSON-serializes payload with default separators (matches wire format from json= kwarg) 3. UTF-8 encodes the message 4. HMAC-SHA256 signs with the shared secret 5. Hex-encodes and prefixes with "sha256="
Args
headers- Existing headers dictionary to add signature headers to
secret- Shared secret key for HMAC signing
timestamp- Unix timestamp in seconds (str or int). If None, uses current time.
payload- Webhook payload (dict or Pydantic model - will be JSON-serialized)
Returns
The modified headers dictionary with signature headers added
Examples
Sign and send an MCP webhook:
>>> import time >>> from adcp.webhooks import create_mcp_webhook_payload >>> from adcp.webhooks import get_adcp_signed_headers_for_webhook >>> >>> payload = create_mcp_webhook_payload( ... task_id="task_123", ... status="completed", ... task_type="create_media_buy", ... result={"media_buy_id": "mb_1"}, ... ) >>> headers = {"Content-Type": "application/json"} >>> signed_headers = get_adcp_signed_headers_for_webhook( ... headers, secret="my-webhook-secret", timestamp=str(int(time.time())), ... payload=payload, ... ) >>> >>> # Send webhook with signed headers >>> import httpx >>> response = await httpx.post( ... webhook_url, ... json=payload, ... headers=signed_headers ... )Headers will contain:
>>> print(signed_headers) { "Content-Type": "application/json", "X-AdCP-Signature": "sha256=a1b2c3...", "X-AdCP-Timestamp": "1773185740" } def sign_legacy_webhook(secret: str,
payload: dict[str, Any] | AdCPBaseModel,
*,
timestamp: str | int | None = None,
headers: dict[str, Any] | None = None) ‑> tuple[dict[str, str], bytes]-
Expand source code
def sign_legacy_webhook( secret: str, payload: dict[str, Any] | AdCPBaseModel, *, timestamp: str | int | None = None, headers: dict[str, Any] | None = None, ) -> tuple[dict[str, str], bytes]: """Return ``(signed_headers, body_bytes)`` for a legacy HMAC webhook. Byte-equality between signature input and HTTP body is guaranteed — callers POST ``content=body_bytes`` instead of ``json=payload``, so the separator-drift trap that caused silent 401s in every spaced-vs-compact interop is structurally impossible here. This is a lower-level companion to :func:`deliver` for callers who need to own the HTTP transport themselves (custom auth, pre-configured ``httpx.AsyncClient``, non-httpx clients). For the one-shot "send a webhook" path, prefer :func:`deliver`. The returned ``body_bytes`` use compact separators (``","``/``":"``) matching the canonical on-wire form pinned by adcontextprotocol/adcp#2478. Example: >>> signed, body = sign_legacy_webhook("shared-secret", payload) >>> headers = {**signed, "Content-Type": "application/json"} >>> await client.post(url, content=body, headers=headers) """ signature_headers, body_bytes = _compute_legacy_signature( secret=secret, timestamp=timestamp, payload=payload ) if headers is not None: merged = {str(k): str(v) for k, v in headers.items()} merged.update(signature_headers) return merged, body_bytes return signature_headers, body_bytesReturn
(signed_headers, body_bytes)for a legacy HMAC webhook.Byte-equality between signature input and HTTP body is guaranteed — callers POST
content=body_bytesinstead ofjson=payload, so the separator-drift trap that caused silent 401s in every spaced-vs-compact interop is structurally impossible here.This is a lower-level companion to :func:
deliver()for callers who need to own the HTTP transport themselves (custom auth, pre-configuredhttpx.AsyncClient, non-httpx clients). For the one-shot "send a webhook" path, prefer :func:deliver().The returned
body_bytesuse compact separators (","/":") matching the canonical on-wire form pinned by adcontextprotocol/adcp#2478.Example
>>> signed, body = sign_legacy_webhook("shared-secret", payload) >>> headers = {**signed, "Content-Type": "application/json"} >>> await client.post(url, content=body, headers=headers) def sign_webhook(*,
method: str,
url: str,
headers: Mapping[str, str],
body: bytes,
private_key: PrivateKey,
key_id: str,
alg: str,
created: int | None = None,
expires_in_seconds: int = 300,
nonce: str | None = None,
label: str = 'sig1') ‑> SignedHeaders-
Expand source code
def sign_webhook( *, method: str, url: str, headers: Mapping[str, str], body: bytes, private_key: PrivateKey, key_id: str, alg: str, created: int | None = None, expires_in_seconds: int = DEFAULT_EXPIRES_IN_SECONDS, nonce: str | None = None, label: str = SIG_LABEL_DEFAULT, ) -> SignedHeaders: """Sign an outgoing webhook POST per adcp/webhook-signing/v1. ``cover_content_digest=True`` and ``tag=WEBHOOK_TAG`` are pinned. The caller attaches ``SignedHeaders.as_dict()`` to the outgoing HTTP request. The ``method`` is normally ``"POST"`` for webhook delivery; passed through unchanged so callers signing a retried ``PUT`` or variant delivery verb are not forced into an extra translation. See also: :class:`adcp.webhooks.WebhookSender` — higher-level one-call helper that builds the payload, signs, and POSTs in a single call. Prefer it unless you need to own the HTTP transport yourself. """ return sign_request( method=method, url=url, headers=headers, body=body, private_key=private_key, key_id=key_id, alg=alg, cover_content_digest=True, created=created, expires_in_seconds=expires_in_seconds, nonce=nonce, tag=WEBHOOK_TAG, label=label, )Sign an outgoing webhook POST per adcp/webhook-signing/v1.
cover_content_digest=Trueandtag=WEBHOOK_TAGare pinned. The caller attachesSignedHeaders.as_dict()to the outgoing HTTP request.The
methodis normally"POST"for webhook delivery; passed through unchanged so callers signing a retriedPUTor variant delivery verb are not forced into an extra translation.See also: :class:
WebhookSender— higher-level one-call helper that builds the payload, signs, and POSTs in a single call. Prefer it unless you need to own the HTTP transport yourself. def to_wire_dict(payload: AdCPBaseModel | Task | TaskStatusUpdateEvent | Mapping[str, Any]) ‑> dict[str, typing.Any]-
Expand source code
def to_wire_dict( payload: AdCPBaseModel | Task | TaskStatusUpdateEvent | Mapping[str, Any], ) -> dict[str, Any]: """Serialize any AdCP webhook payload to a JSON-ready dict. Single seam for adopters that accept "any AdCP webhook payload" — a sender wrapping :func:`create_a2a_webhook_payload` and :func:`create_mcp_webhook_payload` would otherwise have to write per-shape dispatch (``isinstance`` checks, ``MessageToDict`` for protobuf, ``model_dump`` for Pydantic, passthrough for dict). Brittle: a future a2a-sdk that swaps protobuf for a Pydantic façade silently changes which branch runs, and adopters duplicate the dispatch in every send path. Use this helper instead — the dispatch lives here. Behaviour by input shape: * a2a ``Task`` / ``TaskStatusUpdateEvent`` (protobuf, a2a-sdk 1.0+) → ``MessageToDict(..., preserving_proto_field_name=False)`` so JSON keys match the A2A wire spec (camelCase: ``id``, ``contextId``, ``artifactId``). Enum values are normalized from the 1.0 protobuf form (``TASK_STATE_COMPLETED``, ``ROLE_AGENT``) to the 0.3-spec lowercase form (``completed``, ``agent``) so 0.3 buyer receivers keep parsing. * Any Pydantic model (``McpWebhookPayload``, future Pydantic façades, :class:`AdCPBaseModel` subclasses) → ``model_dump(mode="json", exclude_none=True)``. * ``Mapping`` → coerced to ``dict``. Legacy adopter passthrough for callers that build the wire dict by hand. Raises: TypeError: payload is none of the above. """ if isinstance(payload, (Task, TaskStatusUpdateEvent)): data = MessageToDict(payload, preserving_proto_field_name=False) _normalize_a2a_task_state_to_v03(data) return data if hasattr(payload, "model_dump"): model = cast(AdCPBaseModel, payload) return model.model_dump(mode="json", exclude_none=True) if isinstance(payload, Mapping): return dict(payload) raise TypeError( f"Unsupported webhook payload type {type(payload).__name__}: expected " "a2a Task / TaskStatusUpdateEvent (protobuf), an AdCP Pydantic model " "(e.g. McpWebhookPayload), or a Mapping[str, Any]." )Serialize any AdCP webhook payload to a JSON-ready dict.
Single seam for adopters that accept "any AdCP webhook payload" — a sender wrapping :func:
create_a2a_webhook_payload()and :func:create_mcp_webhook_payload()would otherwise have to write per-shape dispatch (isinstancechecks,MessageToDictfor protobuf,model_dumpfor Pydantic, passthrough for dict). Brittle: a future a2a-sdk that swaps protobuf for a Pydantic façade silently changes which branch runs, and adopters duplicate the dispatch in every send path. Use this helper instead — the dispatch lives here.Behaviour by input shape:
- a2a
Task/TaskStatusUpdateEvent(protobuf, a2a-sdk 1.0+) →MessageToDict(..., preserving_proto_field_name=False)so JSON keys match the A2A wire spec (camelCase:id,contextId,artifactId). Enum values are normalized from the 1.0 protobuf form (TASK_STATE_COMPLETED,ROLE_AGENT) to the 0.3-spec lowercase form (completed,agent) so 0.3 buyer receivers keep parsing. - Any Pydantic model (
McpWebhookPayload, future Pydantic façades, :class:AdCPBaseModelsubclasses) →model_dump(mode="json", exclude_none=True). Mapping→ coerced todict. Legacy adopter passthrough for callers that build the wire dict by hand.
Raises
TypeError- payload is none of the above.
- a2a
def validate_webhook_challenge_response(response: bytes | Mapping[str, Any],
*,
challenge: str,
field: str | None = None,
url: str | None = None) ‑> str-
Expand source code
def validate_webhook_challenge_response( response: bytes | Mapping[str, Any], *, challenge: str, field: str | None = None, url: str | None = None, ) -> str: """Validate that a receiver echoed the challenge value. Receivers may respond with either ``{"challenge": "<value>"}`` or ``{"token": "<value>"}``. The return value is the field that matched. """ try: if isinstance(response, bytes): decoded = json.loads(response.decode("utf-8")) else: decoded = dict(response) except (UnicodeDecodeError, json.JSONDecodeError, TypeError, ValueError) as exc: raise WebhookChallengeError( "webhook challenge response must be a JSON object", reason="invalid_json", field=field, url=url, ) from exc if not isinstance(decoded, Mapping): raise WebhookChallengeError( "webhook challenge response must be a JSON object", reason="invalid_json", field=field, url=url, ) for key in ("challenge", "token"): value = decoded.get(key) if value == challenge: return key if "challenge" in decoded or "token" in decoded: reason = "challenge_mismatch" message = "webhook challenge response did not echo the expected value" else: reason = "missing_echo" message = "webhook challenge response must include 'challenge' or 'token'" raise WebhookChallengeError(message, reason=reason, field=field, url=url)Validate that a receiver echoed the challenge value.
Receivers may respond with either
{"challenge": "<value>"}or{"token": "<value>"}. The return value is the field that matched. def validate_webhook_destination_url(url: str | AnyUrl,
*,
policy: WebhookDestinationPolicy | None = None,
field: str | None = None) ‑> WebhookDestinationValidation-
Expand source code
def validate_webhook_destination_url( url: str | AnyUrl, *, policy: WebhookDestinationPolicy | None = None, field: str | None = None, ) -> WebhookDestinationValidation: """Validate a buyer webhook URL before storing it. The helper is the registration-time counterpart to ``WebhookSender``'s delivery-time SSRF guard. It applies optional transport hooks, enforces production HTTPS policy, resolves the destination once through the shared SSRF classifier, and returns the effective URL plus the validated IP. Sellers should normally persist ``original_url``. ``effective_url`` is for the immediate validation/delivery decision after transport hooks such as Docker localhost rewrites; do not persist a test-only rewrite as the buyer's registered URL. Raises :class:`WebhookDestinationValidationError` with structured fields sellers can map to ``INVALID_REQUEST`` protocol errors. """ active_policy = policy or WebhookDestinationPolicy.production() _validate_policy_hooks(active_policy) if isinstance(url, str): url_text = url else: url_text = str(url) if not url_text: _raise_webhook_destination_error( "webhook destination URL must be a non-empty string", reason="missing_url", field=field, url=url_text, effective_url=None, policy=active_policy, ) if any(c in url_text for c in _HEADER_FORBIDDEN_CHARS): _raise_webhook_destination_error( "webhook destination URL contains control characters", reason="control_characters", field=field, url=url_text, effective_url=None, policy=active_policy, ) try: effective_url = apply_hooks(url_text, active_policy.transport_hooks) except ValueError as exc: _raise_webhook_destination_error( f"webhook destination URL failed transport hook policy: {exc}", reason="transport_hook_rejected", field=field, url=url_text, effective_url=None, policy=active_policy, ) if any(c in effective_url for c in _HEADER_FORBIDDEN_CHARS): _raise_webhook_destination_error( "webhook destination URL contains control characters after transport hooks", reason="control_characters", field=field, url=url_text, effective_url=effective_url, policy=active_policy, ) parsed = urlsplit(effective_url) if parsed.username is not None or parsed.password is not None: _raise_webhook_destination_error( "webhook destination URL must not embed userinfo (user:pass@host)", reason="userinfo_not_allowed", field=field, url=url_text, effective_url=effective_url, policy=active_policy, suggestion="Pass credentials in webhook authentication settings instead of the URL.", ) if parsed.fragment: _raise_webhook_destination_error( "webhook destination URL must not include a fragment", reason="fragment_not_allowed", field=field, url=url_text, effective_url=effective_url, policy=active_policy, suggestion=( "Move routing state into the webhook path or query string; " "URL fragments are never sent in HTTP requests." ), ) if parsed.scheme not in ("http", "https"): _raise_webhook_destination_error( f"webhook destination URL must use http:// or https:// (got {parsed.scheme!r})", reason="invalid_scheme", field=field, url=url_text, effective_url=effective_url, policy=active_policy, ) if active_policy.require_https and parsed.scheme != "https": _raise_webhook_destination_error( f"webhook destination URL must use https:// under {active_policy.name} policy", reason="https_required", field=field, url=url_text, effective_url=effective_url, policy=active_policy, suggestion=( "Use an HTTPS webhook URL, or pass " "WebhookDestinationPolicy.local_development() for local tests." ), ) try: hostname, resolved_ip, port = resolve_and_validate_host( effective_url, allow_private=active_policy.allow_private_destinations, allowed_ports=active_policy.allowed_destination_ports, ) except SSRFValidationError as exc: _raise_webhook_destination_error( f"webhook destination URL failed SSRF validation: {exc}", reason="ssrf_rejected", field=field, url=url_text, effective_url=effective_url, policy=active_policy, ) return WebhookDestinationValidation( original_url=url_text, effective_url=effective_url, hostname=hostname, resolved_ip=resolved_ip, port=port, policy=active_policy, )Validate a buyer webhook URL before storing it.
The helper is the registration-time counterpart to
WebhookSender's delivery-time SSRF guard. It applies optional transport hooks, enforces production HTTPS policy, resolves the destination once through the shared SSRF classifier, and returns the effective URL plus the validated IP. Sellers should normally persistoriginal_url.effective_urlis for the immediate validation/delivery decision after transport hooks such as Docker localhost rewrites; do not persist a test-only rewrite as the buyer's registered URL.Raises :class:
WebhookDestinationValidationErrorwith structured fields sellers can map toINVALID_REQUESTprotocol errors. def verify_webhook_hmac(*,
headers: Mapping[str, str],
body: bytes,
options: LegacyWebhookHmacOptions) ‑> VerifiedLegacyWebhookSender-
Expand source code
def verify_webhook_hmac( *, headers: Mapping[str, str], body: bytes, options: LegacyWebhookHmacOptions, ) -> VerifiedLegacyWebhookSender: """Verify an HMAC-SHA256-signed webhook body per the legacy scheme. Raises :class:`LegacyWebhookHmacError` on any failure. Fires a one-time :class:`DeprecationWarning` — operators SHOULD migrate to 9421 before AdCP 4.0 removes the ``authentication`` field. ``headers`` can be any ``Mapping[str, str]`` — ``dict``, ``werkzeug.datastructures.EnvironHeaders``, Starlette's ``Headers``, etc. Keys are case-folded internally. """ _warn_once() header_map = {str(k).lower(): str(v) for k, v in headers.items()} sig_value = header_map.get(_SIGNATURE_HEADER) ts_value = header_map.get(_TIMESTAMP_HEADER) if sig_value is None or ts_value is None: raise LegacyWebhookHmacError("missing X-AdCP-Signature or X-AdCP-Timestamp header") if not sig_value.startswith(_HEX_PREFIX): raise LegacyWebhookHmacError( f"signature must start with {_HEX_PREFIX!r}, got {sig_value[:16]!r}" ) hex_sig = sig_value[len(_HEX_PREFIX) :] try: ts_int = int(ts_value) except ValueError as exc: raise LegacyWebhookHmacError(f"invalid timestamp {ts_value!r}") from exc # Bound on the skew window. Matches the 9421 max window (300s) exactly — # the 9421 pipeline applies DEFAULT_SKEW_SECONDS inside its window check, # so both schemes have the same "skew budget" on the wire. Do NOT add # DEFAULT_SKEW_SECONDS on top; that would double-count and yield a 360s # budget for HMAC vs 300s for 9421. skew = abs(options.now - ts_int) if skew > options.window_seconds: raise LegacyWebhookHmacError( f"timestamp skew {skew:.0f}s exceeds window {options.window_seconds}s" ) # The sender constructs the message as f"{timestamp}.{json_payload}" # where json_payload is the body bytes as UTF-8. Re-decoding a dict would # re-serialize with potentially different key order and break the # signature — verify against the raw bytes as received. message = f"{ts_value}.".encode() + body expected = hmac.new(options.secret, message, hashlib.sha256).hexdigest() # Constant-time compare — hmac.compare_digest handles str/str. if not hmac.compare_digest(expected, hex_sig): raise LegacyWebhookHmacError("signature did not match") return VerifiedLegacyWebhookSender(sender_identity=options.sender_identity)Verify an HMAC-SHA256-signed webhook body per the legacy scheme.
Raises :class:
LegacyWebhookHmacErroron any failure. Fires a one-time :class:DeprecationWarning— operators SHOULD migrate to 9421 before AdCP 4.0 removes theauthenticationfield.headerscan be anyMapping[str, str]—dict,werkzeug.datastructures.EnvironHeaders, Starlette'sHeaders, etc. Keys are case-folded internally. def verify_webhook_signature(*,
method: str,
url: str,
headers: Mapping[str, str],
body: bytes,
options: WebhookVerifyOptions) ‑> VerifiedWebhookSender-
Expand source code
def verify_webhook_signature( *, method: str, url: str, headers: Mapping[str, str], body: bytes, options: WebhookVerifyOptions, ) -> VerifiedWebhookSender: """Verify an incoming signed webhook per the adcp/webhook-signing/v1 profile. Raises :class:`SignatureVerificationError` with a ``webhook_signature_*`` code on failure. Success returns a :class:`VerifiedWebhookSender` carrying the identity to scope dedup state by. """ _precheck_webhook_has_required_components(headers) request_options = VerifyOptions( now=options.clock(), capability=VerifierCapability( supported=True, covers_content_digest="required", required_for=frozenset({"webhook"}), ), operation="webhook", jwks_resolver=options.jwks_resolver, replay_store=options.replay_store, revocation_checker=options.revocation_checker, revocation_list=options.revocation_list, max_skew_seconds=options.max_skew_seconds, max_window_seconds=options.max_window_seconds, label=options.label, expected_tag=WEBHOOK_TAG, expected_adcp_use=ADCP_USE_WEBHOOK, allowed_algs=options.allowed_algs, agent_url=options.sender_url, ) try: signer: VerifiedSigner = verify_request_signature( method=method, url=url, headers=headers, body=body, options=request_options ) except SignatureVerificationError as exc: raise _retag_to_webhook(exc) from exc return VerifiedWebhookSender( key_id=signer.key_id, alg=signer.alg, label=signer.label, verified_at=signer.verified_at, sender_url=signer.agent_url, )Verify an incoming signed webhook per the adcp/webhook-signing/v1 profile.
Raises :class:
SignatureVerificationErrorwith awebhook_signature_*code on failure. Success returns a :class:VerifiedWebhookSendercarrying the identity to scope dedup state by.
Classes
class DockerLocalhostRewrite (rewrite_to: str = 'host.docker.internal')-
Expand source code
@dataclass(frozen=True) class DockerLocalhostRewrite: """Rewrite ``localhost`` / ``127.0.0.1`` / ``::1`` to a Docker-host alias. Activated by adopters running e2e tests against host-side services from inside a Docker container. The default ``host.docker.internal`` works on Docker Desktop (Mac/Windows). On Linux, pass ``rewrite_to="172.17.0.1"`` (default bridge gateway) or ``rewrite_to="host.docker.internal"`` after adding ``--add-host=host.docker.internal:host-gateway`` to the container run. Construction-time validation: this hook is only meaningful when the sender has ``allow_private_destinations=True``. The construct method on the sender side checks the flag — a hook attached to a sender without it raises :class:`ValueError` so the misconfiguration surfaces at wiring time rather than at the first delivery. The check happens via :meth:`validate_for_sender`, called by :meth:`WebhookSender._from_strategy` (and ``__init__``) when ``transport_hooks`` is set. """ rewrite_to: str = "host.docker.internal" def rewrite_url(self, url: str) -> str | None: parsed = urlsplit(url) # ``hostname`` lower-cases and strips brackets from IPv6 — match # against both bare and bracketed forms above. host = (parsed.hostname or "").lower() if host not in _LOCALHOST_HOSTS: return None # Reassemble with the rewritten host, preserving port, path, # query, fragment. Userinfo (``user:pass@``) is intentionally # dropped — webhook URLs in AdCP do not carry credentials in the # URL, and ``_extract_config_fields`` rejects userinfo upstream. # If a future caller needs it, propagate ``parsed.username`` / # ``parsed.password`` here. netloc = self.rewrite_to if parsed.port is not None: netloc = f"{self.rewrite_to}:{parsed.port}" return urlunsplit((parsed.scheme, netloc, parsed.path, parsed.query, parsed.fragment)) def validate_for_sender(self, *, allow_private_destinations: bool) -> None: """Reject misconfiguration at sender-construction time. Without ``allow_private_destinations=True``, SSRF would reject the post-rewrite URL — silently making this hook a no-op at best, confusing failure at worst. Raise. """ if not allow_private_destinations: raise ValueError( "DockerLocalhostRewrite requires the sender to be constructed " "with allow_private_destinations=True. The hook rewrites " "localhost to a private-IP destination; SSRF would reject the " "rewritten URL otherwise. Pass allow_private_destinations=True " "to opt in explicitly, or remove the hook for production senders." )Rewrite
localhost/127.0.0.1/::1to a Docker-host alias.Activated by adopters running e2e tests against host-side services from inside a Docker container. The default
host.docker.internalworks on Docker Desktop (Mac/Windows). On Linux, passrewrite_to="172.17.0.1"(default bridge gateway) orrewrite_to="host.docker.internal"after adding--add-host=host.docker.internal:host-gatewayto the container run.Construction-time validation: this hook is only meaningful when the sender has
allow_private_destinations=True. The construct method on the sender side checks the flag — a hook attached to a sender without it raises :class:ValueErrorso the misconfiguration surfaces at wiring time rather than at the first delivery.The check happens via :meth:
validate_for_sender, called by :meth:WebhookSender._from_strategy(and__init__) whentransport_hooksis set.Instance variables
var rewrite_to : str
Methods
def rewrite_url(self, url: str) ‑> str | None-
Expand source code
def rewrite_url(self, url: str) -> str | None: parsed = urlsplit(url) # ``hostname`` lower-cases and strips brackets from IPv6 — match # against both bare and bracketed forms above. host = (parsed.hostname or "").lower() if host not in _LOCALHOST_HOSTS: return None # Reassemble with the rewritten host, preserving port, path, # query, fragment. Userinfo (``user:pass@``) is intentionally # dropped — webhook URLs in AdCP do not carry credentials in the # URL, and ``_extract_config_fields`` rejects userinfo upstream. # If a future caller needs it, propagate ``parsed.username`` / # ``parsed.password`` here. netloc = self.rewrite_to if parsed.port is not None: netloc = f"{self.rewrite_to}:{parsed.port}" return urlunsplit((parsed.scheme, netloc, parsed.path, parsed.query, parsed.fragment)) def validate_for_sender(self, *, allow_private_destinations: bool) ‑> None-
Expand source code
def validate_for_sender(self, *, allow_private_destinations: bool) -> None: """Reject misconfiguration at sender-construction time. Without ``allow_private_destinations=True``, SSRF would reject the post-rewrite URL — silently making this hook a no-op at best, confusing failure at worst. Raise. """ if not allow_private_destinations: raise ValueError( "DockerLocalhostRewrite requires the sender to be constructed " "with allow_private_destinations=True. The hook rewrites " "localhost to a private-IP destination; SSRF would reject the " "rewritten URL otherwise. Pass allow_private_destinations=True " "to opt in explicitly, or remove the hook for production senders." )Reject misconfiguration at sender-construction time.
Without
allow_private_destinations=True, SSRF would reject the post-rewrite URL — silently making this hook a no-op at best, confusing failure at worst. Raise.
class LegacyHmacFallback (options_for: Callable[[Mapping[str, str]], LegacyWebhookHmacOptions | None],
only_when_9421_absent: bool = True)-
Expand source code
@dataclass(frozen=True) class LegacyHmacFallback: """Opt-in policy for accepting HMAC-SHA256 senders during 3.x migration. The default behavior of the receiver is to reject any request that fails 9421 verification. Pass an instance of this class to ``WebhookReceiverConfig`` to accept HMAC-signed webhooks as a fallback. :param options_for: callback that returns a populated :class:`LegacyWebhookHmacOptions` given the incoming request headers. Your implementation resolves the sender (from Bearer, hostname, or legacy shared-secret tag) and returns the secret + sender_identity tuple the verifier needs. Return ``None`` to decline the fallback for this request (rejection follows the 9421-only failure path). :param only_when_9421_absent: when ``True`` (default), HMAC fallback only fires when no 9421 headers are present at all. When a request carries 9421 headers that FAIL verification, it still rejects — preventing a downgrade attack where a MITM strips the 9421 signature and replaces it with a forged HMAC one it knows the secret for. When ``False``, HMAC is tried on any 9421 failure; only set this for testing or known homogenous sender cohorts. """ options_for: Callable[[Mapping[str, str]], LegacyWebhookHmacOptions | None] only_when_9421_absent: bool = True @classmethod def from_shared_secret( cls, *, secret: bytes, sender_identity: str, only_when_9421_absent: bool = True, window_seconds: int = 300, ) -> LegacyHmacFallback: """Convenience constructor for the "one secret, one sender" case. Covers the common 3.x migration setup where the receiver has exactly one publisher on the legacy scheme and binds them to a known ``sender_identity`` (typically a buyer-defined string). For multi- sender or header-derived-identity setups, construct with an ``options_for`` callback directly. """ import time as _time def _options_for(_headers: Mapping[str, str]) -> LegacyWebhookHmacOptions: return LegacyWebhookHmacOptions( secret=secret, sender_identity=sender_identity, now=_time.time(), window_seconds=window_seconds, ) return cls( options_for=_options_for, only_when_9421_absent=only_when_9421_absent, )Opt-in policy for accepting HMAC-SHA256 senders during 3.x migration.
The default behavior of the receiver is to reject any request that fails 9421 verification. Pass an instance of this class to
WebhookReceiverConfigto accept HMAC-signed webhooks as a fallback.:param options_for: callback that returns a populated :class:
LegacyWebhookHmacOptionsgiven the incoming request headers. Your implementation resolves the sender (from Bearer, hostname, or legacy shared-secret tag) and returns the secret + sender_identity tuple the verifier needs. ReturnNoneto decline the fallback for this request (rejection follows the 9421-only failure path). :param only_when_9421_absent: whenTrue(default), HMAC fallback only fires when no 9421 headers are present at all. When a request carries 9421 headers that FAIL verification, it still rejects — preventing a downgrade attack where a MITM strips the 9421 signature and replaces it with a forged HMAC one it knows the secret for. WhenFalse, HMAC is tried on any 9421 failure; only set this for testing or known homogenous sender cohorts.Static methods
-
Convenience constructor for the "one secret, one sender" case.
Covers the common 3.x migration setup where the receiver has exactly one publisher on the legacy scheme and binds them to a known
sender_identity(typically a buyer-defined string). For multi- sender or header-derived-identity setups, construct with anoptions_forcallback directly.
Instance variables
var only_when_9421_absent : boolvar options_for : Callable[[Mapping[str, str]], LegacyWebhookHmacOptions | None]
-
class LegacyWebhookHmacError (reason: str)-
Expand source code
class LegacyWebhookHmacError(Exception): """Raised when HMAC-SHA256 legacy verification fails. Distinct from :class:`adcp.signing.errors.SignatureVerificationError` so callers can distinguish legacy-path failures from 9421-path failures — operators want to know which scheme fired when diagnosing a 401. """ def __init__(self, reason: str) -> None: super().__init__(reason) self.reason = reasonRaised when HMAC-SHA256 legacy verification fails.
Distinct from :class:
SignatureVerificationErrorso callers can distinguish legacy-path failures from 9421-path failures — operators want to know which scheme fired when diagnosing a 401.Ancestors
- builtins.Exception
- builtins.BaseException
class LegacyWebhookHmacOptions (secret: bytes, sender_identity: str, now: float, window_seconds: int = 300)-
Expand source code
@dataclass(frozen=True) class LegacyWebhookHmacOptions: """Options for the HMAC verifier. :param secret_resolver: callable ``(header_map) -> bytes | None`` that returns the shared secret for this incoming request. The receiver is responsible for determining sender identity from headers (Bearer token, IP allowlist, hostname) and looking up the secret bound to that sender. The resolver returns ``None`` when no sender can be authenticated — the verifier then rejects without attempting compare. :param sender_identity: the string used to scope dedup after verification succeeds. In HMAC-legacy, there is no cryptographic sender identity (the secret IS the identity), so the caller provides one — typically derived from the same lookup that produced the secret. :param now: current time, epoch seconds. Defaults fetched at call time. :param window_seconds: accepted skew. Sender timestamp outside ``[now - window, now + window]`` rejects. """ secret: bytes sender_identity: str now: float window_seconds: int = _DEFAULT_WINDOW_SECONDSOptions for the HMAC verifier.
:param secret_resolver: callable
(header_map) -> bytes | Nonethat returns the shared secret for this incoming request. The receiver is responsible for determining sender identity from headers (Bearer token, IP allowlist, hostname) and looking up the secret bound to that sender. The resolver returnsNonewhen no sender can be authenticated — the verifier then rejects without attempting compare. :param sender_identity: the string used to scope dedup after verification succeeds. In HMAC-legacy, there is no cryptographic sender identity (the secret IS the identity), so the caller provides one — typically derived from the same lookup that produced the secret. :param now: current time, epoch seconds. Defaults fetched at call time. :param window_seconds: accepted skew. Sender timestamp outside[now - window, now + window]rejects.Instance variables
var now : floatvar secret : bytesvar sender_identity : strvar window_seconds : int
class MemoryBackend (*, clock: Callable[[], float] = <built-in function time>)-
Expand source code
class MemoryBackend(IdempotencyBackend): """In-process dict-backed store. Suitable for tests, single-process reference implementations, and local development. **Not suitable for multi-process deployments** — each worker has its own cache, so a retry that lands on a different worker is treated as a fresh request. Thread safety: the backend uses an :class:`asyncio.Lock` to serialize mutations of the shared dict. Reads go through the lock too; for a pure in-process backend this is cheap and prevents torn reads across concurrent ``get``/``put`` interleaving. :param clock: Callable returning the current epoch seconds. Override for tests that need to advance time deterministically without monkeypatching :mod:`time`. Defaults to :func:`time.time`. """ def __init__(self, *, clock: Callable[[], float] = time.time) -> None: self._store: dict[tuple[str, str], CachedResponse] = {} self._lock = asyncio.Lock() self._clock = clock async def get(self, scope_key: str, key: str) -> CachedResponse | None: async with self._lock: entry = self._store.get((scope_key, key)) if entry is None: return None if entry.expires_at_epoch <= self._clock(): # Lazy expiry — drop the stale entry so the next request # treats the slot as fresh and races to repopulate. del self._store[(scope_key, key)] return None return entry async def put( self, scope_key: str, key: str, entry: CachedResponse, ) -> None: async with self._lock: self._store[(scope_key, key)] = entry async def delete_expired(self, now_epoch: float | None = None) -> int: cutoff = now_epoch if now_epoch is not None else self._clock() async with self._lock: stale = [k for k, v in self._store.items() if v.expires_at_epoch <= cutoff] for k in stale: del self._store[k] return len(stale) async def clear(self) -> None: """Remove all cached entries. Test-suite hook — handy for resetting state between fixtures when a single :class:`MemoryBackend` is shared across multiple tests. """ async with self._lock: self._store.clear() async def _size(self) -> int: """Test-only: return the current entry count.""" async with self._lock: return len(self._store)In-process dict-backed store.
Suitable for tests, single-process reference implementations, and local development. Not suitable for multi-process deployments — each worker has its own cache, so a retry that lands on a different worker is treated as a fresh request.
Thread safety: the backend uses an :class:
asyncio.Lockto serialize mutations of the shared dict. Reads go through the lock too; for a pure in-process backend this is cheap and prevents torn reads across concurrentget/putinterleaving.:param clock: Callable returning the current epoch seconds. Override for tests that need to advance time deterministically without monkeypatching :mod:
time. Defaults to :func:time.time.Ancestors
- IdempotencyBackend
- abc.ABC
Methods
async def clear(self) ‑> None-
Expand source code
async def clear(self) -> None: """Remove all cached entries. Test-suite hook — handy for resetting state between fixtures when a single :class:`MemoryBackend` is shared across multiple tests. """ async with self._lock: self._store.clear()Remove all cached entries.
Test-suite hook — handy for resetting state between fixtures when a single :class:
MemoryBackendis shared across multiple tests.
Inherited members
class PgWebhookDeliverySupervisor (pool: Any,
sender: WebhookSender | None,
*,
retry: RetryPolicy | None = None,
circuit: CircuitBreakerPolicy | None = None,
log_sink: DeliveryLogSink | None = None,
circuit_table: str = 'adcp_webhook_circuit_state',
queue_table: str = 'adcp_webhook_delivery_queue',
log_table: str = 'adcp_webhook_delivery_log')-
Expand source code
class PgWebhookDeliverySupervisor: """Postgres-backed :class:`~adcp.webhook_supervisor.WebhookDeliverySupervisor`. Parameters ---------- pool: A ``psycopg_pool.AsyncConnectionPool`` owned by the caller. Each operation acquires a short-lived connection (or holds one for the duration of a delivery). We don't open, own, or close the pool. sender: The underlying :class:`~adcp.webhook_sender.WebhookSender` used for the actual HTTP-Signatures POST. Must be non-None. retry: Retry policy (default: 3 attempts, exponential backoff with jitter). circuit: Circuit-breaker tuning (default: 5 failures open, 60s recovery, 2 successes close). log_sink: Optional :class:`~adcp.webhook_supervisor.DeliveryLogSink` called after each attempt. Failures are swallowed. Use for custom observability pipelines in addition to the built-in log table. circuit_table / queue_table / log_table: Override table names (ASCII only, 1–63 chars). Useful for multi- tenant schemas. Defaults are ``adcp_webhook_circuit_state``, ``adcp_webhook_delivery_queue``, and ``adcp_webhook_delivery_log``. Concurrency ----------- Safe to share across asyncio tasks in a single process. Multiple processes/pods each running :meth:`run_worker` are explicitly supported via ``FOR UPDATE SKIP LOCKED``. """ def __init__( self, pool: Any, # psycopg_pool.AsyncConnectionPool; Any avoids import at runtime sender: WebhookSender | None, *, retry: RetryPolicy | None = None, circuit: CircuitBreakerPolicy | None = None, log_sink: DeliveryLogSink | None = None, circuit_table: str = DEFAULT_CIRCUIT_TABLE, queue_table: str = DEFAULT_QUEUE_TABLE, log_table: str = DEFAULT_LOG_TABLE, ) -> None: if not PG_AVAILABLE: raise ImportError(_INSTALL_HINT) if sender is None: raise ValueError( "PgWebhookDeliverySupervisor requires a non-None WebhookSender. " "Construct one via WebhookSender.from_jwk(...) or " "WebhookSender.from_pem(...) and pass it as the first positional argument." ) self._pool = pool self._sender = sender self._retry = retry or RetryPolicy() self._circuit_policy = circuit or CircuitBreakerPolicy() self._log_sink = log_sink ct = _safe_identifier(circuit_table) qt = _safe_identifier(queue_table) lt = _safe_identifier(log_table) self._circuit_t = ct self._queue_t = qt self._log_t = lt # Pre-format SQL at construction time to avoid per-call f-string overhead # and to bake in the validated table names once. self._sql_circuit_get = ( f"SELECT state, opened_at FROM {ct} WHERE breaker_key = %s" # noqa: S608 ) self._sql_circuit_set_half_open = ( f"UPDATE {ct} SET state = 'half_open', success_count = 0, " # noqa: S608 f"updated_at = now() WHERE breaker_key = %s AND state = 'open'" ) # Atomic upsert: increment failure_count; open circuit when threshold crossed. # {ct}.column_name in DO UPDATE SET refers to the *existing* row's value # (before the update), which is what Postgres requires for self-referential # ON CONFLICT expressions. # Coerce thresholds via ``int()`` before f-string interpolation — # an adopter passing an ``IntEnum`` / ``bool`` / custom ``__index__`` # subclass for ``failure_threshold`` would format verbatim into # SQL otherwise. Coercion produces a plain int whose ``str()`` # is byte-safe for static SQL emission. failure_t = int(self._circuit_policy.failure_threshold) self._sql_circuit_failure = ( f"INSERT INTO {ct} " # noqa: S608 f"(breaker_key, state, failure_count, success_count, updated_at) " f"VALUES (%s, 'closed', 1, 0, now()) " f"ON CONFLICT (breaker_key) DO UPDATE SET " f" failure_count = {ct}.failure_count + 1, " f" success_count = 0, " f" state = CASE " f" WHEN {ct}.failure_count + 1 >= {failure_t} OR {ct}.state = 'half_open' " f" THEN 'open' ELSE {ct}.state END, " f" opened_at = CASE " f" WHEN ({ct}.failure_count + 1 >= {failure_t} OR {ct}.state = 'half_open') " f" AND {ct}.state != 'open' THEN now() " f" ELSE {ct}.opened_at END, " f" updated_at = now()" ) # Atomic upsert: increment success_count; close circuit when threshold crossed. # RETURNING lets us read the post-update state without a second query, which # eliminates the half-open race: two concurrent workers see different final # counts and only the worker whose UPDATE produces count >= threshold # transitions to 'closed'. success_t = int(self._circuit_policy.success_threshold) self._sql_circuit_success = ( f"INSERT INTO {ct} " # noqa: S608 f"(breaker_key, state, failure_count, success_count, updated_at) " f"VALUES (%s, 'closed', 0, 0, now()) " f"ON CONFLICT (breaker_key) DO UPDATE SET " f" failure_count = 0, " f" success_count = CASE " f" WHEN {ct}.state = 'half_open' THEN {ct}.success_count + 1 ELSE 0 END, " f" state = CASE " f" WHEN {ct}.state = 'half_open' AND {ct}.success_count + 1 >= {success_t} " f" THEN 'closed' ELSE {ct}.state END, " f" updated_at = now() " f"RETURNING state, success_count" ) self._sql_enqueue = ( f"INSERT INTO {qt} " # noqa: S608 f"(breaker_key, url, task_id, task_type, status_str, result_json, " f"token, sequence_key, max_attempts, notification_type, operation_id) " f"VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id" ) self._sql_poll = ( f"SELECT id, breaker_key, url, task_id, task_type, status_str, " # noqa: S608 f"result_json, token, sequence_key, attempt_count, max_attempts, " f"idempotency_key, sent_body, notification_type, operation_id " f"FROM {qt} " f"WHERE status_str IN ('pending', 'retry') AND scheduled_at <= now() " f"ORDER BY scheduled_at LIMIT 1 FOR UPDATE SKIP LOCKED" ) self._sql_delete_job = f"DELETE FROM {qt} WHERE id = %s" # noqa: S608 self._sql_reschedule = ( f"UPDATE {qt} SET " # noqa: S608 f" status_str = 'retry', " f" attempt_count = %s, " f" scheduled_at = %s, " f" sent_body = %s, " f" idempotency_key = %s " f"WHERE id = %s" ) self._sql_log_insert = ( f"INSERT INTO {lt} " # noqa: S608 f"(queue_id, breaker_key, url, task_id, sequence_key, sequence_number, " f"attempt_number, max_attempts, outcome, http_status_code, error_message, " f"response_time_ms, occurred_at, will_retry, next_retry_at, task_type, " f"payload_size_bytes, notification_type) " f"VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)" ) self._worker_started = False self._worker_warned = False # ------------------------------------------------------------------ schema async def create_schema(self) -> None: """Bootstrap all three tables and their indexes. Idempotent. Safe to call on every app boot. Each DDL statement is executed separately — psycopg does not split on ``;``. :: pool = AsyncConnectionPool("postgresql://...") supervisor = PgWebhookDeliverySupervisor(pool=pool, sender=sender) await supervisor.create_schema() """ ct, qt, lt = self._circuit_t, self._queue_t, self._log_t statements = [ f"""CREATE TABLE IF NOT EXISTS {ct} ( breaker_key TEXT COLLATE "C" NOT NULL PRIMARY KEY, state TEXT NOT NULL DEFAULT 'closed', failure_count INT NOT NULL DEFAULT 0, success_count INT NOT NULL DEFAULT 0, opened_at TIMESTAMPTZ, updated_at TIMESTAMPTZ NOT NULL DEFAULT now() )""", f"""CREATE TABLE IF NOT EXISTS {qt} ( id BIGSERIAL PRIMARY KEY, breaker_key TEXT NOT NULL, url TEXT NOT NULL, task_id TEXT NOT NULL, task_type TEXT, status_str TEXT NOT NULL DEFAULT 'pending', result_json TEXT, token TEXT, sequence_key TEXT, attempt_count INT NOT NULL DEFAULT 0, max_attempts INT NOT NULL DEFAULT 3, scheduled_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), idempotency_key TEXT, sent_body BYTEA, notification_type TEXT, operation_id TEXT )""", # Backfill the operation_id column on tables created before the # async-completion-webhook operation_id echo landed. ADD COLUMN # IF NOT EXISTS is a no-op on fresh tables (the column is already # in the CREATE above) and an in-place add on pre-existing ones — # so a CREATE-only deployment and an upgrading one converge. f"ALTER TABLE {qt} ADD COLUMN IF NOT EXISTS operation_id TEXT", # Partial index on work-eligible rows; avoids scanning completed/in-flight rows. f"""CREATE INDEX IF NOT EXISTS {qt}_work_idx ON {qt} (status_str, scheduled_at) WHERE status_str IN ('pending', 'retry')""", f"""CREATE TABLE IF NOT EXISTS {lt} ( id BIGSERIAL PRIMARY KEY, queue_id BIGINT, breaker_key TEXT NOT NULL, url TEXT NOT NULL, task_id TEXT NOT NULL, sequence_key TEXT, sequence_number BIGINT, attempt_number INT NOT NULL, max_attempts INT NOT NULL, outcome TEXT NOT NULL, http_status_code INT, error_message TEXT, response_time_ms INT NOT NULL DEFAULT 0, occurred_at TIMESTAMPTZ NOT NULL, will_retry BOOLEAN NOT NULL DEFAULT false, next_retry_at TIMESTAMPTZ, task_type TEXT, payload_size_bytes INT, notification_type TEXT )""", f"CREATE INDEX IF NOT EXISTS {lt}_queue_id_idx ON {lt} (queue_id)", f"CREATE INDEX IF NOT EXISTS {lt}_task_id_idx ON {lt} (task_id)", ] async with self._pool.connection() as conn: for stmt in statements: await conn.execute(stmt) # ----------------------------------------------------------------- send async def send_mcp( self, *, url: str, task_id: str, status: GeneratedTaskStatus | str, task_type: TaskType | str, result: Any = None, operation_id: str | None = None, token: str | None = None, sequence_key: str | None = None, breaker_key: str | None = None, notification_type: str | None = None, ) -> WebhookDeliveryResult | None: """Enqueue one MCP-style webhook delivery; **always returns None**. The actual HTTP send happens in :meth:`run_worker`. This method writes the job to ``adcp_webhook_delivery_queue`` and returns immediately. ``None`` is returned whether the circuit is open (delivery skipped) or the job was successfully enqueued. For delivery outcomes use a :class:`~adcp.webhook_supervisor.DeliveryLogSink` or query the ``adcp_webhook_delivery_log`` table directly. :param breaker_key: Override the circuit-breaker lookup key (default: ``url``). Multi-tenant sellers whose buyers share a SaaS receiver URL MUST pass a tenant-scoped key (e.g. ``f"{tenant_id}:{url}"``). :param operation_id: Buyer-supplied correlation id echoed verbatim into the webhook payload per spec. Persisted on the queue row and replayed to :meth:`WebhookSender.send_mcp` when the worker delivers the job. :param notification_type: Passed through to the delivery log for delivery-report webhooks (``scheduled`` / ``final`` / etc.). """ if not self._worker_started and not self._worker_warned: self._worker_warned = True logger.warning( "[adcp.webhook_supervisor_pg] send_mcp() called before run_worker() " "has been started. Deliveries will be queued but not sent until a worker " "is running. Call asyncio.create_task(supervisor.run_worker()) at startup." ) # The queue column is TEXT; psycopg would otherwise bind a TaskType # enum via repr (`"TaskType.create_media_buy"`), corrupting the wire # value. Normalize once here so every persistence + log site sees # the on-wire string. task_type_str: str = task_type.value if isinstance(task_type, TaskType) else task_type bkey = breaker_key or url # Check circuit state; reject immediately if OPEN within the timeout window. async with self._pool.connection() as conn: cur = await conn.execute(self._sql_circuit_get, (bkey,)) row = await cur.fetchone() if row is not None: state: str = row[0] opened_at: datetime | None = row[1] if state == "open" and opened_at is not None: if opened_at.tzinfo is None: opened_at = opened_at.replace(tzinfo=UTC) elapsed = (datetime.now(UTC) - opened_at).total_seconds() if elapsed < self._circuit_policy.open_timeout_seconds: occurred_at = datetime.now(UTC) attempt = DeliveryAttempt( url=url, sequence_key=sequence_key, sequence_number=None, attempt_number=0, max_attempts=self._retry.max_attempts, outcome="circuit_open", http_status_code=None, error_message=f"circuit breaker OPEN for {bkey} — skipped delivery", response_time_ms=0, occurred_at=occurred_at, will_retry=False, next_retry_at=None, task_type=task_type_str, task_id=task_id, payload_size_bytes=None, notification_type=notification_type, ) await self._log_circuit_open(bkey, attempt) await self._call_sink(attempt) logger.warning( "[adcp.webhook_supervisor_pg] circuit OPEN for %s — skipped %s", bkey, task_type_str, ) return None # Open timeout elapsed; transition to half_open so next worker probes. async with self._pool.connection() as conn: await conn.execute(self._sql_circuit_set_half_open, (bkey,)) status_str = status if isinstance(status, str) else str(status) result_json = json.dumps(result) if result is not None else None async with self._pool.connection() as conn: cur = await conn.execute( self._sql_enqueue, ( bkey, url, task_id, task_type_str, status_str, result_json, token, sequence_key, self._retry.max_attempts, notification_type, operation_id, ), ) enqueue_row = await cur.fetchone() queue_id = enqueue_row[0] if enqueue_row else None logger.debug( "[adcp.webhook_supervisor_pg] enqueued %s → %s (queue_id=%s)", task_id, url, queue_id, ) return None # ----------------------------------------------------------------- worker async def run_worker(self, *, poll_interval: float = 0.5) -> None: """Poll the delivery queue with ``FOR UPDATE SKIP LOCKED``; runs forever. **REQUIRED** — start at app startup:: asyncio.create_task(supervisor.run_worker()) Multiple processes or coroutines can run concurrently; ``SKIP LOCKED`` ensures each job is processed by exactly one worker. The DB connection is held for the duration of the HTTP send so a crashed worker automatically releases the job back to the queue via transaction rollback (no separate recovery sweep needed). :param poll_interval: Seconds to sleep when the queue is empty. """ self._worker_started = True logger.info( "[adcp.webhook_supervisor_pg] worker started (poll_interval=%.2fs)", poll_interval, ) while True: try: delivered = await self._poll_and_process() if not delivered: await asyncio.sleep(poll_interval) except asyncio.CancelledError: logger.info("[adcp.webhook_supervisor_pg] worker cancelled — shut down") raise except Exception: logger.exception( "[adcp.webhook_supervisor_pg] worker error; will retry after %.2fs", poll_interval, ) await asyncio.sleep(poll_interval) # ---------------------------------------------------------- internal helpers async def _poll_and_process(self) -> bool: """Lease one job from the queue, send it, update state. Returns True if processed.""" # The connection stays open for the duration of the HTTP send so the # FOR UPDATE lock is held throughout. On crash (or CancelledError), # the transaction rolls back and the job returns to 'pending'. async with self._pool.connection() as conn: cur = await conn.execute(self._sql_poll) row = await cur.fetchone() if row is None: return False ( queue_id, bkey, url, task_id, task_type, status_str, result_json, token, sequence_key, attempt_count, max_attempts, idempotency_key, sent_body, notification_type, operation_id, ) = row attempt_number = attempt_count + 1 will_retry = attempt_number < max_attempts # Deferred import avoids circular module dependency at package init. from adcp.webhook_sender import WebhookDeliveryResult # noqa: PLC0415 t0 = time.monotonic() occurred_at = datetime.now(UTC) delivery_result: WebhookDeliveryResult | None = None exc_caught: BaseException | None = None try: if sent_body: # Spec-compliant retry: replay the exact bytes so the receiver # can dedup via the same idempotency_key (per mcp-webhook-payload.json: # "Publishers MUST … reuse the same key on every retry"). prev = WebhookDeliveryResult( status_code=0, idempotency_key=idempotency_key or "", url=url, response_headers={}, response_body=b"", sent_body=sent_body, ) delivery_result = await self._sender.resend(prev) else: result_obj = json.loads(result_json) if result_json else None delivery_result = await self._sender.send_mcp( url=url, task_id=task_id, status=status_str, task_type=task_type, result=result_obj, operation_id=operation_id, token=token, ) except Exception as exc: exc_caught = exc response_time_ms = int((time.monotonic() - t0) * 1000) success = delivery_result is not None and delivery_result.ok if success: assert delivery_result is not None # narrowing for mypy await conn.execute(self._sql_delete_job, (queue_id,)) await conn.execute(self._sql_circuit_success, (bkey,)) attempt = DeliveryAttempt( url=url, sequence_key=sequence_key, sequence_number=queue_id, attempt_number=attempt_number, max_attempts=max_attempts, outcome="success", http_status_code=delivery_result.status_code, error_message=None, response_time_ms=response_time_ms, occurred_at=occurred_at, will_retry=False, next_retry_at=None, task_type=task_type, task_id=task_id, payload_size_bytes=( len(delivery_result.sent_body) if delivery_result.sent_body else None ), notification_type=notification_type, ) else: await conn.execute(self._sql_circuit_failure, (bkey,)) next_delay = ( self._retry.delay_for_attempt(attempt_number + 1) if will_retry else None ) next_retry_at = ( occurred_at + timedelta(seconds=next_delay) if next_delay is not None else None ) if will_retry: stored_body = delivery_result.sent_body if delivery_result is not None else None stored_ikey = ( delivery_result.idempotency_key if delivery_result is not None else None ) await conn.execute( self._sql_reschedule, ( attempt_number, next_retry_at or occurred_at, stored_body, stored_ikey, queue_id, ), ) else: await conn.execute(self._sql_delete_job, (queue_id,)) if delivery_result is not None: err_msg: str | None = ( f"HTTP {delivery_result.status_code}: " f"{delivery_result.response_body[:200]!r}" ) http_status: int | None = delivery_result.status_code psize: int | None = ( len(delivery_result.sent_body) if delivery_result.sent_body else None ) elif exc_caught is not None: err_msg = f"{type(exc_caught).__name__}: {exc_caught}" http_status = None psize = None else: err_msg = None http_status = None psize = None attempt = DeliveryAttempt( url=url, sequence_key=sequence_key, sequence_number=queue_id, attempt_number=attempt_number, max_attempts=max_attempts, outcome="failure", http_status_code=http_status, error_message=err_msg, response_time_ms=response_time_ms, occurred_at=occurred_at, will_retry=will_retry, next_retry_at=next_retry_at, task_type=task_type, task_id=task_id, payload_size_bytes=psize, notification_type=notification_type, ) await self._log_attempt_via_conn(conn, queue_id, bkey, attempt) # connection __aexit__ commits here; lock released # Sink is called outside the transaction so a slow/broken sink # cannot interfere with the queue update. await self._call_sink(attempt) if exc_caught is not None and not will_retry: raise exc_caught # propagate to run_worker's exception logger return True async def _log_attempt_via_conn( self, conn: Any, queue_id: int | None, bkey: str, attempt: DeliveryAttempt, ) -> None: """Write a delivery attempt row using an already-open connection.""" try: await conn.execute( self._sql_log_insert, ( queue_id, bkey, attempt.url, attempt.task_id, attempt.sequence_key, attempt.sequence_number, attempt.attempt_number, attempt.max_attempts, attempt.outcome, attempt.http_status_code, attempt.error_message, attempt.response_time_ms, attempt.occurred_at, attempt.will_retry, attempt.next_retry_at, attempt.task_type, attempt.payload_size_bytes, attempt.notification_type, ), ) except Exception: logger.warning( "[adcp.webhook_supervisor_pg] failed to log attempt for %s — delivery unaffected", attempt.url, exc_info=True, ) async def _log_circuit_open(self, bkey: str, attempt: DeliveryAttempt) -> None: """Write a circuit_open attempt row using a fresh connection.""" try: async with self._pool.connection() as conn: await self._log_attempt_via_conn(conn, None, bkey, attempt) except Exception: logger.warning( "[adcp.webhook_supervisor_pg] failed to log circuit_open for %s", attempt.url, exc_info=True, ) async def _call_sink(self, attempt: DeliveryAttempt) -> None: if self._log_sink is None: return try: await asyncio.wait_for( self._log_sink.record(attempt), timeout=self._retry.sink_timeout_seconds, ) except asyncio.TimeoutError: logger.warning( "[adcp.webhook_supervisor_pg] DeliveryLogSink timed out for %s", attempt.url, ) except Exception: logger.warning( "[adcp.webhook_supervisor_pg] DeliveryLogSink raised for %s", attempt.url, exc_info=True, )Postgres-backed :class:
~adcp.webhook_supervisor.WebhookDeliverySupervisor.Parameters
pool: A
psycopg_pool.AsyncConnectionPoolowned by the caller. Each operation acquires a short-lived connection (or holds one for the duration of a delivery). We don't open, own, or close the pool. sender: The underlying :class:~adcp.webhook_sender.WebhookSenderused for the actual HTTP-Signatures POST. Must be non-None. retry: Retry policy (default: 3 attempts, exponential backoff with jitter). circuit: Circuit-breaker tuning (default: 5 failures open, 60s recovery, 2 successes close). log_sink: Optional :class:~adcp.webhook_supervisor.DeliveryLogSinkcalled after each attempt. Failures are swallowed. Use for custom observability pipelines in addition to the built-in log table. circuit_table / queue_table / log_table: Override table names (ASCII only, 1–63 chars). Useful for multi- tenant schemas. Defaults areadcp_webhook_circuit_state,adcp_webhook_delivery_queue, andadcp_webhook_delivery_log.Concurrency
Safe to share across asyncio tasks in a single process. Multiple processes/pods each running :meth:
run_workerare explicitly supported viaFOR UPDATE SKIP LOCKED.Methods
async def create_schema(self) ‑> None-
Expand source code
async def create_schema(self) -> None: """Bootstrap all three tables and their indexes. Idempotent. Safe to call on every app boot. Each DDL statement is executed separately — psycopg does not split on ``;``. :: pool = AsyncConnectionPool("postgresql://...") supervisor = PgWebhookDeliverySupervisor(pool=pool, sender=sender) await supervisor.create_schema() """ ct, qt, lt = self._circuit_t, self._queue_t, self._log_t statements = [ f"""CREATE TABLE IF NOT EXISTS {ct} ( breaker_key TEXT COLLATE "C" NOT NULL PRIMARY KEY, state TEXT NOT NULL DEFAULT 'closed', failure_count INT NOT NULL DEFAULT 0, success_count INT NOT NULL DEFAULT 0, opened_at TIMESTAMPTZ, updated_at TIMESTAMPTZ NOT NULL DEFAULT now() )""", f"""CREATE TABLE IF NOT EXISTS {qt} ( id BIGSERIAL PRIMARY KEY, breaker_key TEXT NOT NULL, url TEXT NOT NULL, task_id TEXT NOT NULL, task_type TEXT, status_str TEXT NOT NULL DEFAULT 'pending', result_json TEXT, token TEXT, sequence_key TEXT, attempt_count INT NOT NULL DEFAULT 0, max_attempts INT NOT NULL DEFAULT 3, scheduled_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), idempotency_key TEXT, sent_body BYTEA, notification_type TEXT, operation_id TEXT )""", # Backfill the operation_id column on tables created before the # async-completion-webhook operation_id echo landed. ADD COLUMN # IF NOT EXISTS is a no-op on fresh tables (the column is already # in the CREATE above) and an in-place add on pre-existing ones — # so a CREATE-only deployment and an upgrading one converge. f"ALTER TABLE {qt} ADD COLUMN IF NOT EXISTS operation_id TEXT", # Partial index on work-eligible rows; avoids scanning completed/in-flight rows. f"""CREATE INDEX IF NOT EXISTS {qt}_work_idx ON {qt} (status_str, scheduled_at) WHERE status_str IN ('pending', 'retry')""", f"""CREATE TABLE IF NOT EXISTS {lt} ( id BIGSERIAL PRIMARY KEY, queue_id BIGINT, breaker_key TEXT NOT NULL, url TEXT NOT NULL, task_id TEXT NOT NULL, sequence_key TEXT, sequence_number BIGINT, attempt_number INT NOT NULL, max_attempts INT NOT NULL, outcome TEXT NOT NULL, http_status_code INT, error_message TEXT, response_time_ms INT NOT NULL DEFAULT 0, occurred_at TIMESTAMPTZ NOT NULL, will_retry BOOLEAN NOT NULL DEFAULT false, next_retry_at TIMESTAMPTZ, task_type TEXT, payload_size_bytes INT, notification_type TEXT )""", f"CREATE INDEX IF NOT EXISTS {lt}_queue_id_idx ON {lt} (queue_id)", f"CREATE INDEX IF NOT EXISTS {lt}_task_id_idx ON {lt} (task_id)", ] async with self._pool.connection() as conn: for stmt in statements: await conn.execute(stmt)Bootstrap all three tables and their indexes. Idempotent.
Safe to call on every app boot. Each DDL statement is executed separately — psycopg does not split on
;.::
pool = AsyncConnectionPool("postgresql://...") supervisor = PgWebhookDeliverySupervisor(pool=pool, sender=sender) await supervisor.create_schema() async def run_worker(self, *, poll_interval: float = 0.5) ‑> None-
Expand source code
async def run_worker(self, *, poll_interval: float = 0.5) -> None: """Poll the delivery queue with ``FOR UPDATE SKIP LOCKED``; runs forever. **REQUIRED** — start at app startup:: asyncio.create_task(supervisor.run_worker()) Multiple processes or coroutines can run concurrently; ``SKIP LOCKED`` ensures each job is processed by exactly one worker. The DB connection is held for the duration of the HTTP send so a crashed worker automatically releases the job back to the queue via transaction rollback (no separate recovery sweep needed). :param poll_interval: Seconds to sleep when the queue is empty. """ self._worker_started = True logger.info( "[adcp.webhook_supervisor_pg] worker started (poll_interval=%.2fs)", poll_interval, ) while True: try: delivered = await self._poll_and_process() if not delivered: await asyncio.sleep(poll_interval) except asyncio.CancelledError: logger.info("[adcp.webhook_supervisor_pg] worker cancelled — shut down") raise except Exception: logger.exception( "[adcp.webhook_supervisor_pg] worker error; will retry after %.2fs", poll_interval, ) await asyncio.sleep(poll_interval)Poll the delivery queue with
FOR UPDATE SKIP LOCKED; runs forever.REQUIRED — start at app startup::
asyncio.create_task(supervisor.run_worker())Multiple processes or coroutines can run concurrently;
SKIP LOCKEDensures each job is processed by exactly one worker. The DB connection is held for the duration of the HTTP send so a crashed worker automatically releases the job back to the queue via transaction rollback (no separate recovery sweep needed).:param poll_interval: Seconds to sleep when the queue is empty.
async def send_mcp(self,
*,
url: str,
task_id: str,
status: GeneratedTaskStatus | str,
task_type: TaskType | str,
result: Any = None,
operation_id: str | None = None,
token: str | None = None,
sequence_key: str | None = None,
breaker_key: str | None = None,
notification_type: str | None = None) ‑> WebhookDeliveryResult | None-
Expand source code
async def send_mcp( self, *, url: str, task_id: str, status: GeneratedTaskStatus | str, task_type: TaskType | str, result: Any = None, operation_id: str | None = None, token: str | None = None, sequence_key: str | None = None, breaker_key: str | None = None, notification_type: str | None = None, ) -> WebhookDeliveryResult | None: """Enqueue one MCP-style webhook delivery; **always returns None**. The actual HTTP send happens in :meth:`run_worker`. This method writes the job to ``adcp_webhook_delivery_queue`` and returns immediately. ``None`` is returned whether the circuit is open (delivery skipped) or the job was successfully enqueued. For delivery outcomes use a :class:`~adcp.webhook_supervisor.DeliveryLogSink` or query the ``adcp_webhook_delivery_log`` table directly. :param breaker_key: Override the circuit-breaker lookup key (default: ``url``). Multi-tenant sellers whose buyers share a SaaS receiver URL MUST pass a tenant-scoped key (e.g. ``f"{tenant_id}:{url}"``). :param operation_id: Buyer-supplied correlation id echoed verbatim into the webhook payload per spec. Persisted on the queue row and replayed to :meth:`WebhookSender.send_mcp` when the worker delivers the job. :param notification_type: Passed through to the delivery log for delivery-report webhooks (``scheduled`` / ``final`` / etc.). """ if not self._worker_started and not self._worker_warned: self._worker_warned = True logger.warning( "[adcp.webhook_supervisor_pg] send_mcp() called before run_worker() " "has been started. Deliveries will be queued but not sent until a worker " "is running. Call asyncio.create_task(supervisor.run_worker()) at startup." ) # The queue column is TEXT; psycopg would otherwise bind a TaskType # enum via repr (`"TaskType.create_media_buy"`), corrupting the wire # value. Normalize once here so every persistence + log site sees # the on-wire string. task_type_str: str = task_type.value if isinstance(task_type, TaskType) else task_type bkey = breaker_key or url # Check circuit state; reject immediately if OPEN within the timeout window. async with self._pool.connection() as conn: cur = await conn.execute(self._sql_circuit_get, (bkey,)) row = await cur.fetchone() if row is not None: state: str = row[0] opened_at: datetime | None = row[1] if state == "open" and opened_at is not None: if opened_at.tzinfo is None: opened_at = opened_at.replace(tzinfo=UTC) elapsed = (datetime.now(UTC) - opened_at).total_seconds() if elapsed < self._circuit_policy.open_timeout_seconds: occurred_at = datetime.now(UTC) attempt = DeliveryAttempt( url=url, sequence_key=sequence_key, sequence_number=None, attempt_number=0, max_attempts=self._retry.max_attempts, outcome="circuit_open", http_status_code=None, error_message=f"circuit breaker OPEN for {bkey} — skipped delivery", response_time_ms=0, occurred_at=occurred_at, will_retry=False, next_retry_at=None, task_type=task_type_str, task_id=task_id, payload_size_bytes=None, notification_type=notification_type, ) await self._log_circuit_open(bkey, attempt) await self._call_sink(attempt) logger.warning( "[adcp.webhook_supervisor_pg] circuit OPEN for %s — skipped %s", bkey, task_type_str, ) return None # Open timeout elapsed; transition to half_open so next worker probes. async with self._pool.connection() as conn: await conn.execute(self._sql_circuit_set_half_open, (bkey,)) status_str = status if isinstance(status, str) else str(status) result_json = json.dumps(result) if result is not None else None async with self._pool.connection() as conn: cur = await conn.execute( self._sql_enqueue, ( bkey, url, task_id, task_type_str, status_str, result_json, token, sequence_key, self._retry.max_attempts, notification_type, operation_id, ), ) enqueue_row = await cur.fetchone() queue_id = enqueue_row[0] if enqueue_row else None logger.debug( "[adcp.webhook_supervisor_pg] enqueued %s → %s (queue_id=%s)", task_id, url, queue_id, ) return NoneEnqueue one MCP-style webhook delivery; always returns None.
The actual HTTP send happens in :meth:
run_worker. This method writes the job toadcp_webhook_delivery_queueand returns immediately.Noneis returned whether the circuit is open (delivery skipped) or the job was successfully enqueued.For delivery outcomes use a :class:
~adcp.webhook_supervisor.DeliveryLogSinkor query theadcp_webhook_delivery_logtable directly.:param breaker_key: Override the circuit-breaker lookup key (default:
url). Multi-tenant sellers whose buyers share a SaaS receiver URL MUST pass a tenant-scoped key (e.g.f"{tenant_id}:{url}"). :param operation_id: Buyer-supplied correlation id echoed verbatim into the webhook payload per spec. Persisted on the queue row and replayed to :meth:WebhookSender.send_mcp()when the worker delivers the job. :param notification_type: Passed through to the delivery log for delivery-report webhooks (scheduled/final/ etc.).
class TransportHook (*args, **kwargs)-
Expand source code
class TransportHook(Protocol): """Rewrite the destination URL before SSRF runs. Implementations return either ``None`` (no rewrite — pass through) or a new URL string. The framework validates that the new URL has the same scheme and port as the input, and reassembles path/query/fragment from the original; only the hostname is permitted to change. Hooks may be called many times per sender (once per delivery), so they should be cheap and side-effect-free. """ def rewrite_url(self, url: str) -> str | None: ...Rewrite the destination URL before SSRF runs.
Implementations return either
None(no rewrite — pass through) or a new URL string. The framework validates that the new URL has the same scheme and port as the input, and reassembles path/query/fragment from the original; only the hostname is permitted to change.Hooks may be called many times per sender (once per delivery), so they should be cheap and side-effect-free.
Ancestors
- typing.Protocol
- typing.Generic
Methods
def rewrite_url(self, url: str) ‑> str | None-
Expand source code
def rewrite_url(self, url: str) -> str | None: ...
class VerifiedLegacyWebhookSender (sender_identity: str)-
Expand source code
@dataclass(frozen=True) class VerifiedLegacyWebhookSender: """Identity returned by the HMAC verifier on success. Shape-compatible with the 9421 ``VerifiedWebhookSender.as_sender_identity`` so downstream dedup code treats both the same. """ sender_identity: str def as_sender_identity(self) -> str: return self.sender_identityIdentity returned by the HMAC verifier on success.
Shape-compatible with the 9421
VerifiedWebhookSender.as_sender_identity()so downstream dedup code treats both the same.Instance variables
var sender_identity : str
Methods
def as_sender_identity(self) ‑> str-
Expand source code
def as_sender_identity(self) -> str: return self.sender_identity
class VerifiedSignerLike (*args, **kwargs)-
Expand source code
@runtime_checkable class VerifiedSignerLike(Protocol): """Anything with ``as_sender_identity() -> str``. Both :class:`VerifiedWebhookSender` (9421) and :class:`VerifiedLegacyWebhookSender` (HMAC) implement this shape, so the receiver treats both verification paths identically downstream. """ def as_sender_identity(self) -> str: ...Anything with
as_sender_identity() -> str.Both :class:
VerifiedWebhookSender(9421) and :class:VerifiedLegacyWebhookSender(HMAC) implement this shape, so the receiver treats both verification paths identically downstream.Ancestors
- typing.Protocol
- typing.Generic
Methods
def as_sender_identity(self) ‑> str-
Expand source code
def as_sender_identity(self) -> str: ...
class VerifiedWebhookSender (key_id: str,
alg: str,
label: str,
verified_at: float,
sender_url: str | None = None)-
Expand source code
@dataclass(frozen=True) class VerifiedWebhookSender: """Returned on successful webhook verification. Distinct type from :class:`VerifiedSigner` so a caller that mistakenly passes a request-verified signer into a webhook-scoped dedup store (or the reverse) will fail to type-check. Both carry the same bytes; the type separation is a guardrail, not a data difference. """ key_id: str alg: str label: str verified_at: float sender_url: str | None = None def as_sender_identity(self) -> str: """Identity string used to scope dedup state. Webhook dedup MUST be scoped to the authenticated sender — trusting a payload field for identity is the attack-surface hole the spec's "Sender requirements" paragraph calls out. The key_id is the cryptographically verified identity; prefer ``sender_url:key_id`` when a sender URL is present to tolerate JWKS reuse across co-deployed senders. """ if self.sender_url is not None: return f"{self.sender_url}|{self.key_id}" return self.key_idReturned on successful webhook verification.
Distinct type from :class:
VerifiedSignerso a caller that mistakenly passes a request-verified signer into a webhook-scoped dedup store (or the reverse) will fail to type-check. Both carry the same bytes; the type separation is a guardrail, not a data difference.Instance variables
var alg : strvar key_id : strvar label : strvar sender_url : str | Nonevar verified_at : float
Methods
def as_sender_identity(self) ‑> str-
Expand source code
def as_sender_identity(self) -> str: """Identity string used to scope dedup state. Webhook dedup MUST be scoped to the authenticated sender — trusting a payload field for identity is the attack-surface hole the spec's "Sender requirements" paragraph calls out. The key_id is the cryptographically verified identity; prefer ``sender_url:key_id`` when a sender URL is present to tolerate JWKS reuse across co-deployed senders. """ if self.sender_url is not None: return f"{self.sender_url}|{self.key_id}" return self.key_idIdentity string used to scope dedup state.
Webhook dedup MUST be scoped to the authenticated sender — trusting a payload field for identity is the attack-surface hole the spec's "Sender requirements" paragraph calls out. The key_id is the cryptographically verified identity; prefer
sender_url:key_idwhen a sender URL is present to tolerate JWKS reuse across co-deployed senders.
class WebhookChallengeError (message: str,
*,
reason: str,
field: str | None = None,
url: str | None = None,
status_code: int | None = None,
suggestion: str | None = None)-
Expand source code
class WebhookChallengeError(ValueError): """Typed proof-of-control failure suitable for ``sync_accounts`` errors.""" code = "INVALID_REQUEST" def __init__( self, message: str, *, reason: str, field: str | None = None, url: str | None = None, status_code: int | None = None, suggestion: str | None = None, ) -> None: super().__init__(message) self.reason = reason self.field = field self.url = url self.status_code = status_code self.suggestion = suggestion def to_error(self) -> dict[str, str]: """Return a small ``errors[]``-compatible dict for seller handlers.""" error = {"code": self.code, "message": str(self)} if self.field is not None: error["field"] = self.field if self.suggestion is not None: error["suggestion"] = self.suggestion return errorTyped proof-of-control failure suitable for
sync_accountserrors.Ancestors
- builtins.ValueError
- builtins.Exception
- builtins.BaseException
Class variables
var code
Methods
def to_error(self) ‑> dict[str, str]-
Expand source code
def to_error(self) -> dict[str, str]: """Return a small ``errors[]``-compatible dict for seller handlers.""" error = {"code": self.code, "message": str(self)} if self.field is not None: error["field"] = self.field if self.suggestion is not None: error["suggestion"] = self.suggestion return errorReturn a small
errors[]-compatible dict for seller handlers.
class WebhookChallengeResult (challenge: str,
echoed_field: str,
destination: WebhookDestinationValidation,
status_code: int,
response_headers: Mapping[str, str],
response_body: bytes)-
Expand source code
@dataclass(frozen=True) class WebhookChallengeResult: """Successful durable webhook proof-of-control challenge.""" challenge: str echoed_field: str destination: WebhookDestinationValidation status_code: int response_headers: Mapping[str, str] response_body: bytes @property def ok(self) -> bool: return 200 <= self.status_code < 300Successful durable webhook proof-of-control challenge.
Instance variables
var challenge : strvar destination : WebhookDestinationValidationvar echoed_field : strprop ok : bool-
Expand source code
@property def ok(self) -> bool: return 200 <= self.status_code < 300 var response_body : bytesvar response_headers : Mapping[str, str]var status_code : int
class WebhookDedupStore (backend: IdempotencyBackend,
ttl_seconds: int = 86400,
*,
namespace: str = 'webhook',
clock: Callable[[], float] = <built-in function time>)-
Expand source code
class WebhookDedupStore: """Dedup ``(sender_id, idempotency_key)`` pairs to suppress retried webhooks. :param backend: any :class:`IdempotencyBackend`. Same MemoryBackend or PgBackend type used by :class:`IdempotencyStore` is fine — the ``namespace`` parameter prefixes all sender IDs so request-side and webhook-side scopes can't alias even when sharing one backend instance. :param ttl_seconds: replay window. Must be within ``[86400, 604800]`` per the spec minimum. Defaults to 86400 (24h). :param namespace: prefix applied to every ``sender_id`` before it hits the backend. Defaults to ``"webhook"``, which is safe when the same backend is shared with :class:`IdempotencyStore` (request-side keys are scoped by a principal_id that isn't wrapped in this namespace, so collisions are impossible). Override only if you run multiple webhook scopes against one backend (e.g., separate dedup spaces for task webhooks vs list-change webhooks). """ def __init__( self, backend: IdempotencyBackend, ttl_seconds: int = _MIN_TTL_SECONDS, *, namespace: str = "webhook", clock: Callable[[], float] = time.time, ) -> None: if not _MIN_TTL_SECONDS <= ttl_seconds <= _MAX_TTL_SECONDS: raise ValueError( f"ttl_seconds must be in [{_MIN_TTL_SECONDS}, {_MAX_TTL_SECONDS}] " f"per webhook spec minimum, got {ttl_seconds}" ) if not namespace: raise ValueError("namespace must be a non-empty string") self.backend = backend self.ttl_seconds = ttl_seconds self.namespace = namespace self._clock = clock async def check_and_record(self, sender_id: str, idempotency_key: str) -> bool: """Atomically check for first-seen and record if new. Returns ``True`` when the pair is first-seen (event should be processed), ``False`` on duplicate (caller MUST still return 2xx to the sender — the event was delivered successfully, it's just a retry). Race note: the check-then-put pattern is not atomic across concurrent callers unless the backend provides its own atomicity. MemoryBackend serializes individual ``get`` and ``put`` under an ``asyncio.Lock`` but does NOT bracket them together — two concurrent retries of the same event CAN both observe "first-seen" and both process the event. That's a tolerable failure mode: the ultimate guarantee is "at most once per replay window in the common case"; a concurrent retry arriving in the same few milliseconds is rare and, if it happens, produces the same "duplicated side effect" outcome the at-least-once contract already warns callers to tolerate. PgBackend implementations SHOULD use ``INSERT ... ON CONFLICT DO NOTHING`` returning ``rowcount`` for lock-free atomicity. """ if not sender_id: raise ValueError("sender_id must be a non-empty string") if not idempotency_key: raise ValueError("idempotency_key must be a non-empty string") scoped_sender = f"{self.namespace}:{sender_id}" existing = await self.backend.get(scoped_sender, idempotency_key) if existing is not None: logger.debug( "webhook dedup: duplicate sender=%s key_prefix=%s", sender_id, idempotency_key[:8], ) return False entry = CachedResponse( payload_hash=_SENTINEL_HASH, response={}, expires_at_epoch=self._clock() + self.ttl_seconds, ) try: await self.backend.put(scoped_sender, idempotency_key, entry) except Exception: # Same fail-open reasoning as the request-side store: log and # process. Swallowing the put failure means this event MIGHT # reprocess on retry, not that we drop it. Better than raising, # which would look like handler failure to the sender. logger.warning( "webhook dedup put failed for sender=%s key_prefix=%s — " "event processed but next retry will reprocess", sender_id, idempotency_key[:8], exc_info=True, ) return TrueDedup
(sender_id, idempotency_key)pairs to suppress retried webhooks.:param backend: any :class:
IdempotencyBackend. Same MemoryBackend or PgBackend type used by :class:IdempotencyStoreis fine — thenamespaceparameter prefixes all sender IDs so request-side and webhook-side scopes can't alias even when sharing one backend instance. :param ttl_seconds: replay window. Must be within[86400, 604800]per the spec minimum. Defaults to 86400 (24h). :param namespace: prefix applied to everysender_idbefore it hits the backend. Defaults to"webhook", which is safe when the same backend is shared with :class:IdempotencyStore(request-side keys are scoped by a principal_id that isn't wrapped in this namespace, so collisions are impossible). Override only if you run multiple webhook scopes against one backend (e.g., separate dedup spaces for task webhooks vs list-change webhooks).Methods
async def check_and_record(self, sender_id: str, idempotency_key: str) ‑> bool-
Expand source code
async def check_and_record(self, sender_id: str, idempotency_key: str) -> bool: """Atomically check for first-seen and record if new. Returns ``True`` when the pair is first-seen (event should be processed), ``False`` on duplicate (caller MUST still return 2xx to the sender — the event was delivered successfully, it's just a retry). Race note: the check-then-put pattern is not atomic across concurrent callers unless the backend provides its own atomicity. MemoryBackend serializes individual ``get`` and ``put`` under an ``asyncio.Lock`` but does NOT bracket them together — two concurrent retries of the same event CAN both observe "first-seen" and both process the event. That's a tolerable failure mode: the ultimate guarantee is "at most once per replay window in the common case"; a concurrent retry arriving in the same few milliseconds is rare and, if it happens, produces the same "duplicated side effect" outcome the at-least-once contract already warns callers to tolerate. PgBackend implementations SHOULD use ``INSERT ... ON CONFLICT DO NOTHING`` returning ``rowcount`` for lock-free atomicity. """ if not sender_id: raise ValueError("sender_id must be a non-empty string") if not idempotency_key: raise ValueError("idempotency_key must be a non-empty string") scoped_sender = f"{self.namespace}:{sender_id}" existing = await self.backend.get(scoped_sender, idempotency_key) if existing is not None: logger.debug( "webhook dedup: duplicate sender=%s key_prefix=%s", sender_id, idempotency_key[:8], ) return False entry = CachedResponse( payload_hash=_SENTINEL_HASH, response={}, expires_at_epoch=self._clock() + self.ttl_seconds, ) try: await self.backend.put(scoped_sender, idempotency_key, entry) except Exception: # Same fail-open reasoning as the request-side store: log and # process. Swallowing the put failure means this event MIGHT # reprocess on retry, not that we drop it. Better than raising, # which would look like handler failure to the sender. logger.warning( "webhook dedup put failed for sender=%s key_prefix=%s — " "event processed but next retry will reprocess", sender_id, idempotency_key[:8], exc_info=True, ) return TrueAtomically check for first-seen and record if new.
Returns
Truewhen the pair is first-seen (event should be processed),Falseon duplicate (caller MUST still return 2xx to the sender — the event was delivered successfully, it's just a retry).Race note: the check-then-put pattern is not atomic across concurrent callers unless the backend provides its own atomicity. MemoryBackend serializes individual
getandputunder anasyncio.Lockbut does NOT bracket them together — two concurrent retries of the same event CAN both observe "first-seen" and both process the event. That's a tolerable failure mode: the ultimate guarantee is "at most once per replay window in the common case"; a concurrent retry arriving in the same few milliseconds is rare and, if it happens, produces the same "duplicated side effect" outcome the at-least-once contract already warns callers to tolerate. PgBackend implementations SHOULD useINSERT … ON CONFLICT DO NOTHINGreturningrowcountfor lock-free atomicity.
class WebhookDeliveryResult (status_code: int,
idempotency_key: str,
url: str,
response_headers: Mapping[str, str],
response_body: bytes,
sent_body: bytes = b'',
sent_extra_headers: Mapping[str, str] = <factory>)-
Expand source code
@dataclass(frozen=True) class WebhookDeliveryResult: """Outcome of one ``send_*`` call. Senders care about: did it land (``ok``), what key was used (for logs and retry), what did the receiver say (``status_code``, ``response_body``). The ``sent_body`` and ``sent_extra_headers`` fields capture exactly what was signed and POSTed — the sender's :meth:`WebhookSender.resend` replays them under a fresh signature (preserving ``idempotency_key`` for dedup) rather than re-serializing from a user-supplied dict, which would drift if any field (``timestamp``, nested ``result``) differs between calls. """ status_code: int idempotency_key: str url: str response_headers: Mapping[str, str] response_body: bytes sent_body: bytes = b"" sent_extra_headers: Mapping[str, str] = field(default_factory=dict) @property def ok(self) -> bool: """True on 2xx. Note: receivers MUST return 2xx on duplicates too, so a 200 with ``duplicate=true`` in the body is still ``ok``.""" return 200 <= self.status_code < 300Outcome of one
send_*call.Senders care about: did it land (
ok), what key was used (for logs and retry), what did the receiver say (status_code,response_body).The
sent_bodyandsent_extra_headersfields capture exactly what was signed and POSTed — the sender's :meth:WebhookSender.resend()replays them under a fresh signature (preservingidempotency_keyfor dedup) rather than re-serializing from a user-supplied dict, which would drift if any field (timestamp, nestedresult) differs between calls.Instance variables
var idempotency_key : strprop ok : bool-
Expand source code
@property def ok(self) -> bool: """True on 2xx. Note: receivers MUST return 2xx on duplicates too, so a 200 with ``duplicate=true`` in the body is still ``ok``.""" return 200 <= self.status_code < 300True on 2xx. Note: receivers MUST return 2xx on duplicates too, so a 200 with
duplicate=truein the body is stillok. var response_body : bytesvar response_headers : Mapping[str, str]var sent_body : bytesvar sent_extra_headers : Mapping[str, str]var status_code : intvar url : str
class WebhookDestinationPolicy (require_https: bool = True,
allow_private_destinations: bool = False,
allowed_destination_ports: frozenset[int] | None = None,
transport_hooks: tuple[TransportHook, ...] = (),
name: str = 'production')-
Expand source code
@dataclass(frozen=True) class WebhookDestinationPolicy: """Registration-time policy for durable buyer webhook URLs. Use :meth:`production` before persisting buyer-provided ``push_notification_config.url`` or ``accounts[].notification_configs[].url``. Use :meth:`local_development` only for tests and local fixtures that need HTTP localhost or private-network endpoints. """ require_https: bool = True allow_private_destinations: bool = False allowed_destination_ports: frozenset[int] | None = None transport_hooks: tuple[TransportHook, ...] = () name: str = "production" @classmethod def production( cls, *, allowed_destination_ports: frozenset[int] | None = None, transport_hooks: tuple[TransportHook, ...] = (), ) -> WebhookDestinationPolicy: """Production webhook policy: HTTPS and public routable IPs only.""" return cls( require_https=True, allow_private_destinations=False, allowed_destination_ports=allowed_destination_ports, transport_hooks=transport_hooks, name="production", ) @classmethod def local_development( cls, *, allowed_destination_ports: frozenset[int] | None = None, transport_hooks: tuple[TransportHook, ...] = (), ) -> WebhookDestinationPolicy: """Explicit dev/test policy: allows HTTP and private destinations. Cloud metadata endpoints remain blocked by the shared SSRF validator even when private destinations are allowed. """ return cls( require_https=False, allow_private_destinations=True, allowed_destination_ports=allowed_destination_ports, transport_hooks=transport_hooks, name="local_development", )Registration-time policy for durable buyer webhook URLs.
Use :meth:
productionbefore persisting buyer-providedpush_notification_config.urloraccounts[].notification_configs[].url. Use :meth:local_developmentonly for tests and local fixtures that need HTTP localhost or private-network endpoints.Static methods
def local_development(*,
allowed_destination_ports: frozenset[int] | None = None,
transport_hooks: tuple[TransportHook, ...] = ()) ‑> WebhookDestinationPolicy-
Explicit dev/test policy: allows HTTP and private destinations.
Cloud metadata endpoints remain blocked by the shared SSRF validator even when private destinations are allowed.
def production(*,
allowed_destination_ports: frozenset[int] | None = None,
transport_hooks: tuple[TransportHook, ...] = ()) ‑> WebhookDestinationPolicy-
Production webhook policy: HTTPS and public routable IPs only.
Instance variables
var allow_private_destinations : boolvar allowed_destination_ports : frozenset[int] | Nonevar name : strvar require_https : boolvar transport_hooks : tuple[TransportHook, ...]
class WebhookDestinationValidation (original_url: str,
effective_url: str,
hostname: str,
resolved_ip: str,
port: int,
policy: WebhookDestinationPolicy)-
Expand source code
@dataclass(frozen=True) class WebhookDestinationValidation: """Resolved result of a registration-time webhook URL validation.""" original_url: str effective_url: str hostname: str resolved_ip: str port: int policy: WebhookDestinationPolicyResolved result of a registration-time webhook URL validation.
Instance variables
var effective_url : strvar hostname : strvar original_url : strvar policy : WebhookDestinationPolicyvar port : intvar resolved_ip : str
class WebhookDestinationValidationError (message: str,
*,
reason: str,
field: str | None = None,
url: str | None = None,
effective_url: str | None = None,
policy: WebhookDestinationPolicy | None = None,
suggestion: str | None = None)-
Expand source code
class WebhookDestinationValidationError(ValueError): """Typed URL-policy failure suitable for protocol error mapping. ``code`` is intentionally the protocol-level bucket sellers commonly return in ``errors[]``; ``reason`` carries the SDK-specific detail. ``field`` should be set by callers to values such as ``push_notification_config.url`` or ``accounts[0].notification_configs[0].url``. """ code = "INVALID_REQUEST" def __init__( self, message: str, *, reason: str, field: str | None = None, url: str | None = None, effective_url: str | None = None, policy: WebhookDestinationPolicy | None = None, suggestion: str | None = None, ) -> None: super().__init__(message) self.reason = reason self.field = field self.url = url self.effective_url = effective_url self.policy = policy self.suggestion = suggestion def to_error(self) -> dict[str, str]: """Return a small ``errors[]``-compatible dict for seller handlers.""" error = {"code": self.code, "message": str(self)} if self.field is not None: error["field"] = self.field if self.suggestion is not None: error["suggestion"] = self.suggestion return errorTyped URL-policy failure suitable for protocol error mapping.
codeis intentionally the protocol-level bucket sellers commonly return inerrors[];reasoncarries the SDK-specific detail.fieldshould be set by callers to values such aspush_notification_config.urloraccounts[0].notification_configs[0].url.Ancestors
- builtins.ValueError
- builtins.Exception
- builtins.BaseException
Class variables
var code
Methods
def to_error(self) ‑> dict[str, str]-
Expand source code
def to_error(self) -> dict[str, str]: """Return a small ``errors[]``-compatible dict for seller handlers.""" error = {"code": self.code, "message": str(self)} if self.field is not None: error["field"] = self.field if self.suggestion is not None: error["suggestion"] = self.suggestion return errorReturn a small
errors[]-compatible dict for seller handlers.
class WebhookOutcome (rejected: bool = False,
rejection_reason: RejectionReason | None = None,
response_headers: Mapping[str, str] = <factory>,
sender_identity: str | None = None,
payload: WebhookPayload | None = None,
duplicate: bool = False,
idempotency_key: str | None = None)-
Expand source code
@dataclass(frozen=True) class WebhookOutcome: """Result of a single ``receive`` call. Exactly one of ``rejected`` or ``payload`` is set. ``duplicate=True`` is compatible with a non-None ``payload`` — the payload parsed fine, the signature verified fine, it's just a retry the caller should 200 away. """ rejected: bool = False rejection_reason: RejectionReason | None = None response_headers: Mapping[str, str] = field(default_factory=dict) # Populated on successful verify (even when rejected downstream of crypto) sender_identity: str | None = None # Populated on successful verify + parse payload: WebhookPayload | None = None duplicate: bool = False idempotency_key: str | None = NoneResult of a single
receivecall.Exactly one of
rejectedorpayloadis set.duplicate=Trueis compatible with a non-Nonepayload— the payload parsed fine, the signature verified fine, it's just a retry the caller should 200 away.Instance variables
var duplicate : boolvar idempotency_key : str | Nonevar payload : adcp.types.generated_poc.core.mcp_webhook_payload.McpWebhookPayload | adcp.types.generated_poc.brand.revocation_notification.RevocationNotification | adcp.types.generated_poc.collection.collection_list_changed_webhook.CollectionListChangedWebhook | adcp.types.generated_poc.property.property_list_changed_webhook.PropertyListChangedWebhook | adcp.types.generated_poc.content_standards.artifact_webhook_payload.ArtifactWebhookPayload | Nonevar rejected : boolvar rejection_reason : Literal['signature_missing', 'signature_invalid', 'signature_legacy_failed', 'content_type_invalid', 'body_invalid_json', 'payload_invalid', 'idempotency_key_missing', 'idempotency_key_invalid'] | Nonevar response_headers : Mapping[str, str]var sender_identity : str | None
class WebhookReceiver (config: WebhookReceiverConfig)-
Expand source code
class WebhookReceiver: """Stateless webhook entry point, one instance per receiver configuration. Instance state (``config``) is read-only after construction. Per-request state lives in the :class:`WebhookOutcome` returned from :meth:`receive`. """ def __init__(self, config: WebhookReceiverConfig) -> None: self._config = config async def receive( self, *, method: str, url: str, headers: Mapping[str, str], body: bytes, ) -> WebhookOutcome: """Verify, dedupe, parse. Returns a :class:`WebhookOutcome`. Never raises for sender-caused cryptographic or protocol failures — returns an outcome with ``rejected=True`` and populated ``response_headers`` so the caller can convert to an HTTP response without try/except around every call. Operational failures inside the dedup backend or verify-options factory MAY still raise; wrap the call if you need to 5xx cleanly on internal errors. """ if not _content_type_is_json(headers): return _reject("content_type_invalid", sender_identity=None) signer, rejection = await self._verify(method=method, url=url, headers=headers, body=body) if rejection is not None: return rejection assert signer is not None # verification succeeded sender_id = signer.as_sender_identity() try: payload_dict = json.loads(body) except json.JSONDecodeError: return _reject("body_invalid_json", sender_identity=sender_id) if not isinstance(payload_dict, dict): return _reject("body_invalid_json", sender_identity=sender_id) idempotency_key = payload_dict.get("idempotency_key") if not isinstance(idempotency_key, str) or not idempotency_key: # Spec 3.0-rc: idempotency_key is REQUIRED on every webhook payload. return _reject("idempotency_key_missing", sender_identity=sender_id) if not _IDEMPOTENCY_KEY_RE.match(idempotency_key): # Non-conformant format — charset or length out of bounds. return _reject("idempotency_key_invalid", sender_identity=sender_id) parsed = self._parse(payload_dict) if parsed is None: return _reject("payload_invalid", sender_identity=sender_id) is_first_seen = await self._config.dedup.check_and_record( sender_id=sender_id, idempotency_key=idempotency_key ) return WebhookOutcome( sender_identity=sender_id, payload=parsed, duplicate=not is_first_seen, idempotency_key=idempotency_key, ) def receive_sync( self, *, method: str, url: str, headers: Mapping[str, str], body: bytes, ) -> WebhookOutcome: """Synchronous wrapper around :meth:`receive` for WSGI-style frameworks. Use this from Flask, Gunicorn sync workers, ``http.server``, or any other sync-only HTTP entry point where wrapping every call in ``asyncio.run(...)`` is just noise:: @app.post("/webhooks/adcp") def hook(): outcome = receiver.receive_sync( method=request.method, url=request.url, headers=dict(request.headers), body=request.get_data(), ) ... Raises :class:`RuntimeError` if invoked from a thread that already has a running event loop — the underlying verify / dedup path is async and cannot be driven from inside an active loop without blocking it. From async code, call :meth:`receive` directly. """ try: asyncio.get_running_loop() except RuntimeError: # No running loop in this thread — safe to spin one up. return asyncio.run(self.receive(method=method, url=url, headers=headers, body=body)) raise RuntimeError( "WebhookReceiver.receive_sync() cannot be called from a running " "event loop. Use `await receiver.receive(...)` instead." ) async def _verify( self, *, method: str, url: str, headers: Mapping[str, str], body: bytes, ) -> tuple[VerifiedSignerLike | None, WebhookOutcome | None]: """Returns (signer, None) on success or (None, rejection_outcome).""" has_9421 = _has_9421_headers(headers) if has_9421: try: signer = verify_webhook_signature( method=method, url=url, headers=headers, body=body, options=self._config.verify_options, ) return signer, None except SignatureVerificationError as exc: # Downgrade defense: when 9421 IS present but fails, do NOT # consult HMAC fallback by default. A MITM that stripped a # valid 9421 signature and replaced it with a forged HMAC one # is exactly what the downgrade guard exists for. fallback = self._config.legacy_hmac allow_hmac = fallback is not None and not fallback.only_when_9421_absent if not allow_hmac: return None, WebhookOutcome( rejected=True, rejection_reason="signature_invalid", response_headers=_www_authenticate_header(exc.code), ) logger.warning( "9421 webhook verify failed (%s); trying HMAC legacy because " "legacy_hmac.only_when_9421_absent=False is set", exc.code, ) fallback = self._config.legacy_hmac if fallback is None: # No 9421 headers AND no HMAC fallback configured → spec says 9421 # is baseline-required in 3.0, so this is non-conformant. return None, WebhookOutcome( rejected=True, rejection_reason="signature_missing", response_headers=_www_authenticate_header("webhook_signature_required"), ) hmac_options = fallback.options_for(headers) if hmac_options is None: return None, WebhookOutcome( rejected=True, rejection_reason="signature_missing", response_headers=_www_authenticate_header("webhook_signature_required"), ) try: legacy_signer = verify_webhook_hmac(headers=headers, body=body, options=hmac_options) return legacy_signer, None except LegacyWebhookHmacError: return None, WebhookOutcome( rejected=True, rejection_reason="signature_legacy_failed", response_headers=_www_authenticate_header("webhook_signature_invalid"), ) def _parse(self, payload_dict: dict[str, Any]) -> WebhookPayload | None: model = _MODEL_BY_KIND[self._config.kind] try: return cast(WebhookPayload, model.model_validate(payload_dict)) except ValidationError as exc: # Operators need the field-level reason to diagnose sender bugs. # The receiver still returns payload_invalid downstream; this is # just observability. logger.warning( "webhook payload failed %s validation: %s", self._config.kind, exc.errors(include_url=False), ) return NoneStateless webhook entry point, one instance per receiver configuration.
Instance state (
config) is read-only after construction. Per-request state lives in the :class:WebhookOutcomereturned from :meth:receive.Methods
async def receive(self, *, method: str, url: str, headers: Mapping[str, str], body: bytes) ‑> WebhookOutcome-
Expand source code
async def receive( self, *, method: str, url: str, headers: Mapping[str, str], body: bytes, ) -> WebhookOutcome: """Verify, dedupe, parse. Returns a :class:`WebhookOutcome`. Never raises for sender-caused cryptographic or protocol failures — returns an outcome with ``rejected=True`` and populated ``response_headers`` so the caller can convert to an HTTP response without try/except around every call. Operational failures inside the dedup backend or verify-options factory MAY still raise; wrap the call if you need to 5xx cleanly on internal errors. """ if not _content_type_is_json(headers): return _reject("content_type_invalid", sender_identity=None) signer, rejection = await self._verify(method=method, url=url, headers=headers, body=body) if rejection is not None: return rejection assert signer is not None # verification succeeded sender_id = signer.as_sender_identity() try: payload_dict = json.loads(body) except json.JSONDecodeError: return _reject("body_invalid_json", sender_identity=sender_id) if not isinstance(payload_dict, dict): return _reject("body_invalid_json", sender_identity=sender_id) idempotency_key = payload_dict.get("idempotency_key") if not isinstance(idempotency_key, str) or not idempotency_key: # Spec 3.0-rc: idempotency_key is REQUIRED on every webhook payload. return _reject("idempotency_key_missing", sender_identity=sender_id) if not _IDEMPOTENCY_KEY_RE.match(idempotency_key): # Non-conformant format — charset or length out of bounds. return _reject("idempotency_key_invalid", sender_identity=sender_id) parsed = self._parse(payload_dict) if parsed is None: return _reject("payload_invalid", sender_identity=sender_id) is_first_seen = await self._config.dedup.check_and_record( sender_id=sender_id, idempotency_key=idempotency_key ) return WebhookOutcome( sender_identity=sender_id, payload=parsed, duplicate=not is_first_seen, idempotency_key=idempotency_key, )Verify, dedupe, parse. Returns a :class:
WebhookOutcome.Never raises for sender-caused cryptographic or protocol failures — returns an outcome with
rejected=Trueand populatedresponse_headersso the caller can convert to an HTTP response without try/except around every call. Operational failures inside the dedup backend or verify-options factory MAY still raise; wrap the call if you need to 5xx cleanly on internal errors. def receive_sync(self, *, method: str, url: str, headers: Mapping[str, str], body: bytes) ‑> WebhookOutcome-
Expand source code
def receive_sync( self, *, method: str, url: str, headers: Mapping[str, str], body: bytes, ) -> WebhookOutcome: """Synchronous wrapper around :meth:`receive` for WSGI-style frameworks. Use this from Flask, Gunicorn sync workers, ``http.server``, or any other sync-only HTTP entry point where wrapping every call in ``asyncio.run(...)`` is just noise:: @app.post("/webhooks/adcp") def hook(): outcome = receiver.receive_sync( method=request.method, url=request.url, headers=dict(request.headers), body=request.get_data(), ) ... Raises :class:`RuntimeError` if invoked from a thread that already has a running event loop — the underlying verify / dedup path is async and cannot be driven from inside an active loop without blocking it. From async code, call :meth:`receive` directly. """ try: asyncio.get_running_loop() except RuntimeError: # No running loop in this thread — safe to spin one up. return asyncio.run(self.receive(method=method, url=url, headers=headers, body=body)) raise RuntimeError( "WebhookReceiver.receive_sync() cannot be called from a running " "event loop. Use `await receiver.receive(...)` instead." )Synchronous wrapper around :meth:
receivefor WSGI-style frameworks.Use this from Flask, Gunicorn sync workers,
http.server, or any other sync-only HTTP entry point where wrapping every call inasyncio.run(…)is just noise::@app.post("/webhooks/adcp") def hook(): outcome = receiver.receive_sync( method=request.method, url=request.url, headers=dict(request.headers), body=request.get_data(), ) ...Raises :class:
RuntimeErrorif invoked from a thread that already has a running event loop — the underlying verify / dedup path is async and cannot be driven from inside an active loop without blocking it. From async code, call :meth:receivedirectly.
class WebhookReceiverConfig (verify_options: WebhookVerifyOptions,
dedup: WebhookDedupStore,
legacy_hmac: LegacyHmacFallback | None = None,
kind: WebhookKind = 'mcp')-
Expand source code
@dataclass(frozen=True) class WebhookReceiverConfig: """Configuration bundle. :param verify_options: verifier configuration (JWKS, replay store, etc.). A single instance is reused for every request — the verifier stamps ``now`` itself via ``verify_options.clock()``, so there's no need to refresh a time field per request. :param dedup: webhook-dedup store. :param legacy_hmac: optional HMAC-SHA256 fallback for 3.x migration. :param kind: which webhook payload type to parse into. Default ``"mcp"`` (the task-status webhook that dominates most integrations); pass explicitly for list-change / artifact / revocation receivers. """ verify_options: WebhookVerifyOptions dedup: WebhookDedupStore legacy_hmac: LegacyHmacFallback | None = None kind: WebhookKind = "mcp"Configuration bundle.
:param verify_options: verifier configuration (JWKS, replay store, etc.). A single instance is reused for every request — the verifier stamps
nowitself viaverify_options.clock(), so there's no need to refresh a time field per request. :param dedup: webhook-dedup store. :param legacy_hmac: optional HMAC-SHA256 fallback for 3.x migration. :param kind: which webhook payload type to parse into. Default"mcp"(the task-status webhook that dominates most integrations); pass explicitly for list-change / artifact / revocation receivers.Instance variables
var dedup : WebhookDedupStorevar kind : Literal['mcp', 'revocation_notification', 'collection_list_changed', 'property_list_changed', 'artifact']var legacy_hmac : LegacyHmacFallback | Nonevar verify_options : WebhookVerifyOptions
class WebhookSender (*,
private_key: PrivateKey,
key_id: str,
alg: str,
client: httpx.AsyncClient | None = None,
timeout_seconds: float = 10.0,
allow_private_destinations: bool = False,
allowed_destination_ports: frozenset[int] | None = None,
transport_hooks: tuple[TransportHook, ...] = ())-
Expand source code
class WebhookSender: """Outbound signed-webhook delivery client. Owns one webhook-signing private key. Reuses a single :class:`httpx.AsyncClient` across requests for connection pooling — pass your own via ``client=`` if you want to share it with other SDK surfaces. Thread/task safety: safe to call concurrent ``send_*`` from many asyncio tasks. The underlying ``httpx.AsyncClient`` manages its own pool. """ def __init__( self, *, private_key: PrivateKey, key_id: str, alg: str, client: httpx.AsyncClient | None = None, timeout_seconds: float = _DEFAULT_TIMEOUT_SECONDS, allow_private_destinations: bool = False, allowed_destination_ports: frozenset[int] | None = None, transport_hooks: tuple[TransportHook, ...] = (), ) -> None: """Construct a sender wired to RFC 9421 JWK signing. The HMAC and bearer modes are reached via :meth:`from_bearer_token`, :meth:`from_adcp_legacy_hmac`, and :meth:`from_standard_webhooks_secret` — those classmethods bypass this initializer through :meth:`_from_strategy` because their key material has different types (``bytes`` / ``str`` rather than ``PrivateKey``). ``transport_hooks`` runs URL rewrites before SSRF validation — see :class:`adcp.webhook_transport_hooks.DockerLocalhostRewrite` for the canonical use case. SSRF remains authoritative on the rewritten URL; hooks cannot punch through the range check. """ self._auth: WebhookAuthStrategy = JwkSignerStrategy( private_key=private_key, key_id=key_id, alg=alg ) self._key_id = key_id self._timeout = timeout_seconds self._client = client self._owns_client = client is None self._allow_private_destinations = allow_private_destinations self._allowed_destination_ports = allowed_destination_ports self._transport_hooks = tuple(transport_hooks) _validate_hooks(self._transport_hooks, allow_private_destinations) @classmethod def _from_strategy( cls, auth: WebhookAuthStrategy, *, key_id: str, client: httpx.AsyncClient | None, timeout_seconds: float, allow_private_destinations: bool, allowed_destination_ports: frozenset[int] | None, transport_hooks: tuple[TransportHook, ...] = (), ) -> WebhookSender: """Build a sender around a pre-constructed auth strategy. Internal constructor for the HMAC/bearer paths. The public ``__init__`` is locked to the JWK signature for back-compat; new modes don't fit that signature, so they bypass it here. """ sender = cls.__new__(cls) sender._auth = auth sender._key_id = key_id sender._timeout = timeout_seconds sender._client = client sender._owns_client = client is None sender._allow_private_destinations = allow_private_destinations sender._allowed_destination_ports = allowed_destination_ports sender._transport_hooks = tuple(transport_hooks) _validate_hooks(sender._transport_hooks, allow_private_destinations) return sender @classmethod def from_jwk( cls, jwk: Mapping[str, Any], *, d_field: str = "d", client: httpx.AsyncClient | None = None, timeout_seconds: float = _DEFAULT_TIMEOUT_SECONDS, allow_private_destinations: bool = False, allowed_destination_ports: frozenset[int] | None = None, transport_hooks: tuple[TransportHook, ...] = (), ) -> WebhookSender: """Construct from a JWK that includes the private scalar. The JWK MUST have ``adcp_use == "webhook-signing"`` — the sender doesn't validate this (you're signing with your own key; validation happens at the receiver), but a key whose adcp_use is wrong will be rejected by every conformant verifier. ``allow_private_destinations`` and ``allowed_destination_ports`` forward to :meth:`__init__` — see that signature for semantics. """ # Snapshot the mapping once — a live Mapping could otherwise return # different values across the adcp_use / kid / d / alg reads. jwk_snapshot = dict(jwk) if jwk_snapshot.get("adcp_use") != "webhook-signing": raise ValueError( f"WebhookSender requires a JWK with adcp_use='webhook-signing' " f"(got {jwk_snapshot.get('adcp_use')!r}). Webhook-signing and " f"request-signing keys MUST be distinct so a signature from one " f"surface cannot be replayed as the other. Generate a separate " f"key with adcp_use='webhook-signing' and publish it in your " f"adagents.json alongside your request-signing key. See " f"https://adcontextprotocol.org/docs/building/implementation/security" ) alg = jwk_snapshot.get("alg") if alg == "EdDSA": alg = "ed25519" elif alg == "ES256": alg = "ecdsa-p256-sha256" if alg not in ("ed25519", "ecdsa-p256-sha256"): raise ValueError(f"unsupported JWK alg {jwk_snapshot.get('alg')!r}") private_key = private_key_from_jwk(jwk_snapshot, d_field=d_field) return cls( private_key=private_key, key_id=str(jwk_snapshot["kid"]), alg=alg, client=client, timeout_seconds=timeout_seconds, allow_private_destinations=allow_private_destinations, allowed_destination_ports=allowed_destination_ports, transport_hooks=transport_hooks, ) @classmethod def from_pem( cls, pem_path: str | Path | bytes, *, key_id: str, alg: str = "ed25519", passphrase: bytes | None = None, client: httpx.AsyncClient | None = None, timeout_seconds: float = _DEFAULT_TIMEOUT_SECONDS, allow_private_destinations: bool = False, allowed_destination_ports: frozenset[int] | None = None, transport_hooks: tuple[TransportHook, ...] = (), ) -> WebhookSender: """Load a private key from a PEM file and bind it as a webhook sender. Companion to ``adcp-keygen --purpose webhook-signing``, which writes the PEM and prints the public JWK. The JWK is published at your ``jwks_uri``; the PEM holds the private key material. ``from_pem`` reads the PEM, constructs the right ``PrivateKey`` type for ``alg``, and returns a sender ready to send. Args: pem_path: Path to the PKCS#8 PEM, or the PEM bytes directly. key_id: JWK ``kid`` claim — must match the published JWK. alg: Signature algorithm. ``ed25519`` (default) or ``es256``. Also accepts the RFC 9421 form ``ecdsa-p256-sha256``. passphrase: Required if the PEM is encrypted (``adcp-keygen --encrypt``). client: Optional pre-built :class:`httpx.AsyncClient` to share across the SDK; the sender owns its own client when omitted. timeout_seconds: Per-request timeout for the owned client. allow_private_destinations: Forwarded to :meth:`__init__`. allowed_destination_ports: Forwarded to :meth:`__init__`. Raises: ValueError: ``alg`` is not ed25519 / es256, or the PEM contains a key whose type doesn't match ``alg``. """ if alg in ("es256", "ES256"): alg = ALG_ES256 elif alg == "EdDSA": alg = ALG_ED25519 if alg not in (ALG_ED25519, ALG_ES256): raise ValueError( f"unsupported alg {alg!r} — use 'ed25519' or 'es256' " f"(the two AdCP webhook-signing algorithms)" ) if isinstance(pem_path, bytes): pem_bytes = pem_path else: pem_bytes = Path(pem_path).read_bytes() private_key = load_private_key_pem(pem_bytes, password=passphrase) # The PEM's key type must match the requested alg — mixing them # would produce signatures no verifier can validate, and the # resulting error at delivery time would point at the receiver. # Fail here so the misconfiguration surfaces at construction. if alg == ALG_ED25519 and not isinstance(private_key, ed25519.Ed25519PrivateKey): raise ValueError( f"PEM holds a {type(private_key).__name__} but alg='ed25519' " f"was requested. Re-run adcp-keygen with --alg ed25519, or " f"pass alg='es256' to match the existing PEM." ) if alg == ALG_ES256 and not isinstance(private_key, ec.EllipticCurvePrivateKey): raise ValueError( f"PEM holds a {type(private_key).__name__} but alg='es256' " f"was requested. Re-run adcp-keygen with --alg es256, or " f"pass alg='ed25519' to match the existing PEM." ) return cls( private_key=private_key, key_id=key_id, alg=alg, client=client, timeout_seconds=timeout_seconds, allow_private_destinations=allow_private_destinations, allowed_destination_ports=allowed_destination_ports, transport_hooks=transport_hooks, ) @classmethod def from_bearer_token( cls, token: str, *, client: httpx.AsyncClient | None = None, timeout_seconds: float = _DEFAULT_TIMEOUT_SECONDS, allow_private_destinations: bool = False, allowed_destination_ports: frozenset[int] | None = None, transport_hooks: tuple[TransportHook, ...] = (), ) -> WebhookSender: """Build a sender that POSTs with ``Authorization: Bearer <token>``. For buyers who authenticate the sender at the gateway and don't verify body signatures. The sender's marshaling guarantees still apply (byte-exact JSON, idempotency_key in body); body signing is skipped. A buyer treating bearer tokens as the sole authenticity signal SHOULD also enforce TLS/mTLS at the transport layer — a stolen token is a complete forgery. Prefer JWK signing (:meth:`from_jwk`) for AdCP-conformant deliveries. """ if not isinstance(token, str) or not token: raise ValueError("bearer token must be a non-empty string") return cls._from_strategy( BearerTokenStrategy(token=token), key_id="bearer", client=client, timeout_seconds=timeout_seconds, allow_private_destinations=allow_private_destinations, allowed_destination_ports=allowed_destination_ports, transport_hooks=transport_hooks, ) @classmethod def from_adcp_legacy_hmac( cls, secret: bytes, *, key_id: str, client: httpx.AsyncClient | None = None, timeout_seconds: float = _DEFAULT_TIMEOUT_SECONDS, allow_private_destinations: bool = False, allowed_destination_ports: frozenset[int] | None = None, transport_hooks: tuple[TransportHook, ...] = (), ) -> WebhookSender: """Build a sender wired to AdCP-legacy HMAC-SHA256. Wire format matches :func:`adcp.signing.webhook_hmac.verify_webhook_hmac`: ``X-AdCP-Signature: sha256=<hex>`` over ``f"{timestamp}.{body}"``, with ``X-AdCP-Timestamp`` set fresh per delivery (resends produce a new signature over the same body). ``secret`` is the raw HMAC key — the AdCP-legacy scheme has no canonical encoding, so callers pass bytes directly. ``key_id`` is echoed in ``X-AdCP-Key-Id`` for receiver-side multi-key rotation; it is not used in the signature itself. AdCP-legacy HMAC will be removed in AdCP 4.0 — operators SHOULD migrate to JWK signing (:meth:`from_jwk`) ahead of that boundary. """ if not isinstance(secret, bytes) or not secret: raise ValueError("hmac secret must be non-empty bytes") if not isinstance(key_id, str) or not key_id: raise ValueError("key_id must be a non-empty string") # Mirror the receiver-side _warn_once() in webhook_hmac so a # sender-only operator (no receiver in this process) still sees # the AdCP 4.0 deprecation signal at runtime, not just in the # docstring. _warn_legacy_hmac_once() return cls._from_strategy( AdcpLegacyHmacStrategy(secret=secret, key_id=key_id), key_id=key_id, client=client, timeout_seconds=timeout_seconds, allow_private_destinations=allow_private_destinations, allowed_destination_ports=allowed_destination_ports, transport_hooks=transport_hooks, ) @classmethod def from_standard_webhooks_secret( cls, secret: str, *, key_id: str, client: httpx.AsyncClient | None = None, timeout_seconds: float = _DEFAULT_TIMEOUT_SECONDS, allow_private_destinations: bool = False, allowed_destination_ports: frozenset[int] | None = None, transport_hooks: tuple[TransportHook, ...] = (), ) -> WebhookSender: """Build a sender wired to standardwebhooks.com v1 (Svix/Resend interop). ``secret`` is the canonical ``whsec_<base64>`` form distributed by buyers running Svix, Resend, or any other Standard Webhooks verifier. The constructor base64-decodes the prefix-stripped payload internally — passing the literal ``whsec_...`` to :meth:`from_adcp_legacy_hmac` would silently produce signatures Svix rejects, which is exactly the footgun this typed split prevents. Wire format per spec: ``webhook-id`` / ``webhook-timestamp`` / ``webhook-signature: v1,<base64>`` over ``f"{webhook_id}.{webhook_timestamp}.{body}"``. Each delivery gets a fresh ``webhook-id`` so a receiver using webhook-id for its own replay cache doesn't false-positive on a legitimate retry — :meth:`resend` re-signs and gets a new id. """ if not isinstance(secret, str) or not secret: raise ValueError("secret must be a non-empty string (whsec_<base64>)") if not isinstance(key_id, str) or not key_id: raise ValueError("key_id must be a non-empty string") decoded = _decode_sw_secret(secret) return cls._from_strategy( StandardWebhooksHmacStrategy(secret=decoded, key_id=key_id), key_id=key_id, client=client, timeout_seconds=timeout_seconds, allow_private_destinations=allow_private_destinations, allowed_destination_ports=allowed_destination_ports, transport_hooks=transport_hooks, ) def __repr__(self) -> str: # Explicit repr so no future debug helper or error traceback auto- # renders self.__dict__ and pulls the private key (or HMAC secret / # bearer token) into logs. return f"WebhookSender(auth={type(self._auth).__name__}, " f"key_id={self._key_id!r})" @property def signs_with_rfc9421(self) -> bool: """``True`` iff this sender uses the RFC 9421 webhook-signing profile. Boot-time validators read this to enforce the ``webhook_signing.supported=true`` capability invariant: capabilities advertise RFC 9421 → wired sender must produce ``Signature`` / ``Signature-Input`` headers. ``from_bearer_token``, ``from_adcp_legacy_hmac``, and ``from_standard_webhooks_secret`` senders return ``False``. """ return isinstance(self._auth, JwkSignerStrategy) async def aclose(self) -> None: """Close the internal httpx client if we own it.""" if self._owns_client and self._client is not None: await self._client.aclose() self._client = None async def __aenter__(self) -> WebhookSender: if not self._owns_client: await self._get_client() return self async def __aexit__(self, *args: Any) -> None: await self.aclose() async def _get_client(self) -> httpx.AsyncClient: if self._client is None: self._client = httpx.AsyncClient(timeout=self._timeout) return self._client async def send_mcp( self, *, url: str, task_id: str, status: GeneratedTaskStatus | str, task_type: TaskType | str, result: AdcpAsyncResponseData | dict[str, Any] | None = None, timestamp: datetime | None = None, operation_id: str | None = None, message: str | None = None, context_id: str | None = None, protocol: AdcpProtocol | str | None = None, idempotency_key: str | None = None, token: str | None = None, extra_headers: Mapping[str, str] | None = None, ) -> WebhookDeliveryResult: """POST a signed MCP-style task-status webhook. On retry, prefer :meth:`resend` over calling this again — ``resend`` replays the exact same bytes, whereas re-invoking ``send_mcp`` with the "same" args would produce a fresh ``timestamp`` and potentially a different serialized body, which the receiver would dedupe but with different observed payload data. :param token: Buyer-supplied token from ``push_notification_config.token`` echoed back on the payload's ``token`` field per spec (``schemas/cache/core/push_notification_config.json``: "Echoed back in webhook payload to validate request authenticity"). Cross-language wire-parity with the JS implementation. """ payload = create_mcp_webhook_payload( task_id=task_id, status=status, task_type=task_type, result=result, timestamp=timestamp, operation_id=operation_id, message=message, context_id=context_id, protocol=protocol, idempotency_key=idempotency_key, token=token, ) return await self.send_raw( url=url, idempotency_key=payload.idempotency_key, payload=to_wire_dict(payload), extra_headers=extra_headers, ) async def send_revocation_notification( self, *, url: str, rights_id: str, brand_id: str, reason: str, effective_at: str, idempotency_key: str | None = None, extra_headers: Mapping[str, str] | None = None, ) -> WebhookDeliveryResult: """POST a signed rights-revocation notification.""" key = idempotency_key or generate_webhook_idempotency_key() payload: dict[str, Any] = { "idempotency_key": key, "rights_id": rights_id, "brand_id": brand_id, "reason": reason, "effective_at": effective_at, } return await self.send_raw( url=url, idempotency_key=key, payload=payload, extra_headers=extra_headers ) async def send_artifact_webhook( self, *, url: str, media_buy_id: str, batch_id: str, timestamp: str, artifacts: list[dict[str, Any]], idempotency_key: str | None = None, extra_headers: Mapping[str, str] | None = None, ) -> WebhookDeliveryResult: """POST a signed content-standards artifact webhook.""" key = idempotency_key or generate_webhook_idempotency_key() payload: dict[str, Any] = { "idempotency_key": key, "media_buy_id": media_buy_id, "batch_id": batch_id, "timestamp": timestamp, "artifacts": artifacts, } return await self.send_raw( url=url, idempotency_key=key, payload=payload, extra_headers=extra_headers ) async def send_collection_list_changed( self, *, url: str, list_id: str, resolved_at: str, signature: str, idempotency_key: str | None = None, extra_headers: Mapping[str, str] | None = None, ) -> WebhookDeliveryResult: """POST a signed governance collection-list-changed webhook. ``signature`` is the payload-level signature field that predates 9421 webhook transport signing — it remains required by the schema. The 9421 signature this method adds protects the transport envelope. """ key = idempotency_key or generate_webhook_idempotency_key() payload: dict[str, Any] = { "idempotency_key": key, "event": "collection_list_changed", "list_id": list_id, "resolved_at": resolved_at, "signature": signature, } return await self.send_raw( url=url, idempotency_key=key, payload=payload, extra_headers=extra_headers ) async def send_property_list_changed( self, *, url: str, list_id: str, resolved_at: str, signature: str, idempotency_key: str | None = None, extra_headers: Mapping[str, str] | None = None, ) -> WebhookDeliveryResult: """POST a signed governance property-list-changed webhook.""" key = idempotency_key or generate_webhook_idempotency_key() payload: dict[str, Any] = { "idempotency_key": key, "event": "property_list_changed", "list_id": list_id, "resolved_at": resolved_at, "signature": signature, } return await self.send_raw( url=url, idempotency_key=key, payload=payload, extra_headers=extra_headers ) async def send_wholesale_feed( self, *, url: str, subscriber_id: str, account_id: str, notification_type: str, wholesale_feed_version: str, cache_scope: str, event: WholesaleFeedEvent | Mapping[str, Any], previous_wholesale_feed_version: str | None = None, fired_at: datetime | None = None, idempotency_key: str | None = None, subscription_event_types: Sequence[Any] | None = None, extra_headers: Mapping[str, str] | None = None, ) -> WebhookDeliveryResult: """POST a signed account-scoped wholesale feed notification. ``subscription_event_types`` is optional but recommended when the caller is sending to an ``accounts[].notification_configs[]`` entry: pass that entry's ``event_types`` to fail closed if the subscription did not request this notification type. """ if not isinstance(subscriber_id, str) or not subscriber_id: raise ValueError("subscriber_id must be a non-empty string") if not isinstance(account_id, str) or not account_id: raise ValueError("account_id must be a non-empty string") if not isinstance(wholesale_feed_version, str) or not wholesale_feed_version: raise ValueError("wholesale_feed_version must be a non-empty string") event_model = event if not isinstance(event_model, WholesaleFeedEvent): event_model = WholesaleFeedEvent.model_validate(event_model) notification_type_value = _enum_value(notification_type) event_type = _enum_value(event_model.event_type) entity_type = _enum_value(event_model.entity_type) if notification_type_value != event_type: raise ValueError( "notification_type must match event.event_type " f"(got {notification_type_value!r}, event has {event_type!r})" ) if subscription_event_types is not None: allowed_event_types = {_enum_value(item) for item in subscription_event_types} else: allowed_event_types = None if allowed_event_types is not None and notification_type_value not in allowed_event_types: raise ValueError( "notification_type is not present in the subscription's event_types; " "sellers must not silently widen account notification filters" ) expected_entity_type = _entity_type_for_wholesale_notification(notification_type_value) if entity_type != expected_entity_type: raise ValueError( "event.entity_type does not match notification_type " f"(got {entity_type!r}, expected {expected_entity_type!r})" ) cache_scope_value = _enum_value(cache_scope) applies_to = getattr(event_model.payload, "applies_to", None) applies_to_scope = _enum_value(getattr(applies_to, "scope", None)) if applies_to_scope != cache_scope_value: raise ValueError( "cache_scope must match event.payload.applies_to.scope " f"(got {cache_scope_value!r}, event has {applies_to_scope!r})" ) key = idempotency_key or generate_webhook_idempotency_key() timestamp = fired_at or datetime.now(timezone.utc) webhook = WholesaleFeedWebhook.model_validate( { "idempotency_key": key, "notification_id": event_model.event_id, "notification_type": notification_type_value, "fired_at": timestamp, "subscriber_id": subscriber_id, "account_id": account_id, "wholesale_feed_version": wholesale_feed_version, "previous_wholesale_feed_version": previous_wholesale_feed_version, "cache_scope": cache_scope_value, "event": event_model, } ) return await self.send_raw( url=url, idempotency_key=key, payload=webhook.model_dump(mode="json", exclude_none=True), extra_headers=extra_headers, ) async def send_wholesale_feed_to_subscription( self, *, subscription: NotificationConfig | Mapping[str, Any], account_id: str, notification_type: str, wholesale_feed_version: str, cache_scope: str, event: WholesaleFeedEvent | Mapping[str, Any], previous_wholesale_feed_version: str | None = None, fired_at: datetime | None = None, idempotency_key: str | None = None, extra_headers: Mapping[str, str] | None = None, ) -> WebhookDeliveryResult: """POST a wholesale feed notification to a ``NotificationConfig``. This convenience wrapper keeps ``url``, ``subscriber_id``, and ``event_types`` coupled to the same persisted subscription entry. """ config = ( subscription if isinstance(subscription, NotificationConfig) else NotificationConfig.model_validate(subscription) ) return await self.send_wholesale_feed( url=str(config.url), subscriber_id=config.subscriber_id, account_id=account_id, notification_type=notification_type, wholesale_feed_version=wholesale_feed_version, cache_scope=cache_scope, event=event, previous_wholesale_feed_version=previous_wholesale_feed_version, fired_at=fired_at, idempotency_key=idempotency_key, subscription_event_types=config.event_types, extra_headers=extra_headers, ) async def send_webhook_challenge( self, *, url: str, account_id: str, subscriber_id: str, challenge: str | None = None, extra_headers: Mapping[str, str] | None = None, ) -> WebhookDeliveryResult: """POST a signed durable-subscription proof-of-control challenge. The body matches the durable ``notification_configs[]`` challenge shape and intentionally does not inject ``idempotency_key``: ``{"type":"webhook.challenge","challenge":"...", ...}`` Pair this low-level sender method with :func:`adcp.webhooks.challenge_webhook_destination` when you also want URL validation and response echo checking in one call. """ payload = create_webhook_challenge_payload( account_id=account_id, subscriber_id=subscriber_id, challenge=challenge, ) challenge_value = str(payload["challenge"]) body = json.dumps(payload, separators=(",", ":")).encode("utf-8") return await self._send_bytes( url=url, body=body, idempotency_key=challenge_value, extra_headers=extra_headers, ) async def send_raw( self, *, url: str, idempotency_key: str, payload: dict[str, Any], extra_headers: Mapping[str, str] | None = None, ) -> WebhookDeliveryResult: """Low-level escape hatch: sign + POST an arbitrary payload. The ``idempotency_key`` kwarg is required and is injected into the payload before signing — the visible signature makes the contract impossible to forget, unlike a runtime dict check. If ``payload`` already carries an ``idempotency_key``, the kwarg wins so the two cannot disagree. """ if not isinstance(idempotency_key, str) or not idempotency_key: raise ValueError("idempotency_key must be a non-empty string") body_dict = {**payload, "idempotency_key": idempotency_key} # Byte-exact serialization — this is the ONLY representation that # gets signed AND posted. Do not allow an httpx `json=` path anywhere # in the stack because it would reserialize and break the digest. body = json.dumps(body_dict).encode("utf-8") if len(body) > _MAX_BODY_BYTES: raise ValueError( f"serialized webhook body is {len(body):,} bytes, over the " f"{_MAX_BODY_BYTES:,}-byte cap. Split into smaller webhooks " "or use batch-reporting endpoints." ) return await self._send_bytes( url=url, body=body, idempotency_key=idempotency_key, extra_headers=extra_headers, ) async def resend(self, result: WebhookDeliveryResult) -> WebhookDeliveryResult: """Replay an earlier delivery under a fresh signature. The bytes are identical (same ``idempotency_key``, same payload fields, same serialization) — only the Signature / Signature-Input / Content-Digest headers are regenerated. The receiver dedupes via ``idempotency_key``, so the replayed event is a spec-correct retry that won't cause double-processing. """ if not result.sent_body: raise ValueError( "cannot resend: result has no captured sent_body (likely constructed " "externally). Call a send_* method on this sender first." ) return await self._send_bytes( url=result.url, body=result.sent_body, idempotency_key=result.idempotency_key, extra_headers=result.sent_extra_headers or None, ) async def _send_bytes( self, *, url: str, body: bytes, idempotency_key: str, extra_headers: Mapping[str, str] | None, ) -> WebhookDeliveryResult: """Sign + POST a pre-serialized body through an SSRF-validated transport. When the sender owns its httpx client (the default — ``client=None`` was passed to ``__init__``), every delivery builds a per-request :class:`adcp.signing.ip_pinned_transport.AsyncIpPinnedTransport` that resolves the destination, runs the full SSRF range check (loopback / RFC 1918 / link-local / CGNAT / IPv6 ULA / multicast / cloud metadata), enforces the port allowlist, and pins the connection to the validated IP. This closes the DNS-rebinding TOCTOU between validate and connect. When the operator supplied their own client (``WebhookSender(client=...)`` — typically a vetted egress proxy with mTLS to a known buyer set, or an ASGI transport for testing), the sender trusts the operator's transport completely. Pin-and-bind is skipped; the operator's transport owns SSRF. On the owned-client path, SSRF validation runs **before** signing so a hostile URL is rejected without first generating an Ed25519/ES256 signature over the body. That signature would otherwise sit in process memory until the SSRF rejection — anything that snapshots locals on exception (faulthandler, custom logging) could capture it. Validate first, sign second. Transport hooks run before SSRF; the rewritten URL is what gets validated, signed, and POSTed. The signature covers the URL the request actually lands at, not the URL the caller typed — otherwise a receiver computing ``@target-uri`` from its observed Host header would see a different value and verification would fail. The hook output is bounded (hostname-only rewrite, scheme and port preserved), so this can't widen the destination space. """ effective_url = apply_hooks(url, self._transport_hooks) # Build the pinned transport up-front for the owned-client path. # SSRF + port validation runs against the *post-hook* URL — the # one we'll actually connect to. A hostile URL raises # SSRFValidationError here and the body never gets signed (no # signature material to leak via faulthandler / custom logging # on exception). transport: AsyncIpPinnedTransport | None = None if self._owns_client: transport = build_async_ip_pinned_transport( effective_url, allow_private=self._allow_private_destinations, allowed_ports=self._allowed_destination_ports, ) base_headers = {"Content-Type": "application/json"} auth_headers = self._auth.build_auth_headers(method="POST", url=effective_url, body=body) headers = merge_extra_headers( base={**base_headers, **auth_headers}, extra=extra_headers, reserved=self._auth.reserved_headers(), ) if transport is not None: # Owned-client path. ``trust_env=False`` prevents httpx from # routing the request through ``HTTPS_PROXY`` / ``HTTP_PROXY`` # env vars — every other pinned-transport callsite in the # codebase sets this for the same reason (default_jwks_fetcher, # async_default_jwks_fetcher, revocation_fetcher). Without it, # an attacker who controls process env can route the signed # webhook through their endpoint, defeating the IP pin entirely. async with httpx.AsyncClient( transport=transport, timeout=self._timeout, follow_redirects=False, trust_env=False, ) as client: response = await client.post(effective_url, content=body, headers=headers) else: # Operator-supplied client — they own the SSRF guarantees on # their transport (proxy allowlist, mTLS, etc.). Reachable as # None after aclose(); explicit raise survives ``python -O`` # which would strip an assert. if self._client is None: raise RuntimeError( "WebhookSender's operator-supplied client was already " "closed. Construct a new sender or pass a fresh client." ) response = await self._client.post(effective_url, content=body, headers=headers) return WebhookDeliveryResult( status_code=response.status_code, idempotency_key=idempotency_key, url=effective_url, response_headers=dict(response.headers), response_body=response.content, sent_body=body, sent_extra_headers=dict(extra_headers) if extra_headers else {}, )Outbound signed-webhook delivery client.
Owns one webhook-signing private key. Reuses a single :class:
httpx.AsyncClientacross requests for connection pooling — pass your own viaclient=if you want to share it with other SDK surfaces.Thread/task safety: safe to call concurrent
send_*from many asyncio tasks. The underlyinghttpx.AsyncClientmanages its own pool.Construct a sender wired to RFC 9421 JWK signing.
The HMAC and bearer modes are reached via :meth:
from_bearer_token, :meth:from_adcp_legacy_hmac, and :meth:from_standard_webhooks_secret— those classmethods bypass this initializer through :meth:_from_strategybecause their key material has different types (bytes/strrather thanPrivateKey).transport_hooksruns URL rewrites before SSRF validation — see :class:DockerLocalhostRewritefor the canonical use case. SSRF remains authoritative on the rewritten URL; hooks cannot punch through the range check.Static methods
def from_adcp_legacy_hmac(secret: bytes,
*,
key_id: str,
client: httpx.AsyncClient | None = None,
timeout_seconds: float = 10.0,
allow_private_destinations: bool = False,
allowed_destination_ports: frozenset[int] | None = None,
transport_hooks: tuple[TransportHook, ...] = ()) ‑> WebhookSender-
Build a sender wired to AdCP-legacy HMAC-SHA256.
Wire format matches :func:
verify_webhook_hmac():X-AdCP-Signature: sha256=<hex>overf"{timestamp}.{body}", withX-AdCP-Timestampset fresh per delivery (resends produce a new signature over the same body).secretis the raw HMAC key — the AdCP-legacy scheme has no canonical encoding, so callers pass bytes directly.key_idis echoed inX-AdCP-Key-Idfor receiver-side multi-key rotation; it is not used in the signature itself.AdCP-legacy HMAC will be removed in AdCP 4.0 — operators SHOULD migrate to JWK signing (:meth:
from_jwk) ahead of that boundary. def from_bearer_token(token: str,
*,
client: httpx.AsyncClient | None = None,
timeout_seconds: float = 10.0,
allow_private_destinations: bool = False,
allowed_destination_ports: frozenset[int] | None = None,
transport_hooks: tuple[TransportHook, ...] = ()) ‑> WebhookSender-
Build a sender that POSTs with
Authorization: Bearer <token>.For buyers who authenticate the sender at the gateway and don't verify body signatures. The sender's marshaling guarantees still apply (byte-exact JSON, idempotency_key in body); body signing is skipped.
A buyer treating bearer tokens as the sole authenticity signal SHOULD also enforce TLS/mTLS at the transport layer — a stolen token is a complete forgery. Prefer JWK signing (:meth:
from_jwk) for AdCP-conformant deliveries. def from_jwk(jwk: Mapping[str, Any],
*,
d_field: str = 'd',
client: httpx.AsyncClient | None = None,
timeout_seconds: float = 10.0,
allow_private_destinations: bool = False,
allowed_destination_ports: frozenset[int] | None = None,
transport_hooks: tuple[TransportHook, ...] = ()) ‑> WebhookSender-
Construct from a JWK that includes the private scalar.
The JWK MUST have
adcp_use == "webhook-signing"— the sender doesn't validate this (you're signing with your own key; validation happens at the receiver), but a key whose adcp_use is wrong will be rejected by every conformant verifier.allow_private_destinationsandallowed_destination_portsforward to :meth:__init__— see that signature for semantics. def from_pem(pem_path: str | Path | bytes,
*,
key_id: str,
alg: str = 'ed25519',
passphrase: bytes | None = None,
client: httpx.AsyncClient | None = None,
timeout_seconds: float = 10.0,
allow_private_destinations: bool = False,
allowed_destination_ports: frozenset[int] | None = None,
transport_hooks: tuple[TransportHook, ...] = ()) ‑> WebhookSender-
Load a private key from a PEM file and bind it as a webhook sender.
Companion to
adcp-keygen --purpose webhook-signing, which writes the PEM and prints the public JWK. The JWK is published at yourjwks_uri; the PEM holds the private key material.from_pemreads the PEM, constructs the rightPrivateKeytype foralg, and returns a sender ready to send.Args
pem_path- Path to the PKCS#8 PEM, or the PEM bytes directly.
key_id- JWK
kidclaim — must match the published JWK. alg- Signature algorithm.
ed25519(default) ores256. Also accepts the RFC 9421 formecdsa-p256-sha256. passphrase- Required if the PEM is encrypted
(
adcp-keygen --encrypt). client- Optional pre-built :class:
httpx.AsyncClientto share across the SDK; the sender owns its own client when omitted. timeout_seconds- Per-request timeout for the owned client.
allow_private_destinations- Forwarded to :meth:
__init__. allowed_destination_ports- Forwarded to :meth:
__init__.
Raises
ValueErroralgis not ed25519 / es256, or the PEM contains a key whose type doesn't matchalg.
def from_standard_webhooks_secret(secret: str,
*,
key_id: str,
client: httpx.AsyncClient | None = None,
timeout_seconds: float = 10.0,
allow_private_destinations: bool = False,
allowed_destination_ports: frozenset[int] | None = None,
transport_hooks: tuple[TransportHook, ...] = ()) ‑> WebhookSender-
Build a sender wired to standardwebhooks.com v1 (Svix/Resend interop).
secretis the canonicalwhsec_<base64>form distributed by buyers running Svix, Resend, or any other Standard Webhooks verifier. The constructor base64-decodes the prefix-stripped payload internally — passing the literalwhsec_…to :meth:from_adcp_legacy_hmacwould silently produce signatures Svix rejects, which is exactly the footgun this typed split prevents.Wire format per spec:
webhook-id/webhook-timestamp/webhook-signature: v1,<base64>overf"{webhook_id}.{webhook_timestamp}.{body}". Each delivery gets a freshwebhook-idso a receiver using webhook-id for its own replay cache doesn't false-positive on a legitimate retry — :meth:resendre-signs and gets a new id.
Instance variables
prop signs_with_rfc9421 : bool-
Expand source code
@property def signs_with_rfc9421(self) -> bool: """``True`` iff this sender uses the RFC 9421 webhook-signing profile. Boot-time validators read this to enforce the ``webhook_signing.supported=true`` capability invariant: capabilities advertise RFC 9421 → wired sender must produce ``Signature`` / ``Signature-Input`` headers. ``from_bearer_token``, ``from_adcp_legacy_hmac``, and ``from_standard_webhooks_secret`` senders return ``False``. """ return isinstance(self._auth, JwkSignerStrategy)Trueiff this sender uses the RFC 9421 webhook-signing profile.Boot-time validators read this to enforce the
webhook_signing.supported=truecapability invariant: capabilities advertise RFC 9421 → wired sender must produceSignature/Signature-Inputheaders.from_bearer_token,from_adcp_legacy_hmac, andfrom_standard_webhooks_secretsenders returnFalse.
Methods
async def aclose(self) ‑> None-
Expand source code
async def aclose(self) -> None: """Close the internal httpx client if we own it.""" if self._owns_client and self._client is not None: await self._client.aclose() self._client = NoneClose the internal httpx client if we own it.
async def resend(self,
result: WebhookDeliveryResult) ‑> WebhookDeliveryResult-
Expand source code
async def resend(self, result: WebhookDeliveryResult) -> WebhookDeliveryResult: """Replay an earlier delivery under a fresh signature. The bytes are identical (same ``idempotency_key``, same payload fields, same serialization) — only the Signature / Signature-Input / Content-Digest headers are regenerated. The receiver dedupes via ``idempotency_key``, so the replayed event is a spec-correct retry that won't cause double-processing. """ if not result.sent_body: raise ValueError( "cannot resend: result has no captured sent_body (likely constructed " "externally). Call a send_* method on this sender first." ) return await self._send_bytes( url=result.url, body=result.sent_body, idempotency_key=result.idempotency_key, extra_headers=result.sent_extra_headers or None, )Replay an earlier delivery under a fresh signature.
The bytes are identical (same
idempotency_key, same payload fields, same serialization) — only the Signature / Signature-Input / Content-Digest headers are regenerated. The receiver dedupes viaidempotency_key, so the replayed event is a spec-correct retry that won't cause double-processing. async def send_artifact_webhook(self,
*,
url: str,
media_buy_id: str,
batch_id: str,
timestamp: str,
artifacts: list[dict[str, Any]],
idempotency_key: str | None = None,
extra_headers: Mapping[str, str] | None = None) ‑> WebhookDeliveryResult-
Expand source code
async def send_artifact_webhook( self, *, url: str, media_buy_id: str, batch_id: str, timestamp: str, artifacts: list[dict[str, Any]], idempotency_key: str | None = None, extra_headers: Mapping[str, str] | None = None, ) -> WebhookDeliveryResult: """POST a signed content-standards artifact webhook.""" key = idempotency_key or generate_webhook_idempotency_key() payload: dict[str, Any] = { "idempotency_key": key, "media_buy_id": media_buy_id, "batch_id": batch_id, "timestamp": timestamp, "artifacts": artifacts, } return await self.send_raw( url=url, idempotency_key=key, payload=payload, extra_headers=extra_headers )POST a signed content-standards artifact webhook.
async def send_collection_list_changed(self,
*,
url: str,
list_id: str,
resolved_at: str,
signature: str,
idempotency_key: str | None = None,
extra_headers: Mapping[str, str] | None = None) ‑> WebhookDeliveryResult-
Expand source code
async def send_collection_list_changed( self, *, url: str, list_id: str, resolved_at: str, signature: str, idempotency_key: str | None = None, extra_headers: Mapping[str, str] | None = None, ) -> WebhookDeliveryResult: """POST a signed governance collection-list-changed webhook. ``signature`` is the payload-level signature field that predates 9421 webhook transport signing — it remains required by the schema. The 9421 signature this method adds protects the transport envelope. """ key = idempotency_key or generate_webhook_idempotency_key() payload: dict[str, Any] = { "idempotency_key": key, "event": "collection_list_changed", "list_id": list_id, "resolved_at": resolved_at, "signature": signature, } return await self.send_raw( url=url, idempotency_key=key, payload=payload, extra_headers=extra_headers )POST a signed governance collection-list-changed webhook.
signatureis the payload-level signature field that predates 9421 webhook transport signing — it remains required by the schema. The 9421 signature this method adds protects the transport envelope. async def send_mcp(self,
*,
url: str,
task_id: str,
status: GeneratedTaskStatus | str,
task_type: TaskType | str,
result: AdcpAsyncResponseData | dict[str, Any] | None = None,
timestamp: datetime | None = None,
operation_id: str | None = None,
message: str | None = None,
context_id: str | None = None,
protocol: AdcpProtocol | str | None = None,
idempotency_key: str | None = None,
token: str | None = None,
extra_headers: Mapping[str, str] | None = None) ‑> WebhookDeliveryResult-
Expand source code
async def send_mcp( self, *, url: str, task_id: str, status: GeneratedTaskStatus | str, task_type: TaskType | str, result: AdcpAsyncResponseData | dict[str, Any] | None = None, timestamp: datetime | None = None, operation_id: str | None = None, message: str | None = None, context_id: str | None = None, protocol: AdcpProtocol | str | None = None, idempotency_key: str | None = None, token: str | None = None, extra_headers: Mapping[str, str] | None = None, ) -> WebhookDeliveryResult: """POST a signed MCP-style task-status webhook. On retry, prefer :meth:`resend` over calling this again — ``resend`` replays the exact same bytes, whereas re-invoking ``send_mcp`` with the "same" args would produce a fresh ``timestamp`` and potentially a different serialized body, which the receiver would dedupe but with different observed payload data. :param token: Buyer-supplied token from ``push_notification_config.token`` echoed back on the payload's ``token`` field per spec (``schemas/cache/core/push_notification_config.json``: "Echoed back in webhook payload to validate request authenticity"). Cross-language wire-parity with the JS implementation. """ payload = create_mcp_webhook_payload( task_id=task_id, status=status, task_type=task_type, result=result, timestamp=timestamp, operation_id=operation_id, message=message, context_id=context_id, protocol=protocol, idempotency_key=idempotency_key, token=token, ) return await self.send_raw( url=url, idempotency_key=payload.idempotency_key, payload=to_wire_dict(payload), extra_headers=extra_headers, )POST a signed MCP-style task-status webhook.
On retry, prefer :meth:
resendover calling this again —resendreplays the exact same bytes, whereas re-invokingsend_mcpwith the "same" args would produce a freshtimestampand potentially a different serialized body, which the receiver would dedupe but with different observed payload data.:param token: Buyer-supplied token from
push_notification_config.tokenechoed back on the payload'stokenfield per spec (schemas/cache/core/push_notification_config.json: "Echoed back in webhook payload to validate request authenticity"). Cross-language wire-parity with the JS implementation. async def send_property_list_changed(self,
*,
url: str,
list_id: str,
resolved_at: str,
signature: str,
idempotency_key: str | None = None,
extra_headers: Mapping[str, str] | None = None) ‑> WebhookDeliveryResult-
Expand source code
async def send_property_list_changed( self, *, url: str, list_id: str, resolved_at: str, signature: str, idempotency_key: str | None = None, extra_headers: Mapping[str, str] | None = None, ) -> WebhookDeliveryResult: """POST a signed governance property-list-changed webhook.""" key = idempotency_key or generate_webhook_idempotency_key() payload: dict[str, Any] = { "idempotency_key": key, "event": "property_list_changed", "list_id": list_id, "resolved_at": resolved_at, "signature": signature, } return await self.send_raw( url=url, idempotency_key=key, payload=payload, extra_headers=extra_headers )POST a signed governance property-list-changed webhook.
async def send_raw(self,
*,
url: str,
idempotency_key: str,
payload: dict[str, Any],
extra_headers: Mapping[str, str] | None = None) ‑> WebhookDeliveryResult-
Expand source code
async def send_raw( self, *, url: str, idempotency_key: str, payload: dict[str, Any], extra_headers: Mapping[str, str] | None = None, ) -> WebhookDeliveryResult: """Low-level escape hatch: sign + POST an arbitrary payload. The ``idempotency_key`` kwarg is required and is injected into the payload before signing — the visible signature makes the contract impossible to forget, unlike a runtime dict check. If ``payload`` already carries an ``idempotency_key``, the kwarg wins so the two cannot disagree. """ if not isinstance(idempotency_key, str) or not idempotency_key: raise ValueError("idempotency_key must be a non-empty string") body_dict = {**payload, "idempotency_key": idempotency_key} # Byte-exact serialization — this is the ONLY representation that # gets signed AND posted. Do not allow an httpx `json=` path anywhere # in the stack because it would reserialize and break the digest. body = json.dumps(body_dict).encode("utf-8") if len(body) > _MAX_BODY_BYTES: raise ValueError( f"serialized webhook body is {len(body):,} bytes, over the " f"{_MAX_BODY_BYTES:,}-byte cap. Split into smaller webhooks " "or use batch-reporting endpoints." ) return await self._send_bytes( url=url, body=body, idempotency_key=idempotency_key, extra_headers=extra_headers, )Low-level escape hatch: sign + POST an arbitrary payload.
The
idempotency_keykwarg is required and is injected into the payload before signing — the visible signature makes the contract impossible to forget, unlike a runtime dict check. Ifpayloadalready carries anidempotency_key, the kwarg wins so the two cannot disagree. async def send_revocation_notification(self,
*,
url: str,
rights_id: str,
brand_id: str,
reason: str,
effective_at: str,
idempotency_key: str | None = None,
extra_headers: Mapping[str, str] | None = None) ‑> WebhookDeliveryResult-
Expand source code
async def send_revocation_notification( self, *, url: str, rights_id: str, brand_id: str, reason: str, effective_at: str, idempotency_key: str | None = None, extra_headers: Mapping[str, str] | None = None, ) -> WebhookDeliveryResult: """POST a signed rights-revocation notification.""" key = idempotency_key or generate_webhook_idempotency_key() payload: dict[str, Any] = { "idempotency_key": key, "rights_id": rights_id, "brand_id": brand_id, "reason": reason, "effective_at": effective_at, } return await self.send_raw( url=url, idempotency_key=key, payload=payload, extra_headers=extra_headers )POST a signed rights-revocation notification.
async def send_webhook_challenge(self,
*,
url: str,
account_id: str,
subscriber_id: str,
challenge: str | None = None,
extra_headers: Mapping[str, str] | None = None) ‑> WebhookDeliveryResult-
Expand source code
async def send_webhook_challenge( self, *, url: str, account_id: str, subscriber_id: str, challenge: str | None = None, extra_headers: Mapping[str, str] | None = None, ) -> WebhookDeliveryResult: """POST a signed durable-subscription proof-of-control challenge. The body matches the durable ``notification_configs[]`` challenge shape and intentionally does not inject ``idempotency_key``: ``{"type":"webhook.challenge","challenge":"...", ...}`` Pair this low-level sender method with :func:`adcp.webhooks.challenge_webhook_destination` when you also want URL validation and response echo checking in one call. """ payload = create_webhook_challenge_payload( account_id=account_id, subscriber_id=subscriber_id, challenge=challenge, ) challenge_value = str(payload["challenge"]) body = json.dumps(payload, separators=(",", ":")).encode("utf-8") return await self._send_bytes( url=url, body=body, idempotency_key=challenge_value, extra_headers=extra_headers, )POST a signed durable-subscription proof-of-control challenge.
The body matches the durable
notification_configs[]challenge shape and intentionally does not injectidempotency_key:{"type":"webhook.challenge","challenge":"...", ...}Pair this low-level sender method with :func:
challenge_webhook_destination()when you also want URL validation and response echo checking in one call. async def send_wholesale_feed(self,
*,
url: str,
subscriber_id: str,
account_id: str,
notification_type: str,
wholesale_feed_version: str,
cache_scope: str,
event: WholesaleFeedEvent | Mapping[str, Any],
previous_wholesale_feed_version: str | None = None,
fired_at: datetime | None = None,
idempotency_key: str | None = None,
subscription_event_types: Sequence[Any] | None = None,
extra_headers: Mapping[str, str] | None = None) ‑> WebhookDeliveryResult-
Expand source code
async def send_wholesale_feed( self, *, url: str, subscriber_id: str, account_id: str, notification_type: str, wholesale_feed_version: str, cache_scope: str, event: WholesaleFeedEvent | Mapping[str, Any], previous_wholesale_feed_version: str | None = None, fired_at: datetime | None = None, idempotency_key: str | None = None, subscription_event_types: Sequence[Any] | None = None, extra_headers: Mapping[str, str] | None = None, ) -> WebhookDeliveryResult: """POST a signed account-scoped wholesale feed notification. ``subscription_event_types`` is optional but recommended when the caller is sending to an ``accounts[].notification_configs[]`` entry: pass that entry's ``event_types`` to fail closed if the subscription did not request this notification type. """ if not isinstance(subscriber_id, str) or not subscriber_id: raise ValueError("subscriber_id must be a non-empty string") if not isinstance(account_id, str) or not account_id: raise ValueError("account_id must be a non-empty string") if not isinstance(wholesale_feed_version, str) or not wholesale_feed_version: raise ValueError("wholesale_feed_version must be a non-empty string") event_model = event if not isinstance(event_model, WholesaleFeedEvent): event_model = WholesaleFeedEvent.model_validate(event_model) notification_type_value = _enum_value(notification_type) event_type = _enum_value(event_model.event_type) entity_type = _enum_value(event_model.entity_type) if notification_type_value != event_type: raise ValueError( "notification_type must match event.event_type " f"(got {notification_type_value!r}, event has {event_type!r})" ) if subscription_event_types is not None: allowed_event_types = {_enum_value(item) for item in subscription_event_types} else: allowed_event_types = None if allowed_event_types is not None and notification_type_value not in allowed_event_types: raise ValueError( "notification_type is not present in the subscription's event_types; " "sellers must not silently widen account notification filters" ) expected_entity_type = _entity_type_for_wholesale_notification(notification_type_value) if entity_type != expected_entity_type: raise ValueError( "event.entity_type does not match notification_type " f"(got {entity_type!r}, expected {expected_entity_type!r})" ) cache_scope_value = _enum_value(cache_scope) applies_to = getattr(event_model.payload, "applies_to", None) applies_to_scope = _enum_value(getattr(applies_to, "scope", None)) if applies_to_scope != cache_scope_value: raise ValueError( "cache_scope must match event.payload.applies_to.scope " f"(got {cache_scope_value!r}, event has {applies_to_scope!r})" ) key = idempotency_key or generate_webhook_idempotency_key() timestamp = fired_at or datetime.now(timezone.utc) webhook = WholesaleFeedWebhook.model_validate( { "idempotency_key": key, "notification_id": event_model.event_id, "notification_type": notification_type_value, "fired_at": timestamp, "subscriber_id": subscriber_id, "account_id": account_id, "wholesale_feed_version": wholesale_feed_version, "previous_wholesale_feed_version": previous_wholesale_feed_version, "cache_scope": cache_scope_value, "event": event_model, } ) return await self.send_raw( url=url, idempotency_key=key, payload=webhook.model_dump(mode="json", exclude_none=True), extra_headers=extra_headers, )POST a signed account-scoped wholesale feed notification.
subscription_event_typesis optional but recommended when the caller is sending to anaccounts[].notification_configs[]entry: pass that entry'sevent_typesto fail closed if the subscription did not request this notification type. async def send_wholesale_feed_to_subscription(self,
*,
subscription: NotificationConfig | Mapping[str, Any],
account_id: str,
notification_type: str,
wholesale_feed_version: str,
cache_scope: str,
event: WholesaleFeedEvent | Mapping[str, Any],
previous_wholesale_feed_version: str | None = None,
fired_at: datetime | None = None,
idempotency_key: str | None = None,
extra_headers: Mapping[str, str] | None = None) ‑> WebhookDeliveryResult-
Expand source code
async def send_wholesale_feed_to_subscription( self, *, subscription: NotificationConfig | Mapping[str, Any], account_id: str, notification_type: str, wholesale_feed_version: str, cache_scope: str, event: WholesaleFeedEvent | Mapping[str, Any], previous_wholesale_feed_version: str | None = None, fired_at: datetime | None = None, idempotency_key: str | None = None, extra_headers: Mapping[str, str] | None = None, ) -> WebhookDeliveryResult: """POST a wholesale feed notification to a ``NotificationConfig``. This convenience wrapper keeps ``url``, ``subscriber_id``, and ``event_types`` coupled to the same persisted subscription entry. """ config = ( subscription if isinstance(subscription, NotificationConfig) else NotificationConfig.model_validate(subscription) ) return await self.send_wholesale_feed( url=str(config.url), subscriber_id=config.subscriber_id, account_id=account_id, notification_type=notification_type, wholesale_feed_version=wholesale_feed_version, cache_scope=cache_scope, event=event, previous_wholesale_feed_version=previous_wholesale_feed_version, fired_at=fired_at, idempotency_key=idempotency_key, subscription_event_types=config.event_types, extra_headers=extra_headers, )POST a wholesale feed notification to a
NotificationConfig.This convenience wrapper keeps
url,subscriber_id, andevent_typescoupled to the same persisted subscription entry.
class WebhookVerifyOptions (*,
jwks_resolver: JwksResolver,
replay_store: ReplayStore | None = None,
revocation_checker: RevocationChecker | None = None,
revocation_list: RevocationList | None = None,
max_skew_seconds: int = 60,
max_window_seconds: int = 300,
label: str = 'sig1',
allowed_algs: frozenset[str] = frozenset({'ecdsa-p256-sha256', 'ed25519'}),
sender_url: str | None = None,
clock: Callable[[], float] = <built-in function time>)-
Expand source code
@dataclass(frozen=True, kw_only=True) class WebhookVerifyOptions: """Options for the webhook verifier. Subset of :class:`VerifyOptions` — several fields are pinned (tag, adcp_use, content-digest policy) because the webhook profile doesn't leave them as caller choices. Unlike the request verifier, there is no ``now`` field — the webhook verifier stamps time-of-check itself, so the same :class:`WebhookVerifyOptions` instance can live for the lifetime of your receiver without a factory closure around it. Override via ``clock=`` for deterministic tests. """ jwks_resolver: JwksResolver replay_store: ReplayStore | None = None revocation_checker: RevocationChecker | None = None revocation_list: RevocationList | None = None max_skew_seconds: int = DEFAULT_SKEW_SECONDS max_window_seconds: int = MAX_WINDOW_SECONDS label: str = SIG_LABEL_DEFAULT allowed_algs: frozenset[str] = ALLOWED_ALGS sender_url: str | None = None clock: Callable[[], float] = time.timeOptions for the webhook verifier.
Subset of :class:
VerifyOptions— several fields are pinned (tag, adcp_use, content-digest policy) because the webhook profile doesn't leave them as caller choices.Unlike the request verifier, there is no
nowfield — the webhook verifier stamps time-of-check itself, so the same :class:WebhookVerifyOptionsinstance can live for the lifetime of your receiver without a factory closure around it. Override viaclock=for deterministic tests.Instance variables
var allowed_algs : frozenset[str]var jwks_resolver : JwksResolvervar label : strvar max_skew_seconds : intvar max_window_seconds : intvar replay_store : ReplayStore | Nonevar revocation_checker : RevocationChecker | Nonevar revocation_list : RevocationList | Nonevar sender_url : str | None
Methods
def clock(...) ‑> Callable[[], float]-
time() -> floating point number
Return the current time in seconds since the Epoch. Fractions of a second may be present if the system clock provides them.