diff --git a/rest/nodejs/src/api/checkout.ts b/rest/nodejs/src/api/checkout.ts index 2e453fcf..2eca3986 100644 --- a/rest/nodejs/src/api/checkout.ts +++ b/rest/nodejs/src/api/checkout.ts @@ -153,6 +153,8 @@ export class CheckoutService { headers: { "Content-Type": "application/json", "X-Event-Type": eventType, + "Webhook-Id": uuidv4(), + "Webhook-Timestamp": Math.floor(Date.now() / 1000).toString(), }, body: JSON.stringify(orderData), }); diff --git a/rest/nodejs/test/webhook.test.ts b/rest/nodejs/test/webhook.test.ts index 3dfad0da..e5d62a76 100644 --- a/rest/nodejs/test/webhook.test.ts +++ b/rest/nodejs/test/webhook.test.ts @@ -118,6 +118,29 @@ test("webhook delivers the bare order object as the body", async () => { "event type must be carried in the X-Event-Type header" ); + assert.ok( + delivered.headers["Webhook-Id"], + "Webhook-Id header must be present" + ); + const uuidRegex = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + assert.ok( + uuidRegex.test(delivered.headers["Webhook-Id"]), + "Webhook-Id must be a valid UUID" + ); + + assert.ok( + delivered.headers["Webhook-Timestamp"], + "Webhook-Timestamp header must be present" + ); + const timestamp = parseInt(delivered.headers["Webhook-Timestamp"], 10); + assert.ok(!isNaN(timestamp), "Webhook-Timestamp must be a number"); + const now = Math.floor(Date.now() / 1000); + assert.ok( + Math.abs(now - timestamp) < 5, + `Webhook-Timestamp (${timestamp}) should be close to now (${now})` + ); + const body = delivered.body as Record; // The body IS the order: its own id, and every required field present. assert.equal( diff --git a/rest/python/server/exceptions.py b/rest/python/server/exceptions.py index b8c4915a..9d499f9e 100644 --- a/rest/python/server/exceptions.py +++ b/rest/python/server/exceptions.py @@ -143,7 +143,7 @@ def __init__( self, message: str, code: str = "VERSION_INVALID_FORMAT", - status_code: int = 400, + status_code: int = 422, severity: ErrorSeverity = ErrorSeverity.UNRECOVERABLE, ): """Initialize UcpVersionError.""" diff --git a/rest/python/server/integration_test.py b/rest/python/server/integration_test.py index 3b2d7e98..9181d045 100644 --- a/rest/python/server/integration_test.py +++ b/rest/python/server/integration_test.py @@ -16,6 +16,7 @@ import asyncio from collections.abc import AsyncGenerator +import datetime import json from pathlib import Path import shutil @@ -690,6 +691,27 @@ def test_webhook_delivers_the_bare_order_as_body(self) -> None: # The event type travels in the header, not the body. self.assertEqual(delivered["headers"].get("X-Event-Type"), "order_placed") + self.assertIn("Webhook-Id", delivered["headers"]) + uuid_str = delivered["headers"]["Webhook-Id"] + try: + uuid.UUID(uuid_str) + except ValueError: + self.fail(f"Webhook-Id {uuid_str} is not a valid UUID") + + self.assertIn("Webhook-Timestamp", delivered["headers"]) + timestamp_str = delivered["headers"]["Webhook-Timestamp"] + try: + timestamp = int(timestamp_str) + except ValueError: + self.fail(f"Webhook-Timestamp {timestamp_str} is not a valid integer") + + now = int(datetime.datetime.now(datetime.timezone.utc).timestamp()) + self.assertLess( + abs(now - timestamp), + 5, + f"Webhook-Timestamp ({timestamp}) should be close to now ({now})", + ) + body = delivered["json"] # The body IS an order: it validates and carries every required field. Order.model_validate(body) @@ -749,7 +771,7 @@ def test_version_invalid_format(self) -> None: headers=headers, json=payload.model_dump(mode="json", exclude_none=True), ) - self.assertEqual(response.status_code, 400) + self.assertEqual(response.status_code, 422) # Verify the error structure matches UcpErrorResponse data = response.json() diff --git a/rest/python/server/services/checkout_service.py b/rest/python/server/services/checkout_service.py index f2ed1c53..c3f445ea 100644 --- a/rest/python/server/services/checkout_service.py +++ b/rest/python/server/services/checkout_service.py @@ -839,7 +839,13 @@ async def _notify_webhook(self, checkout: Checkout, event_type: str) -> None: await client.post( webhook_url, json=order_data, - headers={"X-Event-Type": event_type}, + headers={ + "X-Event-Type": event_type, + "Webhook-Id": str(uuid.uuid4()), + "Webhook-Timestamp": str( + int(datetime.datetime.now(datetime.timezone.utc).timestamp()) + ), + }, timeout=5.0, ) except Exception as e: # pylint: disable=broad-exception-caught