5656 WebhookVerifyOptions ,
5757 verify_webhook_signature ,
5858)
59- from adcp .types import GeneratedTaskStatus
59+ from adcp .types import GeneratedTaskStatus , McpWebhookPayload , TaskType
6060from adcp .types .base import AdCPBaseModel
6161from adcp .webhook_receiver import (
6262 LegacyHmacFallback ,
@@ -86,72 +86,87 @@ def generate_webhook_idempotency_key() -> str:
8686def create_mcp_webhook_payload (
8787 task_id : str ,
8888 status : GeneratedTaskStatus | str ,
89+ task_type : TaskType | str ,
90+ * ,
8991 result : PydanticBaseModel | dict [str , Any ] | None = None ,
9092 timestamp : datetime | None = None ,
91- task_type : str | None = None ,
9293 operation_id : str | None = None ,
9394 message : str | None = None ,
9495 context_id : str | None = None ,
9596 domain : str | None = None ,
9697 idempotency_key : str | None = None ,
9798 token : str | None = None ,
98- ) -> dict [ str , Any ] :
99+ ) -> McpWebhookPayload :
99100 """
100- Create MCP webhook payload dictionary .
101+ Build an :class:`McpWebhookPayload` for a tracked async task .
101102
102- This function helps agent implementations construct properly formatted
103- webhook payloads for sending to clients.
103+ Pair with :func:`to_wire_dict` for HTTP transport — Pydantic-typed at
104+ construction so the publisher catches schema drift before it leaves
105+ the process.
106+
107+ ``task_type`` is restricted to the closed :class:`TaskType` enum (the
108+ spec's complete set of async/tracked operations). Synchronous-only
109+ operations (e.g. ``get_products``, ``list_creatives``) are not in the
110+ enum because they don't go through the task management system —
111+ passing them would have produced a webhook payload the receiver would
112+ reject as schema-invalid.
104113
105114 Args:
106- task_id: Unique identifier for the task
107- status: Current task status
108- task_type: Optionally type of AdCP operation (e.g., "get_products", "create_media_buy")
109- timestamp: When the webhook was generated (defaults to current UTC time)
110- result: Task-specific payload — any Pydantic model or plain dict
111- operation_id: Client-generated identifier the buyer embedded in the
112- webhook URL when registering push-notification config. Publishers
113- MUST echo this back in the payload so buyers correlate notifications
114- without parsing URL paths (per ``mcp-webhook-payload.json``).
115- Senders extracting the value from the URL path on emission populate
116- this field; callers constructing payloads directly pass it through.
117- message: Human-readable summary of task state
118- context_id: Session/conversation identifier
119- domain: AdCP domain this task belongs to
120- idempotency_key: Sender-generated key stable across retries of the same
121- event. Defaults to a freshly-generated UUID v4 — callers retrying
122- delivery of the same event MUST pass the key from their first
123- attempt; passing None twice mints two keys and defeats dedup.
115+ task_id: Unique identifier for the task.
116+ status: Current task status.
117+ task_type: Type of AdCP async operation (see :class:`TaskType`).
118+ result: Task-specific payload — any Pydantic model or plain dict.
119+ Plain dicts are validated against
120+ :class:`AdcpAsyncResponseData`'s discriminated union.
121+ timestamp: When the webhook was generated. Defaults to current UTC.
122+ operation_id: Client-generated identifier the buyer embedded in
123+ the webhook URL when registering push-notification config.
124+ Publishers MUST echo this back so buyers correlate
125+ notifications without parsing URL paths.
126+ message: Human-readable summary of task state.
127+ context_id: Session/conversation identifier.
128+ domain: AdCP domain this task belongs to.
129+ idempotency_key: Sender-generated key stable across retries of the
130+ same event. Defaults to a freshly-generated UUID v4 — callers
131+ retrying delivery of the same event MUST pass the key from
132+ their first attempt; passing None twice mints two keys and
133+ defeats dedup.
134+ token: Buyer-supplied token from ``push_notification_config.token``,
135+ echoed back per spec for authenticity validation.
124136
125137 Returns:
126- Dictionary matching McpWebhookPayload schema, ready to be sent as JSON
138+ :class:`McpWebhookPayload` instance. Use :func:`to_wire_dict` (or
139+ ``payload.model_dump(mode="json", exclude_none=True)``) to get the
140+ JSON-ready dict for HTTP transport.
127141
128142 Examples:
129143 Create a completed webhook with results:
130- >>> from adcp.webhooks import create_mcp_webhook_payload
144+ >>> from adcp.webhooks import create_mcp_webhook_payload, to_wire_dict
131145 >>> from adcp.types import GeneratedTaskStatus
132146 >>>
133147 >>> payload = create_mcp_webhook_payload(
134148 ... task_id="task_123",
135- ... task_type="get_products",
136149 ... status=GeneratedTaskStatus.completed,
137- ... result={"products": [...]},
138- ... message="Found 5 products"
150+ ... task_type="create_media_buy",
151+ ... result={"media_buy_id": "mb_1", "buyer_ref": "ref_1"},
152+ ... message="Created campaign"
139153 ... )
154+ >>> wire = to_wire_dict(payload)
140155
141156 Create a failed webhook with error:
142157 >>> payload = create_mcp_webhook_payload(
143158 ... task_id="task_456",
144- ... task_type="create_media_buy",
145159 ... status=GeneratedTaskStatus.failed,
160+ ... task_type="create_media_buy",
146161 ... result={"errors": [{"code": "INVALID_INPUT", "message": "..."}]},
147162 ... message="Validation failed"
148163 ... )
149164
150165 Create a working status update:
151166 >>> payload = create_mcp_webhook_payload(
152167 ... task_id="task_789",
153- ... task_type="sync_creatives",
154168 ... status=GeneratedTaskStatus.working,
169+ ... task_type="sync_creatives",
155170 ... message="Processing 3 of 10 creatives"
156171 ... )
157172 """
@@ -160,48 +175,42 @@ def create_mcp_webhook_payload(
160175 if idempotency_key is None :
161176 idempotency_key = generate_webhook_idempotency_key ()
162177
163- # Convert status enum to string value
164178 status_value = status .value if hasattr (status , "value" ) else str (status )
165179
166- # Build payload matching McpWebhookPayload schema
167- payload : dict [str , Any ] = {
168- "idempotency_key" : idempotency_key ,
169- "task_id" : task_id ,
170- "task_type" : task_type ,
171- "status" : status_value ,
172- "timestamp" : timestamp .isoformat () if isinstance (timestamp , datetime ) else timestamp ,
173- }
174-
175- # Add optional fields only if provided
176- if result is not None :
177- # Convert Pydantic model to dict if needed for JSON serialization
178- if hasattr (result , "model_dump" ):
179- payload ["result" ] = result .model_dump (mode = "json" )
180- else :
181- payload ["result" ] = result
182-
183- if operation_id is not None :
184- payload ["operation_id" ] = operation_id
185-
186- if message is not None :
187- payload ["message" ] = message
188-
189- if context_id is not None :
190- payload ["context_id" ] = context_id
180+ # Foreign BaseModel subclasses (anything outside AdcpAsyncResponseData)
181+ # don't match the discriminated-union variants by identity — dump to a
182+ # dict so the union picks by shape, matching the dict path.
183+ result_value : PydanticBaseModel | dict [str , Any ] | None
184+ if isinstance (result , PydanticBaseModel ):
185+ result_value = result .model_dump (mode = "json" )
186+ else :
187+ result_value = result
191188
189+ # `domain` and `token` aren't in the schema but are accepted via
190+ # `extra='allow'`; they round-trip through `model_dump`.
191+ extras : dict [str , Any ] = {}
192192 if domain is not None :
193- payload ["domain" ] = domain
194-
193+ extras ["domain" ] = domain
195194 if token is not None :
196195 # Buyer-supplied token from push_notification_config.token,
197196 # echoed back per push-notification-config.json spec text:
198197 # "Echoed back in webhook payload to validate request authenticity."
199- # Cross-language wire-parity with the JS implementation
200- # (``buildTaskWebhookPayload`` in ``from-platform.ts``) — buyers
201- # validating against the spec read body.token, not headers.
202- payload ["token" ] = token
198+ extras ["token" ] = token
203199
204- return payload
200+ return McpWebhookPayload .model_validate (
201+ {
202+ "idempotency_key" : idempotency_key ,
203+ "task_id" : task_id ,
204+ "task_type" : task_type ,
205+ "status" : status_value ,
206+ "timestamp" : timestamp ,
207+ "operation_id" : operation_id ,
208+ "message" : message ,
209+ "context_id" : context_id ,
210+ "result" : result_value ,
211+ ** extras ,
212+ }
213+ )
205214
206215
207216def get_adcp_signed_headers_for_webhook (
@@ -245,9 +254,9 @@ def get_adcp_signed_headers_for_webhook(
245254 >>>
246255 >>> payload = create_mcp_webhook_payload(
247256 ... task_id="task_123",
248- ... task_type="get_products",
249257 ... status="completed",
250- ... result={"products": [...]}
258+ ... task_type="create_media_buy",
259+ ... result={"media_buy_id": "mb_1"},
251260 ... )
252261 >>> headers = {"Content-Type": "application/json"}
253262 >>> signed_headers = get_adcp_signed_headers_for_webhook(
0 commit comments