From 8f94f32b59a401ffe82edadf5da55a0747cb3f49 Mon Sep 17 00:00:00 2001 From: ag84ark Date: Fri, 29 May 2026 16:07:01 +0200 Subject: [PATCH] feat: implement webhook setup call parsing and response handling --- docs/Webhooks.md | 13 ++++++-- src/API/Webhooks/Webhook.php | 15 +++++++++ src/API/Webhooks/WebhookSetupCallPayload.php | 32 ++++++++++++++++++++ tests/Webhooks/WebhookTest.php | 19 ++++++++++++ 4 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 src/API/Webhooks/WebhookSetupCallPayload.php diff --git a/docs/Webhooks.md b/docs/Webhooks.md index 3583d37..0d64026 100644 --- a/docs/Webhooks.md +++ b/docs/Webhooks.md @@ -96,9 +96,11 @@ Verification is performed against the **raw request body bytes**. JSON that is p ```php use Vatly\API\Exceptions\InvalidSignatureException; use Vatly\API\Webhooks\Webhook; +use Vatly\API\Webhooks\WebhookSetupCallPayload; +use Vatly\API\Webhooks\WebhookSignatureValidator; $payload = file_get_contents('php://input'); -$signature = $_SERVER['HTTP_VATLY_SIGNATURE'] ?? ''; +$signature = $_SERVER[WebhookSignatureValidator::SIGNATURE_HEADER_NAME] ?? ''; $secret = getenv('VATLY_WEBHOOK_SECRET'); try { @@ -108,8 +110,13 @@ try { exit('Invalid signature'); } +if ($event instanceof WebhookSetupCallPayload) { + http_response_code(200); + exit; +} + // Dedupe with Vatly-Event-Id (stable across retry attempts). -$eventId = $_SERVER['HTTP_VATLY_EVENT_ID'] ?? $event->id; +$eventId = $_SERVER[WebhookSignatureValidator::EVENT_ID_HEADER_NAME] ?? $event->id; if (alreadyProcessed($eventId)) { http_response_code(200); exit; @@ -126,6 +133,8 @@ markProcessed($eventId); http_response_code(200); ``` +When a webhook endpoint is registered, Vatly sends a signed setup call with `{"message":"Setup call","testmode":true}`. `Webhook::parse()` returns a `WebhookSetupCallPayload` for this body so receivers can acknowledge it without running normal event handling. + `Webhook::parse()` throws `Vatly\API\Exceptions\InvalidSignatureException` when the signature header is malformed, the timestamp is outside the tolerance window, or the HMAC does not match. It throws `InvalidArgumentException` when the body is not valid JSON or is missing required fields. ### Replay-window tolerance diff --git a/src/API/Webhooks/Webhook.php b/src/API/Webhooks/Webhook.php index c6f274f..7286333 100644 --- a/src/API/Webhooks/Webhook.php +++ b/src/API/Webhooks/Webhook.php @@ -35,6 +35,10 @@ public static function parse(string $payload, string $signature, string $secret) ); } + if (self::isSetupCall($decoded)) { + return new WebhookSetupCallPayload($decoded->message, $decoded->testmode); + } + foreach (['id', 'resource', 'eventName', 'entityType', 'entityId', 'createdAt', 'testmode'] as $field) { if (! isset($decoded->{$field})) { throw new \InvalidArgumentException( @@ -61,4 +65,15 @@ public static function parse(string $payload, string $signature, string $secret) $object, ); } + + private static function isSetupCall($decoded): bool + { + if (! is_object($decoded)) { + return false; + } + + return isset($decoded->message, $decoded->testmode) + && is_bool($decoded->testmode) + && ! isset($decoded->id); + } } diff --git a/src/API/Webhooks/WebhookSetupCallPayload.php b/src/API/Webhooks/WebhookSetupCallPayload.php new file mode 100644 index 0000000..f8a12b5 --- /dev/null +++ b/src/API/Webhooks/WebhookSetupCallPayload.php @@ -0,0 +1,32 @@ +message = $message; + } +} diff --git a/tests/Webhooks/WebhookTest.php b/tests/Webhooks/WebhookTest.php index ee7138f..bf7f5b1 100644 --- a/tests/Webhooks/WebhookTest.php +++ b/tests/Webhooks/WebhookTest.php @@ -9,6 +9,7 @@ use Vatly\API\Exceptions\InvalidSignatureException; use Vatly\API\Webhooks\Webhook; use Vatly\API\Webhooks\WebhookPayload; +use Vatly\API\Webhooks\WebhookSetupCallPayload; class WebhookTest extends TestCase { @@ -78,6 +79,24 @@ public function test_it_parses_a_webhook_without_object(): void $this->assertNull($event->object); } + public function test_it_parses_the_webhook_setup_call(): void + { + $payload = json_encode([ + 'message' => 'Setup call', + 'testmode' => true, + ]); + $signature = $this->sign($payload); + + $event = Webhook::parse($payload, $signature, $this->secret); + + $this->assertInstanceOf(WebhookSetupCallPayload::class, $event); + $this->assertSame('Setup call', $event->message); + $this->assertSame(WebhookSetupCallPayload::RESOURCE, $event->resource); + $this->assertSame(WebhookSetupCallPayload::EVENT_NAME, $event->eventName); + $this->assertTrue($event->testmode); + $this->assertNull($event->object); + } + public function test_it_throws_for_invalid_signature(): void { $this->expectException(InvalidSignatureException::class);