Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
65 changes: 43 additions & 22 deletions docs/WebhookEndpoints.md
Original file line number Diff line number Diff line change
@@ -1,13 +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. There is **at
most one endpoint per mode** — one for test and one for live, determined by the
API token.
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`.

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)).
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` 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

Expand All @@ -21,11 +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 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
Expand All @@ -34,29 +39,39 @@ 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.
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 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. |




```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
'secret' => getenv('VATLY_WEBHOOK_SECRET'),
'enabledEvents' => [
WebhookSubscriptionEventName::ORDER_PAID,
WebhookSubscriptionEventName::REFUND_COMPLETED,
],
]);

echo $endpoint->id; // webhook_...
echo $endpoint->url;
```


Expand Down Expand Up @@ -90,8 +105,7 @@ 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 all endpoints for the token's mode.



Expand All @@ -114,23 +128,30 @@ 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` 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. |
| `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. |




```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,
],
]);
```

Expand Down
124 changes: 114 additions & 10 deletions openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -6902,6 +6921,7 @@ components:
- resource
- testmode
- url
- enabledEvents
- createdAt
- links
additionalProperties: false
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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: |
Expand Down
Loading