From 526b65cb5c73ac94c21c93a1f247351e631d6542 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maurice=20Preu=C3=9F?= Date: Sun, 1 Nov 2026 11:14:42 +0100 Subject: [PATCH 1/2] Verify Anikeen ID webhooks Webhook::constructEvent() checks the Anikeen-Signature header (HMAC-SHA256 of "t.body" with the endpoint's secret, 5-minute tolerance) and returns the event, like Stripe's helper. Co-Authored-By: Claude Opus 5.5 --- README.md | 30 ++++++++ README.stub | 30 ++++++++ .../SignatureVerificationException.php | 13 ++++ src/Id/Webhooks/Webhook.php | 71 +++++++++++++++++++ tests/Id/WebhookTest.php | 56 +++++++++++++++ 5 files changed, 200 insertions(+) create mode 100644 src/Id/Exceptions/SignatureVerificationException.php create mode 100644 src/Id/Webhooks/Webhook.php create mode 100644 tests/Id/WebhookTest.php diff --git a/README.md b/README.md index e8a1c6a..7c80a4d 100644 --- a/README.md +++ b/README.md @@ -183,6 +183,36 @@ Unconfirmed bookings are cancelled after 24 hours. `checkout()` on such a draft throws `ConfirmationRequiredException`; its `confirmationUrl` points to the same page. Trusted apps keep checking out directly. +### Webhooks + +Anikeen ID sends `order.*` and `subscription.*` events to the webhook endpoints of your app (set up +in the Anikeen ID admin panel, which also shows the signing secret `whsec_…`). Verify each request +with the raw body and answer 2xx within 10 seconds; failed deliveries are retried for about three days. + +```php +use Anikeen\Id\Exceptions\SignatureVerificationException; +use Anikeen\Id\Webhooks\Webhook; + +Route::post('webhooks/anikeen', function (Request $request) { + try { + $event = Webhook::constructEvent($request->getContent(), $request->header('Anikeen-Signature'), config('services.anikeen.webhook_secret')); + } catch (SignatureVerificationException) { + abort(400); + } + + match ($event['type']) { + 'order.completed' => Shop::fulfil($event['data']['object']['id']), + 'subscription.cancelled' => Panel::suspend($event['data']['object']['id']), + default => null, + }; + + return response()->noContent(); +})->withoutMiddleware(VerifyCsrfToken::class); +``` + +Events can arrive more than once or out of order: use `$event['id']` to skip duplicates, and +reload the object through the API when the current state matters. + ### Change the default access token / refresh token field name If you access / refresh token fields differs from the default `anikeen_id_access_token` / `anikeen_id_refresh_token`, you can specify the field name in the `AppServiceProvider` boot method: diff --git a/README.stub b/README.stub index 9223695..6fdf071 100644 --- a/README.stub +++ b/README.stub @@ -183,6 +183,36 @@ Unconfirmed bookings are cancelled after 24 hours. `checkout()` on such a draft throws `ConfirmationRequiredException`; its `confirmationUrl` points to the same page. Trusted apps keep checking out directly. +### Webhooks + +Anikeen ID sends `order.*` and `subscription.*` events to the webhook endpoints of your app (set up +in the Anikeen ID admin panel, which also shows the signing secret `whsec_…`). Verify each request +with the raw body and answer 2xx within 10 seconds; failed deliveries are retried for about three days. + +```php +use Anikeen\Id\Exceptions\SignatureVerificationException; +use Anikeen\Id\Webhooks\Webhook; + +Route::post('webhooks/anikeen', function (Request $request) { + try { + $event = Webhook::constructEvent($request->getContent(), $request->header('Anikeen-Signature'), config('services.anikeen.webhook_secret')); + } catch (SignatureVerificationException) { + abort(400); + } + + match ($event['type']) { + 'order.completed' => Shop::fulfil($event['data']['object']['id']), + 'subscription.cancelled' => Panel::suspend($event['data']['object']['id']), + default => null, + }; + + return response()->noContent(); +})->withoutMiddleware(VerifyCsrfToken::class); +``` + +Events can arrive more than once or out of order: use `$event['id']` to skip duplicates, and +reload the object through the API when the current state matters. + ### Change the default access token / refresh token field name If you access / refresh token fields differs from the default `anikeen_id_access_token` / `anikeen_id_refresh_token`, you can specify the field name in the `AppServiceProvider` boot method: diff --git a/src/Id/Exceptions/SignatureVerificationException.php b/src/Id/Exceptions/SignatureVerificationException.php new file mode 100644 index 0000000..203b453 --- /dev/null +++ b/src/Id/Exceptions/SignatureVerificationException.php @@ -0,0 +1,13 @@ +,v1=.">`. + */ +final class Webhook +{ + /** Requests older (or newer) than this many seconds are rejected to prevent replays. */ + public const DEFAULT_TOLERANCE = 300; + + /** + * Verifies the signature and returns the event: ['id', 'type', 'created', 'data' => ['object' => …]]. + * + * @param string $payload the raw request body (not re-encoded JSON) + * @param string|null $signatureHeader the Anikeen-Signature header + * + * @throws SignatureVerificationException + */ + public static function constructEvent(string $payload, ?string $signatureHeader, string $secret, int $tolerance = self::DEFAULT_TOLERANCE): array + { + self::verify($payload, $signatureHeader, $secret, $tolerance); + + $event = json_decode($payload, true); + + if (!is_array($event) || !isset($event['id'], $event['type'])) { + throw new SignatureVerificationException('The webhook payload is not a valid event.'); + } + + return $event; + } + + /** + * @throws SignatureVerificationException + */ + public static function verify(string $payload, ?string $signatureHeader, string $secret, int $tolerance = self::DEFAULT_TOLERANCE): void + { + $timestamp = null; + $signatures = []; + + foreach (explode(',', (string) $signatureHeader) as $part) { + [$key, $value] = array_pad(explode('=', trim($part), 2), 2, null); + + if ($key === 't' && ctype_digit((string) $value)) { + $timestamp = (int) $value; + } elseif ($key === 'v1' && $value) { + $signatures[] = $value; + } + } + + if ($timestamp === null || $signatures === []) { + throw new SignatureVerificationException('The Anikeen-Signature header is missing or malformed.'); + } + + $expected = hash_hmac('sha256', "{$timestamp}.{$payload}", $secret); + + if (!array_filter($signatures, fn (string $signature) => hash_equals($expected, $signature))) { + throw new SignatureVerificationException('The webhook signature does not match. Check the signing secret.'); + } + + if ($tolerance > 0 && abs(time() - $timestamp) > $tolerance) { + throw new SignatureVerificationException('The webhook timestamp is outside the tolerance.'); + } + } +} diff --git a/tests/Id/WebhookTest.php b/tests/Id/WebhookTest.php new file mode 100644 index 0000000..ab0bb88 --- /dev/null +++ b/tests/Id/WebhookTest.php @@ -0,0 +1,56 @@ +payload}", $secret ?? $this->secret); + } + + public function test_a_signed_event_is_returned(): void + { + $event = Webhook::constructEvent($this->payload, $this->header(), $this->secret); + + $this->assertSame('order.completed', $event['type']); + $this->assertSame('01JORD', $event['data']['object']['id']); + } + + public function test_a_wrong_secret_is_rejected(): void + { + $this->expectException(SignatureVerificationException::class); + + Webhook::constructEvent($this->payload, $this->header(secret: 'whsec_other'), $this->secret); + } + + public function test_a_changed_body_is_rejected(): void + { + $this->expectException(SignatureVerificationException::class); + + Webhook::constructEvent(str_replace('01JORD', '01JXXX', $this->payload), $this->header(), $this->secret); + } + + public function test_old_requests_are_rejected(): void + { + $this->expectException(SignatureVerificationException::class); + + Webhook::constructEvent($this->payload, $this->header(time() - 600), $this->secret); + } + + public function test_a_missing_header_is_rejected(): void + { + $this->expectException(SignatureVerificationException::class); + + Webhook::constructEvent($this->payload, null, $this->secret); + } +} From 5c090898223e1c2b6fc44b810f5af326c0510e08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maurice=20Preu=C3=9F?= Date: Sun, 1 Nov 2026 11:18:38 +0100 Subject: [PATCH 2/2] Drop the unused per-subscription webhook fields Apps receive events through their webhook endpoints now. Co-Authored-By: Claude Opus 5.5 --- src/Id/Resources/AppSubscriptions.php | 2 -- src/Id/Resources/Subscription.php | 8 +------- src/Id/Resources/Subscriptions.php | 6 +----- 3 files changed, 2 insertions(+), 14 deletions(-) diff --git a/src/Id/Resources/AppSubscriptions.php b/src/Id/Resources/AppSubscriptions.php index 472a911..e8e64f0 100644 --- a/src/Id/Resources/AppSubscriptions.php +++ b/src/Id/Resources/AppSubscriptions.php @@ -47,8 +47,6 @@ class AppSubscriptions * vat_rate?: float, * payload?: null|array, * ends_at?: null|string, - * webhook_url?: null|string, - * webhook_secret?: null|string, * idempotency_key?: null|string * } $attributes With an idempotency_key (max. 191 characters), a retried call returns the * subscription created by the first one instead of a duplicate. diff --git a/src/Id/Resources/Subscription.php b/src/Id/Resources/Subscription.php index 5e47469..58589fd 100644 --- a/src/Id/Resources/Subscription.php +++ b/src/Id/Resources/Subscription.php @@ -16,8 +16,6 @@ use Throwable; * @property float $vat_rate * @property array $payload * @property string $ends_at - * @property string $webhook_url - * @property string $webhook_secret * @property null|string $confirmation_url where the user confirms it (third-party apps) */ class Subscription extends BaseResource @@ -36,9 +34,7 @@ class Subscription extends BaseResource * price: float, * vat_rate: float, * payload: null|array, - * ends_at: null|string, - * webhook_url: null|string, - * webhook_secret: null|string + * ends_at: null|string * } $attributes The subscription data: * - group: The group (optional) * - name: The name (required when set) @@ -48,8 +44,6 @@ class Subscription extends BaseResource * - vat_rate: The VAT rate (required when set) * - payload: The payload (optional) * - ends_at: The end date (optional) - * - webhook_url: The webhook URL (optional) - * - webhook_secret: The webhook secret (optional) * @throws Throwable */ public function update(array $attributes): self diff --git a/src/Id/Resources/Subscriptions.php b/src/Id/Resources/Subscriptions.php index 214affd..99b62d8 100644 --- a/src/Id/Resources/Subscriptions.php +++ b/src/Id/Resources/Subscriptions.php @@ -22,9 +22,7 @@ class Subscriptions extends BaseCollection * price: float, * vat_rate: float, * payload: null|array, - * ends_at: null|string, - * webhook_url: null|string, - * webhook_secret: null|string + * ends_at: null|string * } $attributes The subscription data: * - group: The group (optional) * - name: The name @@ -34,8 +32,6 @@ class Subscriptions extends BaseCollection * - vat_rate: The VAT rate (required when set) * - payload: The payload (optional) * - ends_at: The end date (optional) - * - webhook_url: The webhook URL (optional) - * - webhook_secret: The webhook secret (optional) * @throws Throwable */ public function create(array $attributes): Subscription