diff --git a/docs/Webhooks.md b/docs/Webhooks.md index 6aa392b..21427cc 100644 --- a/docs/Webhooks.md +++ b/docs/Webhooks.md @@ -31,10 +31,10 @@ The `eventName` field on a delivery identifies what happened. See [`Vatly\API\Ty When you register a webhook endpoint (or change its URL), Vatly sends a signed `webhook.setup` event to confirm the endpoint is reachable. It is delivered as a normal -webhook — same envelope, same signature, same `Vatly-Event-Id` header — so there is -**nothing special to parse**: `Webhook::parse()` returns an ordinary `WebhookPayload`. -Just acknowledge it with a `2xx` and take no action; the `default` arm of an event -`match` already does this. +webhook — same envelope, same signature, same `Vatly-Event-Id` header. `Webhook::parse()` +returns a `WebhookSetupCallPayload` (a `WebhookPayload` subclass) for it, so you can +either ignore it — just `2xx`-acknowledge and take no action, which the `default` arm of +an event `match` already does — or `instanceof`-detect it to handle verification explicitly. --- @@ -106,9 +106,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 { @@ -118,8 +120,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; @@ -136,6 +143,8 @@ markProcessed($eventId); http_response_code(200); ``` +The `webhook.setup` verification call arrives as a standard envelope, so `Webhook::parse()` returns a `WebhookSetupCallPayload` (a `WebhookPayload` subclass). Receivers can `instanceof`-detect it (as above) to acknowledge the setup 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..dee1ef5 100644 --- a/src/API/Webhooks/Webhook.php +++ b/src/API/Webhooks/Webhook.php @@ -5,6 +5,7 @@ namespace Vatly\API\Webhooks; use Vatly\API\Exceptions\InvalidSignatureException; +use Vatly\API\Types\WebhookEventName; class Webhook { @@ -50,7 +51,15 @@ public static function parse(string $payload, string $signature, string $secret) ); } - return new WebhookPayload( + // The webhook endpoint verification ("setup") call is delivered as a + // standard envelope (eventName `webhook.setup`, entityType `webhook`). + // Return the typed subclass so receivers can `instanceof`-detect and + // simply 2xx-acknowledge it without running event-specific handling. + $payloadClass = $decoded->eventName === WebhookEventName::WEBHOOK_SETUP + ? WebhookSetupCallPayload::class + : WebhookPayload::class; + + return new $payloadClass( $decoded->id, $decoded->resource, $decoded->eventName, diff --git a/src/API/Webhooks/WebhookSetupCallPayload.php b/src/API/Webhooks/WebhookSetupCallPayload.php new file mode 100644 index 0000000..43b6875 --- /dev/null +++ b/src/API/Webhooks/WebhookSetupCallPayload.php @@ -0,0 +1,24 @@ +assertNull($event->object); } - public function test_it_parses_a_webhook_setup_event(): void + public function test_it_parses_the_webhook_setup_call(): void { $payload = $this->makePayload([ 'id' => 'webhook_event_St9uVwXyZa1BcDeFgHiJ', @@ -97,9 +98,11 @@ public function test_it_parses_a_webhook_setup_event(): void $event = Webhook::parse($payload, $signature, $this->secret); - // The setup call is a normal WebhookPayload — no special-casing on parse. + // The setup call is delivered as a standard envelope; parse() returns a + // WebhookSetupCallPayload (a WebhookPayload subclass) so receivers can + // instanceof-detect and 2xx-acknowledge it. + $this->assertInstanceOf(WebhookSetupCallPayload::class, $event); $this->assertInstanceOf(WebhookPayload::class, $event); - $this->assertSame('webhook.setup', WebhookEventName::WEBHOOK_SETUP); $this->assertSame(WebhookEventName::WEBHOOK_SETUP, $event->eventName); $this->assertSame('webhook', $event->entityType); $this->assertSame('webhook_QdEpFhdSrG4Y3DnfsdqsH', $event->entityId);