@@ -1893,22 +1893,29 @@ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
18931893 await self .close ()
18941894
18951895 def _verify_webhook_signature (
1896- self , payload : dict [str , Any ], signature : str , timestamp : str
1896+ self ,
1897+ payload : dict [str , Any ],
1898+ signature : str ,
1899+ timestamp : str ,
1900+ raw_body : bytes | str | None = None ,
18971901 ) -> bool :
18981902 """
18991903 Verify HMAC-SHA256 signature of webhook payload.
19001904
19011905 The verification algorithm matches get_adcp_signed_headers_for_webhook:
19021906 1. Constructs message as "{timestamp}.{json_payload}"
1903- 2. JSON-serializes payload with compact separators
1904- 3. UTF-8 encodes the message
1907+ 2. Uses raw HTTP body bytes when available (preserves sender's serialization)
1908+ 3. Falls back to json.dumps() if raw_body not provided
19051909 4. HMAC-SHA256 signs with the shared secret
19061910 5. Compares against the provided signature (with "sha256=" prefix stripped)
19071911
19081912 Args:
1909- payload: Webhook payload dict
1913+ payload: Webhook payload dict (used as fallback if raw_body not provided)
19101914 signature: Signature to verify (with or without "sha256=" prefix)
1911- timestamp: ISO 8601 timestamp from X-AdCP-Timestamp header
1915+ timestamp: Unix timestamp in seconds from X-AdCP-Timestamp header
1916+ raw_body: Raw HTTP request body bytes. When provided, used directly
1917+ for signature verification to avoid cross-language serialization
1918+ mismatches. Strongly recommended for production use.
19121919
19131920 Returns:
19141921 True if signature is valid, False otherwise
@@ -1920,11 +1927,15 @@ def _verify_webhook_signature(
19201927 if signature .startswith ("sha256=" ):
19211928 signature = signature [7 :]
19221929
1923- # Serialize payload to JSON with consistent formatting (matches signing)
1924- payload_bytes = json .dumps (payload , separators = ("," , ":" ), sort_keys = False ).encode ("utf-8" )
1930+ # Use raw body if available (avoids cross-language serialization mismatches),
1931+ # otherwise fall back to json.dumps() for backward compatibility
1932+ if raw_body is not None :
1933+ payload_str = raw_body .decode ("utf-8" ) if isinstance (raw_body , bytes ) else raw_body
1934+ else :
1935+ payload_str = json .dumps (payload )
19251936
1926- # Construct signed message: timestamp.payload (matches get_adcp_signed_headers_for_webhook)
1927- signed_message = f"{ timestamp } .{ payload_bytes . decode ( 'utf-8' ) } "
1937+ # Construct signed message: timestamp.payload
1938+ signed_message = f"{ timestamp } .{ payload_str } "
19281939
19291940 # Generate expected signature
19301941 expected_signature = hmac .new (
@@ -2073,6 +2084,7 @@ async def _handle_mcp_webhook(
20732084 operation_id : str ,
20742085 signature : str | None ,
20752086 timestamp : str | None = None ,
2087+ raw_body : bytes | str | None = None ,
20762088 ) -> TaskResult [AdcpAsyncResponseData ]:
20772089 """
20782090 Handle MCP webhook delivered via HTTP POST.
@@ -2082,7 +2094,8 @@ async def _handle_mcp_webhook(
20822094 task_type: Task type from application routing
20832095 operation_id: Operation identifier from application routing
20842096 signature: Optional HMAC-SHA256 signature for verification (X-AdCP-Signature header)
2085- timestamp: Optional timestamp for signature verification (X-AdCP-Timestamp header)
2097+ timestamp: Optional Unix timestamp for signature verification (X-AdCP-Timestamp header)
2098+ raw_body: Optional raw HTTP request body for signature verification
20862099
20872100 Returns:
20882101 TaskResult with parsed task-specific response data
@@ -2097,7 +2110,7 @@ async def _handle_mcp_webhook(
20972110 if (
20982111 signature
20992112 and timestamp
2100- and not self ._verify_webhook_signature (payload , signature , timestamp )
2113+ and not self ._verify_webhook_signature (payload , signature , timestamp , raw_body )
21012114 ):
21022115 logger .warning (
21032116 f"Webhook signature verification failed for agent { self .agent_config .id } "
@@ -2283,6 +2296,7 @@ async def handle_webhook(
22832296 operation_id : str ,
22842297 signature : str | None = None ,
22852298 timestamp : str | None = None ,
2299+ raw_body : bytes | str | None = None ,
22862300 ) -> TaskResult [AdcpAsyncResponseData ]:
22872301 """
22882302 Handle incoming webhook and return typed result.
@@ -2310,8 +2324,12 @@ async def handle_webhook(
23102324 Used to correlate webhook notifications with original task submission.
23112325 signature: Optional HMAC-SHA256 signature for MCP webhook verification
23122326 (X-AdCP-Signature header). Ignored for A2A webhooks.
2313- timestamp: Optional timestamp for MCP webhook signature verification
2314- (X-AdCP-Timestamp header). Required when signature is provided.
2327+ timestamp: Optional Unix timestamp (seconds) for MCP webhook signature
2328+ verification (X-AdCP-Timestamp header). Required when signature is provided.
2329+ raw_body: Optional raw HTTP request body bytes for signature verification.
2330+ When provided, used directly instead of re-serializing the payload,
2331+ avoiding cross-language JSON serialization mismatches. Strongly
2332+ recommended for production use.
23152333
23162334 Returns:
23172335 TaskResult with parsed task-specific response data. The structure
@@ -2330,11 +2348,13 @@ async def handle_webhook(
23302348 MCP webhook (HTTP endpoint):
23312349 >>> @app.post("/webhook/{task_type}/{agent_id}/{operation_id}")
23322350 >>> async def webhook_handler(task_type: str, operation_id: str, request: Request):
2333- >>> payload = await request.json()
2351+ >>> raw_body = await request.body()
2352+ >>> payload = json.loads(raw_body)
23342353 >>> signature = request.headers.get("X-AdCP-Signature")
23352354 >>> timestamp = request.headers.get("X-AdCP-Timestamp")
23362355 >>> result = await client.handle_webhook(
2337- >>> payload, task_type, operation_id, signature, timestamp
2356+ >>> payload, task_type, operation_id, signature, timestamp,
2357+ >>> raw_body=raw_body,
23382358 >>> )
23392359 >>> if result.success:
23402360 >>> print(f"Task completed: {result.data}")
@@ -2368,7 +2388,7 @@ async def handle_webhook(
23682388 else :
23692389 # MCP webhook (dict payload)
23702390 return await self ._handle_mcp_webhook (
2371- payload , task_type , operation_id , signature , timestamp
2391+ payload , task_type , operation_id , signature , timestamp , raw_body
23722392 )
23732393
23742394
0 commit comments