From 313a18d9f489559bd9fa999204c143dd916508e1 Mon Sep 17 00:00:00 2001 From: Sander van Hooft <7265703+sandervanhooft@users.noreply.github.com> Date: Fri, 11 Sep 2026 15:56:40 +0200 Subject: [PATCH 1/2] feat(webhooks): support enabledEvents + multiple endpoints per mode Adopt vatlify OpenAPI #2163 into the webhook-endpoints resource. - WebhookEndpoint resource gains enabledEvents (string[]), hydrated on read - create() accepts optional enabledEvents (omit => all events at registration; [] => dormant); update() accepts it as a full-set replacement (omit preserves) - new Vatly\API\Types\WebhookSubscriptionEventName constants (31 subscribable public event names; webhook.setup excluded as it is always sent) - multiplicity: up to five endpoints per mode, URLs unique within storefront+mode (duplicate/6th => 422); corrected the old one-per-mode language in docs, endpoint doc comments, and the vendored openapi.yaml - tests for hydration, create/update passthrough, and the new enum - CHANGELOG updated --- CHANGELOG.md | 6 + docs/WebhookEndpoints.md | 49 +++++-- openapi.yaml | 124 ++++++++++++++++-- src/API/Endpoints/WebhookEndpointEndpoint.php | 26 +++- src/API/Resources/WebhookEndpoint.php | 15 ++- .../Types/WebhookSubscriptionEventName.php | 52 ++++++++ .../WebhookSubscriptionEventNameTest.php | 85 ++++++++++++ .../Endpoints/WebhookEndpointEndpointTest.php | 83 +++++++++++- 8 files changed, 412 insertions(+), 28 deletions(-) create mode 100644 src/API/Types/WebhookSubscriptionEventName.php create mode 100644 tests/API/Types/WebhookSubscriptionEventNameTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index b50cf84..809684d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,11 +6,17 @@ All notable changes to `vatly-api-php` will be documented in this file. ### Added +- **`enabledEvents` on webhook endpoints** — the `WebhookEndpoint` resource now exposes `enabledEvents` (`string[]`, the endpoint's persisted subscription set; hydrated on every read). `webhookEndpoints->create([...])` accepts an optional `enabledEvents`: omit it and Vatly subscribes the endpoint to every event available at registration (not updated automatically afterwards), or send `[]` for a dormant endpoint. `webhookEndpoints->update($id, [...])` accepts an optional `enabledEvents` as a full-set replacement — omitting it preserves the current subscriptions, `[]` makes the endpoint dormant. +- **`Vatly\API\Types\WebhookSubscriptionEventName`** — a constant class of the 31 public event names that can be enabled for a webhook endpoint. `webhook.setup` is deliberately excluded: it is always sent when Vatly verifies an endpoint and cannot be subscribed to. - **`cancellationReason` on the remaining cancel webhook DTOs** — `SubscriptionCanceledImmediately` and `SubscriptionCanceledWithGracePeriod` now expose a nullable `cancellationReason` (`?string`), read straight from the delivery's `object['cancellationReason']` (`merchant_request` / `customer_request`, or `null` if absent). Brings them in line with `SubscriptionCanceledForNonpayment` so every cancellation reason flows through. Values are the `Vatly\API\Types\CancellationReason` constants. - **`customers->createPortalSession($id, [...])`** (`POST /v1/customers/{customerId}/portal-sessions`) — creates a short-lived, single-use hosted customer portal link. Returns a `Vatly\API\Types\PortalSession` (`url`, `expiresAt`, `returnUrl`). Optional body: `returnUrl` (absolute HTTPS URL, max 2048 bytes). The link is credential-bearing — redirect the customer to `url`; do not cache or log it. - **`Subscription->cancellationReason`** — nullable string (`payment_failure` / `merchant_request` / `customer_request`, or `null`) explaining why a subscription was canceled. New `Vatly\API\Types\CancellationReason` constant class. - **`subscription.canceled_for_nonpayment` webhook event** — payment recovery was exhausted, so the subscription was canceled. New `WebhookEventName::SUBSCRIPTION_CANCELED_FOR_NONPAYMENT` constant and typed DTO `Vatly\API\Webhooks\Events\SubscriptionCanceledForNonpayment` (exposes `customerId`, `subscriptionId`, `endsAt`, `testmode`, and `cancellationReason`), wired into `WebhookEventFactory` and reported by `getSupportedEvents()` / `isSupported()`. +### Changed + +- **Multiple webhook endpoints per mode.** A storefront may now register up to five webhook endpoints per mode (URLs unique within storefront + mode; a duplicate or sixth returns `422`), replacing the previous one-per-mode limit. Doc comments on `WebhookEndpointEndpoint`, `docs/WebhookEndpoints.md`, and the vendored `openapi.yaml` (webhook-endpoint paths + `WebhookEndpoint`/`CreateWebhookEndpointRequest`/`UpdateWebhookEndpointRequest` schemas, and the new `WebhookSubscriptionEventName` schema) were updated to match. + ### Fixed - **`ScheduledSubscriptionUpdate` now carries `effectiveAt`** — the next-renewal date a scheduled change applies (nullable ISO 8601 date-time), matching the spec where it is a required field of `scheduledUpdate`. It is hydrated on both the `subscription.update_scheduled` webhook and the REST `Subscription` resource. diff --git a/docs/WebhookEndpoints.md b/docs/WebhookEndpoints.md index 91fa9a1..32e3cfa 100644 --- a/docs/WebhookEndpoints.md +++ b/docs/WebhookEndpoints.md @@ -1,9 +1,15 @@ # Webhook Endpoints A webhook endpoint is the HTTPS URL Vatly POSTs event deliveries to. You register -one from code (or infrastructure-as-code) instead of the dashboard. There is **at -most one endpoint per mode** — one for test and one for live, determined by the -API token. +one from code (or infrastructure-as-code) instead of the dashboard. A storefront +can have **up to five endpoints per mode** (test and live are determined by the +API token); URLs must be unique within the storefront and mode. Registering a +duplicate URL or a sixth endpoint is rejected with `422`. + +Each endpoint carries an `enabledEvents` subscription set — the public event names +it receives (see [`WebhookSubscriptionEventName`](../src/API/Types/WebhookSubscriptionEventName.php)). +An empty set makes the endpoint dormant; the `webhook.setup` verification event is +never subscribable and is always sent when Vatly verifies the endpoint. The signing `secret` you provide is **write-only**: it is sent on create/update but is never returned in any response. Store the value you send — you use it to @@ -21,6 +27,7 @@ Below you'll find all properties for the Vatly WebhookEndpoint resource. | `resource` | `string` | Resource type, always `webhook_endpoint`. | | `testmode` | `bool` | Whether this endpoint receives test-mode events. | | `url` | `string` | The HTTPS URL deliveries are POSTed to. | +| `enabledEvents` | `string[]` | The endpoint's persisted subscription names. An empty array means no domain events are delivered (dormant); `webhook.setup` is still sent. | | `createdAt` | `string` | Creation timestamp (ISO 8601). | | `links` | `WebhookEndpointLinks` | HATEOAS links (`self`). | @@ -36,8 +43,9 @@ Below you'll find all properties for the Vatly WebhookEndpoint resource. Register the endpoint for the mode determined by the API token. Vatly sends a `webhook.setup` verification ping to the URL and validates its SSL certificate; -if either fails the request is rejected. Registering a second endpoint for a mode -that already has one is rejected — update or delete the existing one instead. +if either fails the request is rejected. A storefront may have up to five +endpoints per mode and URLs must be unique within the storefront and mode; a +duplicate URL or a sixth endpoint is rejected with `422`. ### Required attributes @@ -46,17 +54,30 @@ that already has one is rejected — update or delete the existing one instead. | `url` | `string` | Publicly reachable HTTPS URL with a valid SSL certificate. `localhost`/loopback addresses are not allowed. | | `secret` | `string` | Signing secret (min 10 chars). Write-only — keep this value, the API never returns it. | +### Optional attributes + +| Name | Type | Description | +| --- | --- | --- | +| `enabledEvents` | `string[]` | The events delivered to this endpoint (`WebhookSubscriptionEventName` values). **Omit** it and Vatly subscribes to every event available at registration (not updated automatically afterwards); send `[]` for a dormant endpoint. `webhook.setup` is not selectable. | + ```php +use Vatly\API\Types\WebhookSubscriptionEventName; + $endpoint = $vatly->webhookEndpoints->create([ 'url' => 'https://merchant.example/webhooks/vatly', 'secret' => getenv('VATLY_WEBHOOK_SECRET'), // min 10 chars, keep it — never returned + 'enabledEvents' => [ + WebhookSubscriptionEventName::ORDER_PAID, + WebhookSubscriptionEventName::REFUND_COMPLETED, + ], ]); echo $endpoint->id; // webhook_... echo $endpoint->url; +print_r($endpoint->enabledEvents); ``` @@ -90,8 +111,8 @@ echo $endpoint->url; -List the endpoints for the token's mode. Because there is at most one endpoint -per mode, this returns at most one endpoint. +List the endpoints for the token's mode. A storefront may have up to five +endpoints per mode, so this returns up to five endpoints. @@ -114,9 +135,10 @@ foreach ($endpoints as $endpoint) { -Repoint the endpoint (`url`), rotate the signing `secret`, or both. A new URL is -revalidated for reachability and SSL just like on creation. Sending an empty body -is a no-op that returns the current endpoint. +Repoint the endpoint (`url`), rotate the signing `secret`, and/or replace its +`enabledEvents` subscription set. A new URL is revalidated for reachability and +SSL just like on creation. Sending an empty body is a no-op that returns the +current endpoint. ### Optional attributes @@ -124,13 +146,20 @@ is a no-op that returns the current endpoint. | --- | --- | --- | | `url` | `string` | New HTTPS delivery URL. | | `secret` | `string` | New signing secret (min 10 chars). Write-only — keep the value. | +| `enabledEvents` | `string[]` | Replaces the **complete** subscription set (`WebhookSubscriptionEventName` values). Omit it to preserve the current subscriptions; send `[]` to make the endpoint dormant. New event names are never added automatically. | ```php +use Vatly\API\Types\WebhookSubscriptionEventName; + $endpoint = $vatly->webhookEndpoints->update('webhook_QdEpFhdSrG4Y3DnfsdqsH', [ 'url' => 'https://merchant.example/webhooks/vatly-v2', + 'enabledEvents' => [ + WebhookSubscriptionEventName::CHECKOUT_PAID, + WebhookSubscriptionEventName::ORDER_PAID, + ], ]); ``` diff --git a/openapi.yaml b/openapi.yaml index 4fa755d..d099781 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -3175,9 +3175,9 @@ paths: summary: List webhook endpoints description: | Returns the webhook endpoints for the authenticated merchant, filtered by - the testmode determined from the API token. Because there is at most one - endpoint per mode, this returns at most one endpoint. The signing secret - is never included. + the testmode determined from the API token. A storefront may have up to + five endpoints per mode, so this returns up to five endpoints. The signing + secret is never included. tags: - Webhook Endpoints parameters: @@ -3213,6 +3213,9 @@ paths: resource: webhook_endpoint testmode: false url: https://merchant.example/webhooks/vatly + enabledEvents: + - order.paid + - refund.completed createdAt: '2024-01-15T10:30:00Z' links: self: @@ -3245,9 +3248,15 @@ paths: Vatly sends a `webhook.setup` verification ping to the URL and validates its SSL certificate; if either fails the request is rejected with `422`. - There is at most one endpoint per mode — registering a second one when the - token's mode already has an endpoint returns `422`; update or delete the - existing one instead. + + Optionally pass `enabledEvents` to choose the endpoint's subscription set. + Omit it and Vatly subscribes the endpoint to every event available at + registration time (not updated automatically afterwards); send an empty + list to create a dormant endpoint. + + A storefront may have up to five endpoints per mode, and URLs must be + unique within the storefront and mode. A duplicate URL or a sixth endpoint + returns `422`. **Use cases:** - Provision an endpoint from CI / infrastructure-as-code @@ -3280,6 +3289,9 @@ paths: resource: webhook_endpoint testmode: false url: https://merchant.example/webhooks/vatly + enabledEvents: + - order.paid + - refund.completed createdAt: '2024-01-15T10:30:00Z' links: self: @@ -3316,6 +3328,9 @@ paths: resource: webhook_endpoint testmode: false url: https://merchant.example/webhooks/vatly + enabledEvents: + - order.paid + - refund.completed createdAt: '2024-01-15T10:30:00Z' links: self: @@ -3369,6 +3384,9 @@ paths: resource: webhook_endpoint testmode: false url: https://merchant.example/webhooks/vatly-v2 + enabledEvents: + - order.paid + - refund.completed createdAt: '2024-01-15T10:30:00Z' links: self: @@ -6891,8 +6909,9 @@ components: and signs each one with the secret you set (see the `Vatly-Signature` header). - There is at most one endpoint per mode: one for test and one for live (the - mode is determined by the API token). + A storefront can have up to five endpoints in each mode. URLs must be + unique within the storefront and mode. Each endpoint receives only the + public event names in `enabledEvents`; an empty list makes it dormant. The signing secret is write-only: you provide it on creation (and may rotate it via update) but it is never returned in any response. Store the value you @@ -6902,6 +6921,7 @@ components: - resource - testmode - url + - enabledEvents - createdAt - links additionalProperties: false @@ -6924,6 +6944,22 @@ components: format: uri description: The HTTPS URL deliveries are POSTed to example: https://merchant.example/webhooks/vatly + enabledEvents: + type: array + description: | + The endpoint's persisted subscription names. Current names in this list + are delivered; a retired public name can remain visible for stable + readback but is inert. New event names are not added automatically; + update this list to opt in. An empty list means no domain events are + delivered, although `webhook.setup` is still sent whenever Vatly + verifies the endpoint configuration. + uniqueItems: true + items: + type: string + description: A current subscribable name or a retired public name retained on this endpoint. + example: + - order.paid + - refund.completed createdAt: type: string format: date-time @@ -6965,12 +7001,27 @@ components: variable) and use it to verify inbound webhooks. It is write-only — keep this value, as the API never returns it. example: whsec_3f9a1c7e2d4f7b9c5a2c1d5b7e9f3a8d + enabledEvents: + type: array + description: | + The explicit events delivered to this endpoint. If omitted, Vatly + stores all event names available at registration time. New event names + added later are not enabled automatically. Send an empty list to create + a dormant endpoint. `webhook.setup` cannot be selected and remains an + unconditional configuration-verification event. + uniqueItems: true + items: + $ref: '#/components/schemas/WebhookSubscriptionEventName' + example: + - order.paid + - refund.completed UpdateWebhookEndpointRequest: type: object description: | Request body for updating a webhook endpoint. Send `url` to repoint the - endpoint, `secret` to rotate the signing secret, or both. Omitting both is a - no-op that returns the current endpoint. + endpoint, `secret` to rotate the signing secret, and/or `enabledEvents` to + replace its full subscription set. Omitting all three is a no-op that + returns the current endpoint. additionalProperties: false properties: url: @@ -6987,6 +7038,59 @@ components: New signing secret for this endpoint. Write-only — keep the value, as the API never returns it. example: whsec_a1b2c3d4e5f60718293a4b5c6d7e8f90 + enabledEvents: + type: array + description: | + Replaces the endpoint's complete event subscription set. Omit this + field to preserve the current subscriptions; send an empty list to make + the endpoint dormant. New event names are never added automatically. + `webhook.setup` is not subscribable. + uniqueItems: true + items: + $ref: '#/components/schemas/WebhookSubscriptionEventName' + example: + - checkout.paid + - order.paid + WebhookSubscriptionEventName: + type: string + description: | + A public event name that can be enabled for a webhook endpoint. The + `webhook.setup` verification event is deliberately excluded: Vatly sends + that event whenever it verifies endpoint configuration, regardless of the + endpoint's subscriptions. + enum: + - checkout.canceled + - checkout.expired + - checkout.failed + - checkout.paid + - one_off_product.archived + - one_off_product.unarchived + - one_off_product.update_approved + - one_off_product.update_rejected + - one_off_product.update_submitted + - order.canceled + - order.chargeback_received + - order.chargeback_reversed + - order.paid + - order.payment_failed + - refund.canceled + - refund.completed + - refund.failed + - subscription.billing_updated + - subscription.canceled_for_nonpayment + - subscription.canceled_immediately + - subscription.canceled_with_grace_period + - subscription.cancellation_grace_period_completed + - subscription.resumed + - subscription.started + - subscription.update_scheduled + - subscription.updated + - subscription_plan.archived + - subscription_plan.unarchived + - subscription_plan.update_approved + - subscription_plan.update_rejected + - subscription_plan.update_submitted + example: order.paid Subscription: type: object description: | diff --git a/src/API/Endpoints/WebhookEndpointEndpoint.php b/src/API/Endpoints/WebhookEndpointEndpoint.php index 18ccc72..d247cd6 100644 --- a/src/API/Endpoints/WebhookEndpointEndpoint.php +++ b/src/API/Endpoints/WebhookEndpointEndpoint.php @@ -25,7 +25,18 @@ protected function getResourceObject(): WebhookEndpoint /** * Register a webhook endpoint. Supply `url` and a write-only signing * `secret` (min 10 chars); the secret is never returned, so keep the value - * you send. There is at most one endpoint per mode. + * you send. + * + * A storefront may have up to five endpoints per mode; URLs must be unique + * within the storefront and mode. A duplicate URL or a sixth endpoint is + * rejected with `422`. + * + * Pass an optional `enabledEvents` (a list of + * {@see \Vatly\API\Types\WebhookSubscriptionEventName} values) to choose the + * subscription set. Omit it and Vatly subscribes the endpoint to every event + * available at registration time (not updated automatically afterwards); send + * an empty array to create a dormant endpoint. `webhook.setup` is not + * subscribable — it is always sent. * * @return WebhookEndpoint|BaseResource * @throws ApiException @@ -45,8 +56,13 @@ public function get(string $id, array $parameters = []): BaseResource } /** - * Update an endpoint's `url`, its signing `secret`, or both. Sending an - * empty body is a no-op that returns the current endpoint. + * Update an endpoint's `url`, its signing `secret`, and/or its + * `enabledEvents` subscription set. `enabledEvents` is a full-set + * replacement (a list of + * {@see \Vatly\API\Types\WebhookSubscriptionEventName} values): omit it to + * preserve the current subscriptions, or send an empty array to make the + * endpoint dormant. New event names are never added automatically. + * Sending an empty body is a no-op that returns the current endpoint. * * @return WebhookEndpoint|BaseResource|null * @throws ApiException @@ -67,8 +83,8 @@ public function delete(string $id): void } /** - * List the webhook endpoints for the token's mode. Because there is at most - * one endpoint per mode, this returns at most one endpoint. + * List the webhook endpoints for the token's mode. A storefront may have up + * to five endpoints per mode, so this returns up to five endpoints. * * @return WebhookEndpointCollection|BaseResourcePage * @throws ApiException diff --git a/src/API/Resources/WebhookEndpoint.php b/src/API/Resources/WebhookEndpoint.php index 4bb29d6..9213aea 100644 --- a/src/API/Resources/WebhookEndpoint.php +++ b/src/API/Resources/WebhookEndpoint.php @@ -24,12 +24,25 @@ class WebhookEndpoint extends BaseResource */ public string $url; + /** + * The endpoint's persisted subscription set: the public event names it + * receives. Current names are delivered; a retired public name may remain + * for stable readback but is inert. New event names are never added + * automatically — update the endpoint to opt in. An empty array means no + * domain events are delivered (a dormant endpoint), although `webhook.setup` + * is still sent whenever Vatly verifies the endpoint configuration. + * + * @var string[] + */ + public array $enabledEvents = []; + public ?string $createdAt = null; public WebhookEndpointLinks $links; /** - * Update this endpoint's `url`, its signing `secret`, or both. The secret is + * Update this endpoint's `url`, its signing `secret`, and/or its + * `enabledEvents` subscription set (a full-set replacement). The secret is * write-only and never returned. * * @return WebhookEndpoint|BaseResource|null diff --git a/src/API/Types/WebhookSubscriptionEventName.php b/src/API/Types/WebhookSubscriptionEventName.php new file mode 100644 index 0000000..6591645 --- /dev/null +++ b/src/API/Types/WebhookSubscriptionEventName.php @@ -0,0 +1,52 @@ +getConstants()); + + sort($constants); + $expected = self::SPEC_ENUM; + sort($expected); + + $this->assertSame($expected, $constants); + $this->assertCount(31, $constants); + } + + /** @test */ + public function it_excludes_the_non_subscribable_webhook_setup_event(): void + { + $constants = array_values((new ReflectionClass(WebhookSubscriptionEventName::class))->getConstants()); + + $this->assertNotContains(WebhookEventName::WEBHOOK_SETUP, $constants); + $this->assertNotContains('webhook.setup', $constants); + } + + /** @test */ + public function each_subscribable_name_is_also_a_known_webhook_event_name(): void + { + $eventNames = array_values((new ReflectionClass(WebhookEventName::class))->getConstants()); + + foreach ((new ReflectionClass(WebhookSubscriptionEventName::class))->getConstants() as $value) { + $this->assertContains($value, $eventNames, "$value should be a known WebhookEventName"); + } + } +} diff --git a/tests/Endpoints/WebhookEndpointEndpointTest.php b/tests/Endpoints/WebhookEndpointEndpointTest.php index 6fee687..b9c6ef1 100644 --- a/tests/Endpoints/WebhookEndpointEndpointTest.php +++ b/tests/Endpoints/WebhookEndpointEndpointTest.php @@ -6,6 +6,7 @@ use Vatly\API\Resources\WebhookEndpoint; use Vatly\API\Resources\WebhookEndpointCollection; +use Vatly\API\Types\WebhookSubscriptionEventName; use Vatly\API\VatlyApiClient; class WebhookEndpointEndpointTest extends BaseEndpointTest @@ -35,6 +36,7 @@ public function it_can_register_a_webhook_endpoint(): void $this->assertEquals('webhook_endpoint', $endpoint->resource); $this->assertFalse($endpoint->testmode); $this->assertEquals('https://merchant.example/webhooks/vatly', $endpoint->url); + $this->assertEquals(['order.paid', 'refund.completed'], $endpoint->enabledEvents); $this->assertEquals('2024-01-15T10:30:00Z', $endpoint->createdAt); $this->assertEquals(self::API_ENDPOINT_URL.'/webhook-endpoints/'.self::WEBHOOK_ENDPOINT_ID, $endpoint->links->self->href); @@ -42,6 +44,54 @@ public function it_can_register_a_webhook_endpoint(): void $this->assertFalse(property_exists($endpoint, 'secret') && isset($endpoint->secret)); } + /** @test */ + public function it_passes_enabled_events_through_when_registering(): void + { + $this->httpClient->setSendReturnObjectFromArray($this->demoData()); + + $this->client->webhookEndpoints->create([ + 'url' => 'https://merchant.example/webhooks/vatly', + 'secret' => 'whsec_3f9a1c7e2d4f7b9c5a2c1d5b7e9f3a8d', + 'enabledEvents' => [ + WebhookSubscriptionEventName::ORDER_PAID, + WebhookSubscriptionEventName::REFUND_COMPLETED, + ], + ]); + + $this->assertWasSentOnly( + VatlyApiClient::HTTP_POST, + self::API_ENDPOINT_URL.'/webhook-endpoints', + [], + '{"url":"https:\/\/merchant.example\/webhooks\/vatly",' + .'"secret":"whsec_3f9a1c7e2d4f7b9c5a2c1d5b7e9f3a8d",' + .'"enabledEvents":["order.paid","refund.completed"]}' + ); + } + + /** @test */ + public function it_creates_a_dormant_endpoint_with_an_empty_enabled_events_list(): void + { + $this->httpClient->setSendReturnObjectFromArray($this->demoData('https://merchant.example/webhooks/vatly', [])); + + /** @var WebhookEndpoint $endpoint */ + $endpoint = $this->client->webhookEndpoints->create([ + 'url' => 'https://merchant.example/webhooks/vatly', + 'secret' => 'whsec_3f9a1c7e2d4f7b9c5a2c1d5b7e9f3a8d', + 'enabledEvents' => [], + ]); + + $this->assertWasSentOnly( + VatlyApiClient::HTTP_POST, + self::API_ENDPOINT_URL.'/webhook-endpoints', + [], + '{"url":"https:\/\/merchant.example\/webhooks\/vatly",' + .'"secret":"whsec_3f9a1c7e2d4f7b9c5a2c1d5b7e9f3a8d",' + .'"enabledEvents":[]}' + ); + + $this->assertSame([], $endpoint->enabledEvents); + } + /** @test */ public function it_can_get_a_webhook_endpoint(): void { @@ -116,6 +166,31 @@ public function it_can_update_a_webhook_endpoint(): void $this->assertEquals('https://merchant.example/webhooks/vatly-v2', $endpoint->url); } + /** @test */ + public function it_replaces_the_full_enabled_events_set_on_update(): void + { + $this->httpClient->setSendReturnObjectFromArray( + $this->demoData('https://merchant.example/webhooks/vatly', ['checkout.paid', 'order.paid']) + ); + + /** @var WebhookEndpoint $endpoint */ + $endpoint = $this->client->webhookEndpoints->update(self::WEBHOOK_ENDPOINT_ID, [ + 'enabledEvents' => [ + WebhookSubscriptionEventName::CHECKOUT_PAID, + WebhookSubscriptionEventName::ORDER_PAID, + ], + ]); + + $this->assertWasSentOnly( + VatlyApiClient::HTTP_PATCH, + self::API_ENDPOINT_URL.'/webhook-endpoints/'.self::WEBHOOK_ENDPOINT_ID, + [], + '{"enabledEvents":["checkout.paid","order.paid"]}' + ); + + $this->assertEquals(['checkout.paid', 'order.paid'], $endpoint->enabledEvents); + } + /** @test */ public function it_can_update_a_webhook_endpoint_secret_from_a_resource_instance(): void { @@ -151,15 +226,19 @@ public function it_can_delete_a_webhook_endpoint(): void } /** + * @param string[] $enabledEvents * @return array */ - private function demoData(string $url = 'https://merchant.example/webhooks/vatly'): array - { + private function demoData( + string $url = 'https://merchant.example/webhooks/vatly', + array $enabledEvents = ['order.paid', 'refund.completed'] + ): array { return [ 'id' => self::WEBHOOK_ENDPOINT_ID, 'resource' => 'webhook_endpoint', 'testmode' => false, 'url' => $url, + 'enabledEvents' => $enabledEvents, 'createdAt' => '2024-01-15T10:30:00Z', 'links' => [ 'self' => [ From b4d8d169be9cf82631c3c1c412a4062be7bfa59a Mon Sep 17 00:00:00 2001 From: Sander van Hooft Date: Sat, 12 Sep 2026 00:13:18 +0200 Subject: [PATCH 2/2] docs: tighten WebhookEndpoints prose --- docs/WebhookEndpoints.md | 56 +++++++++++++++++----------------------- 1 file changed, 24 insertions(+), 32 deletions(-) diff --git a/docs/WebhookEndpoints.md b/docs/WebhookEndpoints.md index 32e3cfa..7ebaa56 100644 --- a/docs/WebhookEndpoints.md +++ b/docs/WebhookEndpoints.md @@ -1,19 +1,19 @@ # Webhook Endpoints -A webhook endpoint is the HTTPS URL Vatly POSTs event deliveries to. You register -one from code (or infrastructure-as-code) instead of the dashboard. A storefront -can have **up to five endpoints per mode** (test and live are determined by the -API token); URLs must be unique within the storefront and mode. Registering a -duplicate URL or a sixth endpoint is rejected with `422`. +A webhook endpoint is the HTTPS URL Vatly POSTs event deliveries to — registered +from code (or infrastructure-as-code) rather than the dashboard. A storefront can +have **up to five endpoints per mode** (test and live are set by the API token), +each with a URL unique within that storefront and mode. A duplicate URL or a sixth +endpoint is rejected with `422`. -Each endpoint carries an `enabledEvents` subscription set — the public event names -it receives (see [`WebhookSubscriptionEventName`](../src/API/Types/WebhookSubscriptionEventName.php)). -An empty set makes the endpoint dormant; the `webhook.setup` verification event is -never subscribable and is always sent when Vatly verifies the endpoint. +Each endpoint has its own `enabledEvents` set — the public event names it receives +(see [`WebhookSubscriptionEventName`](../src/API/Types/WebhookSubscriptionEventName.php)). +An empty set makes it dormant; `webhook.setup` is never subscribable and is always +sent when Vatly verifies the endpoint. -The signing `secret` you provide is **write-only**: it is sent on create/update -but is never returned in any response. Store the value you send — you use it to -verify the `Vatly-Signature` HMAC on deliveries (see [Webhooks](/docs/Webhooks.md)). +The signing `secret` is **write-only**: sent on create/update, never returned. +Store the value you send — you use it to verify the `Vatly-Signature` HMAC on +deliveries (see [Webhooks](/docs/Webhooks.md)). ## The WebhookEndpoint Resource @@ -27,12 +27,10 @@ Below you'll find all properties for the Vatly WebhookEndpoint resource. | `resource` | `string` | Resource type, always `webhook_endpoint`. | | `testmode` | `bool` | Whether this endpoint receives test-mode events. | | `url` | `string` | The HTTPS URL deliveries are POSTed to. | -| `enabledEvents` | `string[]` | The endpoint's persisted subscription names. An empty array means no domain events are delivered (dormant); `webhook.setup` is still sent. | +| `enabledEvents` | `string[]` | The event names this endpoint is subscribed to. An empty array means dormant (no domain events); `webhook.setup` is still sent. | | `createdAt` | `string` | Creation timestamp (ISO 8601). | | `links` | `WebhookEndpointLinks` | HATEOAS links (`self`). | -> The signing `secret` is never present on the resource — it is write-only. - --- ## Register a webhook endpoint @@ -41,24 +39,22 @@ Below you'll find all properties for the Vatly WebhookEndpoint resource. -Register the endpoint for the mode determined by the API token. Vatly sends a -`webhook.setup` verification ping to the URL and validates its SSL certificate; -if either fails the request is rejected. A storefront may have up to five -endpoints per mode and URLs must be unique within the storefront and mode; a -duplicate URL or a sixth endpoint is rejected with `422`. +Register an endpoint for the token's mode. Vatly sends a `webhook.setup` +verification ping and validates the URL's SSL certificate; if either fails, +registration is rejected with `422`. ### Required attributes | Name | Type | Description | | --- | --- | --- | | `url` | `string` | Publicly reachable HTTPS URL with a valid SSL certificate. `localhost`/loopback addresses are not allowed. | -| `secret` | `string` | Signing secret (min 10 chars). Write-only — keep this value, the API never returns it. | +| `secret` | `string` | Signing secret (min 10 chars). Write-only — keep it, the API never returns it. | ### Optional attributes | Name | Type | Description | | --- | --- | --- | -| `enabledEvents` | `string[]` | The events delivered to this endpoint (`WebhookSubscriptionEventName` values). **Omit** it and Vatly subscribes to every event available at registration (not updated automatically afterwards); send `[]` for a dormant endpoint. `webhook.setup` is not selectable. | +| `enabledEvents` | `string[]` | The events to deliver (`WebhookSubscriptionEventName` values). Omit to subscribe to every event available at registration (not updated afterwards); send `[]` for a dormant endpoint. `webhook.setup` is not selectable. | @@ -68,7 +64,7 @@ use Vatly\API\Types\WebhookSubscriptionEventName; $endpoint = $vatly->webhookEndpoints->create([ 'url' => 'https://merchant.example/webhooks/vatly', - 'secret' => getenv('VATLY_WEBHOOK_SECRET'), // min 10 chars, keep it — never returned + 'secret' => getenv('VATLY_WEBHOOK_SECRET'), 'enabledEvents' => [ WebhookSubscriptionEventName::ORDER_PAID, WebhookSubscriptionEventName::REFUND_COMPLETED, @@ -76,8 +72,6 @@ $endpoint = $vatly->webhookEndpoints->create([ ]); echo $endpoint->id; // webhook_... -echo $endpoint->url; -print_r($endpoint->enabledEvents); ``` @@ -111,8 +105,7 @@ echo $endpoint->url; -List the endpoints for the token's mode. A storefront may have up to five -endpoints per mode, so this returns up to five endpoints. +List all endpoints for the token's mode. @@ -136,17 +129,16 @@ foreach ($endpoints as $endpoint) { Repoint the endpoint (`url`), rotate the signing `secret`, and/or replace its -`enabledEvents` subscription set. A new URL is revalidated for reachability and -SSL just like on creation. Sending an empty body is a no-op that returns the -current endpoint. +`enabledEvents` set. A new URL is revalidated for reachability and SSL just like +on creation. An empty body is a no-op that returns the current endpoint. ### Optional attributes | Name | Type | Description | | --- | --- | --- | | `url` | `string` | New HTTPS delivery URL. | -| `secret` | `string` | New signing secret (min 10 chars). Write-only — keep the value. | -| `enabledEvents` | `string[]` | Replaces the **complete** subscription set (`WebhookSubscriptionEventName` values). Omit it to preserve the current subscriptions; send `[]` to make the endpoint dormant. New event names are never added automatically. | +| `secret` | `string` | New signing secret (min 10 chars). Write-only. | +| `enabledEvents` | `string[]` | Replaces the **complete** subscription set (`WebhookSubscriptionEventName` values). Omit to preserve the current set; send `[]` to make the endpoint dormant. |