From 8b88122116c67aabd7e8c1614791f12bd97284aa Mon Sep 17 00:00:00 2001 From: Sander van Hooft Date: Tue, 26 May 2026 20:00:29 +0200 Subject: [PATCH] feat(webhooks): add createdAt + testmode to webhook event payload Webhook deliveries now carry the event timestamp and the test/live mode of the originating resource, so consumers can dedupe on time and route test traffic without inspecting the embedded object. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/Webhooks.md | 4 ++++ openapi.yaml | 13 +++++++++++++ src/API/Resources/WebhookEvent.php | 12 ++++++++++++ src/API/Webhooks/Webhook.php | 4 +++- src/API/Webhooks/WebhookPayload.php | 16 ++++++++++++++++ tests/Endpoints/WebhookEventEndpointTest.php | 4 ++++ tests/Webhooks/WebhookTest.php | 20 ++++++++++++++++++++ 7 files changed, 72 insertions(+), 1 deletion(-) diff --git a/docs/Webhooks.md b/docs/Webhooks.md index 195ce8d..edf3bb4 100644 --- a/docs/Webhooks.md +++ b/docs/Webhooks.md @@ -37,6 +37,8 @@ Every delivery carries a [`WebhookEvent`](../src/API/Resources/WebhookEvent.php) | `entityType` | `string` | Type of the related resource (e.g. `order`, `refund`, `subscription`). | | `entityId` | `string` | ID of the related resource (e.g. `order_Hn5xWqVfKm8RjTgYbUcP`). | | `object` | `object\|null` | The full resource payload at the time of the event. Shape depends on `entityType`. | +| `createdAt` | `string` | When the event occurred, ISO 8601 (e.g. `2024-01-15T10:30:00+00:00`). | +| `testmode` | `bool` | `true` when the event was produced by a test-mode resource. | | `links` | `object` | HATEOAS links — `links.self.href` points to this webhook event. | ### Example payload @@ -54,6 +56,8 @@ Every delivery carries a [`WebhookEvent`](../src/API/Resources/WebhookEvent.php) "status": "paid", "total": { "value": "29.99", "currency": "EUR" } }, + "createdAt": "2024-01-15T10:30:00+00:00", + "testmode": false, "links": { "self": { "href": "https://api.vatly.com/v1/webhook-events/webhook_event_Qk8pRtSvWm2NjLhYcZaE", diff --git a/openapi.yaml b/openapi.yaml index 7489fbb..6a04bf0 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2050,6 +2050,8 @@ paths: subtotal: value: '24.79' currency: EUR + createdAt: '2024-01-15T10:30:00Z' + testmode: false links: self: href: https://api.vatly.com/v1/webhook-events/webhook_event_Qk8pRtSvWm2NjLhYcZaE @@ -3947,6 +3949,8 @@ components: - entityType - entityId - object + - createdAt + - testmode - links properties: id: @@ -3988,6 +3992,15 @@ components: description: | The full resource payload at the time of the event. The shape depends on the `entityType` (e.g., Order, Refund, Chargeback, Subscription, Checkout). + createdAt: + type: string + format: date-time + description: When this event occurred (ISO 8601 format) + example: '2024-01-15T10:30:00Z' + testmode: + type: boolean + description: Whether this event was produced in test mode + example: false links: type: object description: HATEOAS links to related resources diff --git a/src/API/Resources/WebhookEvent.php b/src/API/Resources/WebhookEvent.php index 3dee113..4586e87 100644 --- a/src/API/Resources/WebhookEvent.php +++ b/src/API/Resources/WebhookEvent.php @@ -47,5 +47,17 @@ class WebhookEvent extends BaseResource */ public $object = null; + /** + * When this event occurred (ISO 8601 format). + * + * @example 2023-08-11T10:48:51+02:00 + */ + public string $createdAt; + + /** + * Whether this event was produced in test mode. + */ + public bool $testmode; + public WebhookEventLinks $links; } diff --git a/src/API/Webhooks/Webhook.php b/src/API/Webhooks/Webhook.php index 1205266..99cf8a3 100644 --- a/src/API/Webhooks/Webhook.php +++ b/src/API/Webhooks/Webhook.php @@ -35,7 +35,7 @@ public static function parse(string $payload, string $signature, string $secret) ); } - foreach (['id', 'resource', 'eventName', 'entityType', 'entityId'] as $field) { + foreach (['id', 'resource', 'eventName', 'entityType', 'entityId', 'createdAt', 'testmode'] as $field) { if (! isset($decoded->{$field})) { throw new \InvalidArgumentException( "Webhook payload is missing required field: {$field}" @@ -56,6 +56,8 @@ public static function parse(string $payload, string $signature, string $secret) $decoded->eventName, $decoded->entityType, $decoded->entityId, + $decoded->createdAt, + $decoded->testmode, $object, ); } diff --git a/src/API/Webhooks/WebhookPayload.php b/src/API/Webhooks/WebhookPayload.php index 977aa82..aaa93b6 100644 --- a/src/API/Webhooks/WebhookPayload.php +++ b/src/API/Webhooks/WebhookPayload.php @@ -54,12 +54,26 @@ class WebhookPayload */ public ?object $object; + /** + * When this event occurred (ISO 8601 format). + * + * @example 2023-08-11T10:48:51+02:00 + */ + public string $createdAt; + + /** + * Whether this event was produced in test mode. + */ + public bool $testmode; + public function __construct( string $id, string $resource, string $eventName, string $entityType, string $entityId, + string $createdAt, + bool $testmode, ?object $object = null, ) { $this->id = $id; @@ -67,6 +81,8 @@ public function __construct( $this->eventName = $eventName; $this->entityType = $entityType; $this->entityId = $entityId; + $this->createdAt = $createdAt; + $this->testmode = $testmode; $this->object = $object; } } diff --git a/tests/Endpoints/WebhookEventEndpointTest.php b/tests/Endpoints/WebhookEventEndpointTest.php index 7644cc8..df5edb3 100644 --- a/tests/Endpoints/WebhookEventEndpointTest.php +++ b/tests/Endpoints/WebhookEventEndpointTest.php @@ -30,6 +30,8 @@ public function can_get_webhook_event(): void 'total' => ['value' => '29.99', 'currency' => 'EUR'], 'subtotal' => ['value' => '24.79', 'currency' => 'EUR'], ], + 'createdAt' => '2024-01-15T10:30:00+00:00', + 'testmode' => false, 'links' => [ 'self' => [ 'href' => self::API_ENDPOINT_URL.'/webhook-events/'.$webhookEventId, @@ -56,6 +58,8 @@ public function can_get_webhook_event(): void $this->assertEquals(WebhookEventType::ORDER_PAID, $event->eventName); $this->assertEquals('order', $event->entityType); $this->assertEquals($orderId, $event->entityId); + $this->assertEquals('2024-01-15T10:30:00+00:00', $event->createdAt); + $this->assertFalse($event->testmode); $this->assertIsObject($event->object); $this->assertEquals($orderId, $event->object->id); $this->assertEquals('paid', $event->object->status); diff --git a/tests/Webhooks/WebhookTest.php b/tests/Webhooks/WebhookTest.php index 1ecb615..529f5e2 100644 --- a/tests/Webhooks/WebhookTest.php +++ b/tests/Webhooks/WebhookTest.php @@ -23,6 +23,8 @@ private function makePayload(array $overrides = []): string 'entityType' => 'order', 'entityId' => 'order_Hn5xWqVfKm8RjTgYbUcP', 'object' => ['id' => 'order_Hn5xWqVfKm8RjTgYbUcP', 'resource' => 'order'], + 'createdAt' => '2024-01-15T10:30:00+00:00', + 'testmode' => false, ], $overrides); return json_encode($data); @@ -50,10 +52,22 @@ public function test_it_parses_a_valid_webhook(): void $this->assertSame('order.paid', $event->eventName); $this->assertSame('order', $event->entityType); $this->assertSame('order_Hn5xWqVfKm8RjTgYbUcP', $event->entityId); + $this->assertSame('2024-01-15T10:30:00+00:00', $event->createdAt); + $this->assertFalse($event->testmode); $this->assertIsObject($event->object); $this->assertSame('order_Hn5xWqVfKm8RjTgYbUcP', $event->object->id); } + public function test_it_parses_testmode_true(): void + { + $payload = $this->makePayload(['testmode' => true]); + $signature = $this->sign($payload); + + $event = Webhook::parse($payload, $signature, $this->secret); + + $this->assertTrue($event->testmode); + } + public function test_it_parses_a_webhook_without_object(): void { $data = [ @@ -62,6 +76,8 @@ public function test_it_parses_a_webhook_without_object(): void 'eventName' => 'order.paid', 'entityType' => 'order', 'entityId' => 'order_Hn5xWqVfKm8RjTgYbUcP', + 'createdAt' => '2024-01-15T10:30:00+00:00', + 'testmode' => false, ]; $payload = json_encode($data); $signature = $this->sign($payload); @@ -112,6 +128,8 @@ public function test_it_throws_when_required_field_is_missing(string $field): vo 'eventName' => 'order.paid', 'entityType' => 'order', 'entityId' => 'order_Hn5xWqVfKm8RjTgYbUcP', + 'createdAt' => '2024-01-15T10:30:00+00:00', + 'testmode' => false, ]; unset($data[$field]); @@ -128,6 +146,8 @@ public function requiredFieldProvider(): array 'eventName' => ['eventName'], 'entityType' => ['entityType'], 'entityId' => ['entityId'], + 'createdAt' => ['createdAt'], + 'testmode' => ['testmode'], ]; }