@@ -269,14 +269,14 @@ async with ADCPMultiAgentClient(
269269 print (f " ✅ Sync completion: { len (result.data.products)} products " )
270270
271271 if result.status == " submitted" :
272- # Agent will send webhook when complete
272+ # Poll or let the seller's external durable publisher send the webhook.
273273 print (f " ⏳ Async - webhook registered at: { result.submitted.webhook_url} " )
274274# Connections automatically cleaned up here
275275```
276276
277277## AdCP version support
278278
279- The SDK 8 beta line is built against ** AdCP 3.2.0-beta.4 ** , makes canonical
279+ The SDK 8 beta line is built against ** AdCP 3.2.0-beta.5 ** , makes canonical
280280creatives the primary Python contract, and negotiates AdCP 3.0, 3.1, and the
281281exact 3.2 beta wire dialect. The SDK package version and protocol version are
282282intentionally independent:
@@ -285,7 +285,7 @@ intentionally independent:
285285import adcp
286286
287287adcp.get_adcp_sdk_version() # SDK package version, e.g. "8.0.0b1"
288- adcp.get_adcp_spec_version() # AdCP spec this build targets, e.g. "3.2.0-beta.4 "
288+ adcp.get_adcp_spec_version() # AdCP spec this build targets, e.g. "3.2.0-beta.5 "
289289```
290290
291291If you talk to an agent on a newer spec than this SDK validates, the response
@@ -547,10 +547,10 @@ For the protocol-default RFC 9421 mode, use `WebhookReceiver` as shown below.
547547
548548### Signed webhooks (AdCP 3.0): receiver quickstart
549549
550- AdCP 3.0 webhooks are signed under the RFC 9421 profile
550+ AdCP webhooks are signed under the RFC 9421 profile
551551(` adcp/webhook-signing/v1 ` ) and carry a required ` idempotency_key ` for
552- at-least-once dedup . The ` WebhookReceiver ` packages verify + dedupe + parse
553- into one call so you don't have to re-derive the normative checklist :
552+ at-least-once delivery . The ` WebhookReceiver ` packages verification, immutable
553+ key-to-payload binding, claim ownership, and parsing into one call :
554554
555555``` python
556556from flask import Flask, request, Response
@@ -562,14 +562,24 @@ from adcp.webhooks import (
562562 WebhookVerifyOptions,
563563)
564564
565- # One resolver per publisher. In production, wire an async JWKS fetcher
566- # pointed at the publisher's `adagents.json`.
565+ # Discover this value from the publisher's
566+ # webhook_signing.delivery_retry_horizon_seconds capability. Receiver proof
567+ # must live for at least the whole advertised retry horizon.
568+ publisher_retry_horizon_seconds = 86400
569+
570+ # One resolver per publisher. In production, wire an async JWKS fetcher and a
571+ # durable multi-process backend (PgBackend rather than MemoryBackend).
567572jwks = StaticJwksResolver(publisher_jwks_dict)
568573
569574receiver = WebhookReceiver(
570575 config = WebhookReceiverConfig(
571576 verify_options = WebhookVerifyOptions(jwks_resolver = jwks),
572- dedup = WebhookDedupStore(MemoryBackend(), ttl_seconds = 86400 ),
577+ dedup = WebhookDedupStore(
578+ MemoryBackend(), # local example only; use PgBackend in production
579+ ttl_seconds = publisher_retry_horizon_seconds,
580+ ),
581+ receiver_scope = " buyer-account-123" ,
582+ publisher_scope_for = lambda _signer : " seller-agent-456" ,
573583 ),
574584)
575585
@@ -581,13 +591,16 @@ async def hook():
581591 method = request.method, url = request.url,
582592 headers = dict (request.headers), body = request.get_data(),
583593 )
584- if outcome.rejected:
585- return Response(status = 401 , headers = outcome.response_headers)
586- # Spec: MUST return 2xx on duplicates so the at-least-once sender stops
587- # retrying. A duplicate is a no-op, not an error.
588- if outcome.duplicate:
589- return Response(status = 200 )
590- process(outcome.payload) # typed McpWebhookPayload
594+ # 409 for same-key/different-payload, 503 for an identical delivery still
595+ # being processed, 2xx only for a durably completed exact duplicate.
596+ if outcome.http_status is not None :
597+ return Response(status = outcome.http_status, headers = outcome.response_headers)
598+ try :
599+ await process(outcome.payload) # typed McpWebhookPayload
600+ except Exception :
601+ await receiver.release(outcome) # exact retry may claim again
602+ raise
603+ await receiver.acknowledge(outcome) # durable proof after publication
591604 return Response(status = 200 )
592605```
593606
@@ -599,7 +612,12 @@ from adcp.webhooks import LegacyHmacFallback
599612
600613config = WebhookReceiverConfig(
601614 verify_options = WebhookVerifyOptions(jwks_resolver = jwks),
602- dedup = WebhookDedupStore(MemoryBackend(), ttl_seconds = 86400 ),
615+ dedup = WebhookDedupStore(
616+ MemoryBackend(), # local example only; use PgBackend in production
617+ ttl_seconds = publisher_retry_horizon_seconds,
618+ ),
619+ receiver_scope = " buyer-account-123" ,
620+ publisher_scope_for = lambda _signer : " seller-agent-456" ,
603621 legacy_hmac = LegacyHmacFallback.from_shared_secret(
604622 secret = os.environ[" WEBHOOK_SHARED_SECRET" ].encode(),
605623 sender_identity = " publisher-buyerco" ,
@@ -624,6 +642,7 @@ async with sender:
624642 url = " https://buyer.example.com/webhooks/adcp/create_media_buy/op_abc" ,
625643 task_id = " task_456" ,
626644 task_type = " create_media_buy" ,
645+ operation_id = " op_abc" ,
627646 status = " completed" ,
628647 result = {" media_buy_id" : " mb_1" },
629648 )
0 commit comments