From 80b68dff9eed02f8bf119a606b37bf6a7b8fe1ec Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Thu, 10 Sep 2026 13:09:20 +0800 Subject: [PATCH 1/2] Add Webhook Endpoints Methods --- src/ConvertKit_API_Traits.php | 308 +++++++++++++++++++++++++++++++++- 1 file changed, 305 insertions(+), 3 deletions(-) diff --git a/src/ConvertKit_API_Traits.php b/src/ConvertKit_API_Traits.php index 17fb5df..82f3302 100644 --- a/src/ConvertKit_API_Traits.php +++ b/src/ConvertKit_API_Traits.php @@ -2237,7 +2237,7 @@ public function delete_broadcast(int $id) * * @since 2.0.0 * - * @see https://developers.kit.com/api-reference/webhooks/list-webhooks + * @see https://developers.kit.com/api-reference/webhooks-legacy/list-webhooks * * @return false|mixed */ @@ -2267,7 +2267,7 @@ public function get_webhooks( * @param string $event Event to subscribe to. * @param string $parameter Optional parameter depending on the event. * - * @see https://developers.kit.com/api-reference/webhooks/create-a-webhook + * @see https://developers.kit.com/api-reference/webhooks-legacy/create-a-webhook * * @throws \InvalidArgumentException If the event is not supported. * @@ -2350,7 +2350,7 @@ public function create_webhook(string $url, string $event, string $parameter = ' * * @param integer $id Webhook ID. * - * @see https://developers.kit.com/api-reference/webhooks/delete-a-webhook + * @see https://developers.kit.com/api-reference/webhooks-legacy/delete-a-webhook * * @return mixed|object */ @@ -2359,6 +2359,308 @@ public function delete_webhook(int $id) return $this->delete(sprintf('webhooks/%s', $id)); } + /** + * List webhook endpoints. + * + * Webhook endpoints supersede the legacy webhooks resource. Use these methods for + * new integrations; get_webhooks(), create_webhook() and delete_webhook() continue + * to work against the legacy resource. + * + * @param string $status Endpoint status (active|disabled). + * @param boolean $include_total_count To include the total count of records in the response, use true. + * @param string $after_cursor Return results after the given pagination cursor. + * @param string $before_cursor Return results before the given pagination cursor. + * @param integer $per_page Number of results to return. + * + * @since 2.8.0 + * + * @see https://developers.kit.com/api-reference/webhooks/list-webhook-endpoints + * + * @return false|mixed + */ + public function get_webhook_endpoints( + string $status = '', + bool $include_total_count = false, + string $after_cursor = '', + string $before_cursor = '', + int $per_page = 100 + ) { + // Build parameters. + $options = []; + + if (!empty($status)) { + $options['status'] = $status; + } + + // Send request. + return $this->get( + 'webhook_endpoints', + $this->build_total_count_and_pagination_params( + $options, + $include_total_count, + $after_cursor, + $before_cursor, + $per_page + ) + ); + } + + /** + * Create a webhook endpoint. + * + * The signing secret is returned in this response only, and cannot be fetched again. + * Store it to verify the signature of deliveries sent to this endpoint. + * + * @param string $url URL to receive deliveries. Must be publicly reachable. + * @param array $events Event types to subscribe to e.g. subscriber.created. + * @param string $name Name of the webhook endpoint. + * @param string $description Description of the webhook endpoint. + * + * @since 2.8.0 + * + * @see https://developers.kit.com/api-reference/webhooks/create-a-webhook-endpoint + * + * @return mixed|object + */ + public function create_webhook_endpoint( + string $url, + array $events, + string $name = '', + string $description = '' + ) { + // Build parameters. + $options = [ + 'url' => $url, + 'events' => $events, + ]; + + if (!empty($name)) { + $options['name'] = $name; + } + if (!empty($description)) { + $options['description'] = $description; + } + + // Send request. + return $this->post( + 'webhook_endpoints', + $options + ); + } + + /** + * Get a webhook endpoint. + * + * The signing secret is never included in this response. + * + * @param integer $id Webhook Endpoint ID. + * + * @since 2.8.0 + * + * @see https://developers.kit.com/api-reference/webhooks/get-a-webhook-endpoint + * + * @return mixed|object + */ + public function get_webhook_endpoint(int $id) + { + return $this->get(sprintf('webhook_endpoints/%s', $id)); + } + + /** + * Update a webhook endpoint. + * + * Only the supplied parameters are sent, leaving any other values unchanged. + * $events replaces the endpoint's entire subscription list, so specify the + * full set of event types to subscribe to, not just additions. + * + * @param integer $id Webhook Endpoint ID. + * @param string|null $name Name of the webhook endpoint. + * @param string|null $url URL to receive deliveries. Must be publicly reachable. + * @param string|null $description Description of the webhook endpoint. + * @param string|null $status Endpoint status (active|disabled). + * @param array|null $events Event types to subscribe to e.g. subscriber.created. + * + * @since 2.8.0 + * + * @see https://developers.kit.com/api-reference/webhooks/update-a-webhook-endpoint + * + * @return mixed|object + */ + public function update_webhook_endpoint( + int $id, + string|null $name = null, + string|null $url = null, + string|null $description = null, + string|null $status = null, + array|null $events = null + ) { + // Build parameters, omitting any that weren't specified. + $options = []; + + if (!is_null($name)) { + $options['name'] = $name; + } + if (!is_null($url)) { + $options['url'] = $url; + } + if (!is_null($description)) { + $options['description'] = $description; + } + if (!is_null($status)) { + $options['status'] = $status; + } + if (!is_null($events)) { + $options['events'] = $events; + } + + // Send request. + return $this->patch( + sprintf('webhook_endpoints/%s', $id), + $options + ); + } + + /** + * Delete a webhook endpoint. + * + * To stop deliveries without deleting the endpoint, use update_webhook_endpoint() + * to set the endpoint's status to disabled. + * + * @param integer $id Webhook Endpoint ID. + * + * @since 2.8.0 + * + * @see https://developers.kit.com/api-reference/webhooks/delete-a-webhook-endpoint + * + * @return mixed|object + */ + public function delete_webhook_endpoint(int $id) + { + return $this->delete(sprintf('webhook_endpoints/%s', $id)); + } + + /** + * Rotate a webhook endpoint's signing secret. + * + * The new signing secret is returned in this response only, and cannot be fetched + * again. The previous secret continues to verify deliveries until the endpoint's + * previous_secret_expires_at, with deliveries signed by both secrets until then. + * + * Rotating whilst a previous rotation's overlap window is still open returns an + * error; specify $force to rotate anyway, expiring the older secret immediately. + * + * @param integer $id Webhook Endpoint ID. + * @param boolean $force Rotate even if the previous rotation's overlap window is open. + * + * @since 2.8.0 + * + * @see https://developers.kit.com/api-reference/webhooks/rotate-a-webhook-endpoint-secret + * + * @return mixed|object + */ + public function rotate_webhook_endpoint_secret(int $id, bool $force = false) + { + // Build parameters. + $options = []; + + if ($force) { + $options['force'] = true; + } + + // Send request. + return $this->post( + sprintf('webhook_endpoints/%s/rotate_secret', $id), + $options + ); + } + + /** + * Revoke a webhook endpoint's previous signing secret. + * + * Closes a rotation's overlap window early, so that only the current secret + * verifies deliveries. + * + * @param integer $id Webhook Endpoint ID. + * + * @since 2.8.0 + * + * @see https://developers.kit.com/api-reference/webhooks/revoke-the-previous-webhook-endpoint-secret + * + * @return mixed|object + */ + public function revoke_webhook_endpoint_previous_secret(int $id) + { + return $this->post(sprintf('webhook_endpoints/%s/revoke_previous_secret', $id)); + } + + /** + * Verify the signature of a webhook endpoint delivery. + * + * Kit signs each delivery with the endpoint's signing secret, sending the timestamp + * and one or more signatures in the X-Kit-Signature header. Deliveries are signed + * with both the current and previous secret whilst a rotation's overlap window is + * open, so any one signature matching means the delivery is valid. + * + * $payload must be the raw request body, byte for byte. Decoding and re-encoding it + * changes whitespace and key order, which produces a different signature. + * + * @param string $payload Raw request body. + * @param string $signature_header X-Kit-Signature header value. + * @param string $secret Webhook endpoint signing secret. + * @param integer $tolerance Maximum permitted age of the delivery, in seconds. + * + * @since 2.8.0 + * + * @see https://developers.kit.com/webhooks/verifying-signatures + * + * @return boolean + */ + public function verify_webhook_signature( + string $payload, + string $signature_header, + string $secret, + int $tolerance = 300 + ) { + // Parse the header into its timestamp and signatures. + $timestamp = ''; + $signatures = []; + + foreach (explode(',', $signature_header) as $part) { + $part = trim($part); + + if (strpos($part, 't=') === 0) { + $timestamp = substr($part, 2); + continue; + } + + if (strpos($part, 'v1=') === 0) { + $signatures[] = substr($part, 3); + } + } + + // Bail if the header didn't include a timestamp and at least one signature. + if (!is_numeric($timestamp) || !count($signatures)) { + return false; + } + + // Bail if the delivery is older than the permitted tolerance, to prevent replays. + if (abs((time() - (int) $timestamp)) > $tolerance) { + return false; + } + + // Build the signature we expect for this payload. + $expected = hash_hmac('sha256', $timestamp . '.' . $payload, $secret); + + // The delivery is valid if any of its signatures match. + foreach ($signatures as $signature) { + if (hash_equals($expected, $signature)) { + return true; + } + } + + return false; + } + /** * List custom fields. * From 739d474e2217545ed43470eaf7cd934056898c98 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Thu, 10 Sep 2026 13:38:58 +0800 Subject: [PATCH 2/2] Add Webhook Endpoint Tests --- tests/ConvertKitAPITest.php | 14 + tests/TestsTrait.php | 770 ++++++++++++++++++++++++++++++++++++ 2 files changed, 784 insertions(+) diff --git a/tests/ConvertKitAPITest.php b/tests/ConvertKitAPITest.php index c7d7c3f..f795244 100644 --- a/tests/ConvertKitAPITest.php +++ b/tests/ConvertKitAPITest.php @@ -74,6 +74,15 @@ abstract class ConvertKitAPITest extends TestCase */ protected $broadcast_ids = []; + /** + * Webhook Endpoint IDs to delete on teardown of a test. + * + * @since 2.8.0 + * + * @var array + */ + protected $webhook_endpoint_ids = []; + /** * Cleanup data from the ConvertKit account on a test pass/fail, such as unsubscribing, deleting custom fields etc * @@ -102,6 +111,11 @@ protected function tearDown(): void foreach ($this->broadcast_ids as $id) { $this->api->delete_broadcast($id); } + + // Delete any Webhook Endpoints. + foreach ($this->webhook_endpoint_ids as $id) { + $this->api->delete_webhook_endpoint($id); + } } /** diff --git a/tests/TestsTrait.php b/tests/TestsTrait.php index e0083a2..eeca625 100644 --- a/tests/TestsTrait.php +++ b/tests/TestsTrait.php @@ -6187,6 +6187,750 @@ public function testDeleteWebhookWithInvalidID() }); } + /** + * Test that get_webhook_endpoints() returns the expected data. + * + * @since 2.8.0 + * + * @return void + */ + public function testGetWebhookEndpoints() + { + // Create a webhook endpoint first, so at least one exists. + $result = $this->api->create_webhook_endpoint( + url: 'https://webhook.site/' . str_shuffle('wfervdrtgsdewrafvwefds'), + events: ['subscriber.created'] + ); + + // Set webhook_endpoint_ids to ensure webhook endpoints are deleted after test. + $this->webhook_endpoint_ids = [ $result->webhook_endpoint->id ]; + + // Get webhook endpoints. + $result = $this->api->get_webhook_endpoints(); + + // Assert webhook endpoints and pagination exist. + $this->assertDataExists($result, 'webhook_endpoints'); + $this->assertPaginationExists($result); + } + + /** + * Test that get_webhook_endpoints() returns the expected data + * when the total count is included. + * + * @since 2.8.0 + * + * @return void + */ + public function testGetWebhookEndpointsWithTotalCount() + { + // Create a webhook endpoint first, so at least one exists. + $result = $this->api->create_webhook_endpoint( + url: 'https://webhook.site/' . str_shuffle('wfervdrtgsdewrafvwefds'), + events: ['subscriber.created'] + ); + + // Set webhook_endpoint_ids to ensure webhook endpoints are deleted after test. + $this->webhook_endpoint_ids = [ $result->webhook_endpoint->id ]; + + // Get webhook endpoints. + $result = $this->api->get_webhook_endpoints( + include_total_count: true + ); + + // Assert webhook endpoints and pagination exist. + $this->assertDataExists($result, 'webhook_endpoints'); + $this->assertPaginationExists($result); + + // Assert total count is included. + $this->assertArrayHasKey('total_count', get_object_vars($result->pagination)); + $this->assertGreaterThan(0, $result->pagination->total_count); + } + + /** + * Test that get_webhook_endpoints() returns the expected data + * when pagination parameters and per_page limits are specified. + * + * @since 2.8.0 + * + * @return void + */ + public function testGetWebhookEndpointsPagination() + { + // Create webhook endpoints first. + $results = [ + $this->api->create_webhook_endpoint( + url: 'https://webhook.site/' . str_shuffle('wfervdrtgsdewrafvwefds'), + events: ['subscriber.created'] + ), + $this->api->create_webhook_endpoint( + url: 'https://webhook.site/' . str_shuffle('wfervdrtgsdewrafvwefds'), + events: ['subscriber.created'] + ), + ]; + + // Set webhook_endpoint_ids to ensure webhook endpoints are deleted after test. + $this->webhook_endpoint_ids = [ + $results[0]->webhook_endpoint->id, + $results[1]->webhook_endpoint->id, + ]; + + // Get webhook endpoints. + $result = $this->api->get_webhook_endpoints( + per_page: 1 + ); + + // Assert webhook endpoints and pagination exist. + $this->assertDataExists($result, 'webhook_endpoints'); + $this->assertPaginationExists($result); + + // Assert a single webhook endpoint was returned. + $this->assertCount(1, $result->webhook_endpoints); + + // Assert has_previous_page and has_next_page are correct. + $this->assertFalse($result->pagination->has_previous_page); + $this->assertTrue($result->pagination->has_next_page); + + // Use pagination to fetch next page. + $result = $this->api->get_webhook_endpoints( + per_page: 1, + after_cursor: $result->pagination->end_cursor + ); + + // Assert webhook endpoints and pagination exist. + $this->assertDataExists($result, 'webhook_endpoints'); + $this->assertPaginationExists($result); + + // Assert a single webhook endpoint was returned. + $this->assertCount(1, $result->webhook_endpoints); + + // Assert has_previous_page and has_next_page are correct. + $this->assertTrue($result->pagination->has_previous_page); + + // Use pagination to fetch previous page. + $result = $this->api->get_webhook_endpoints( + per_page: 1, + before_cursor: $result->pagination->start_cursor + ); + + // Assert webhook endpoints and pagination exist. + $this->assertDataExists($result, 'webhook_endpoints'); + $this->assertPaginationExists($result); + + // Assert a single webhook endpoint was returned. + $this->assertCount(1, $result->webhook_endpoints); + } + + /** + * Test that get_webhook_endpoints() returns the expected data + * when a status is specified. + * + * @since 2.8.0 + * + * @return void + */ + public function testGetWebhookEndpointsWithStatus() + { + // Create a webhook endpoint first, which will be active. + $result = $this->api->create_webhook_endpoint( + url: 'https://webhook.site/' . str_shuffle('wfervdrtgsdewrafvwefds'), + events: ['subscriber.created'] + ); + + // Set webhook_endpoint_ids to ensure webhook endpoints are deleted after test. + $this->webhook_endpoint_ids = [ $result->webhook_endpoint->id ]; + + // Get active webhook endpoints. + $result = $this->api->get_webhook_endpoints( + status: 'active' + ); + + // Assert webhook endpoints and pagination exist. + $this->assertDataExists($result, 'webhook_endpoints'); + $this->assertPaginationExists($result); + + // Assert only active webhook endpoints were returned. + foreach ($result->webhook_endpoints as $webhook_endpoint) { + $this->assertEquals('active', $webhook_endpoint->status); + } + } + + /** + * Test that create_webhook_endpoint(), get_webhook_endpoint() and + * delete_webhook_endpoint() return the expected data. + * + * @since 2.8.0 + * + * @return void + */ + public function testCreateGetAndDeleteWebhookEndpoint() + { + // Create a webhook endpoint. + $url = 'https://webhook.site/' . str_shuffle('wfervdrtgsdewrafvwefds'); + $result = $this->api->create_webhook_endpoint( + url: $url, + events: ['subscriber.created'] + ); + + // Confirm the webhook endpoint created with the correct data. + $this->assertArrayHasKey('webhook_endpoint', get_object_vars($result)); + $webhook_endpoint = get_object_vars($result->webhook_endpoint); + $this->assertArrayHasKey('id', $webhook_endpoint); + $this->assertArrayHasKey('status', $webhook_endpoint); + $this->assertEquals($url, $webhook_endpoint['url']); + $this->assertEquals(['subscriber.created'], $webhook_endpoint['events']); + + // Confirm the signing secret is returned when creating the webhook endpoint. + $this->assertArrayHasKey('secret', $webhook_endpoint); + $this->assertStringStartsWith('whsec_', $webhook_endpoint['secret']); + + $id = $result->webhook_endpoint->id; + + // Get the webhook endpoint. + $result = $this->api->get_webhook_endpoint($id); + + // Confirm the expected webhook endpoint was returned. + $webhook_endpoint = get_object_vars($result->webhook_endpoint); + $this->assertEquals($id, $webhook_endpoint['id']); + $this->assertEquals($url, $webhook_endpoint['url']); + + // Confirm the signing secret is never returned when getting a webhook endpoint. + $this->assertArrayNotHasKey('secret', $webhook_endpoint); + + // Delete the webhook endpoint. + $result = $this->api->delete_webhook_endpoint($id); + + // Confirm the webhook endpoint no longer exists. + $this->assertApiError( + function () use ($id) { + return $this->api->get_webhook_endpoint($id); + } + ); + } + + /** + * Test that create_webhook_endpoint() returns the expected data + * when a name and description are specified. + * + * @since 2.8.0 + * + * @return void + */ + public function testCreateWebhookEndpointWithNameAndDescription() + { + // Create a webhook endpoint. + $result = $this->api->create_webhook_endpoint( + url: 'https://webhook.site/' . str_shuffle('wfervdrtgsdewrafvwefds'), + events: ['subscriber.created'], + name: 'Test Webhook Endpoint', + description: 'Test Webhook Endpoint Description' + ); + + // Set webhook_endpoint_ids to ensure webhook endpoints are deleted after test. + $this->webhook_endpoint_ids = [ $result->webhook_endpoint->id ]; + + // Confirm the webhook endpoint created with the correct data. + $webhook_endpoint = get_object_vars($result->webhook_endpoint); + $this->assertEquals('Test Webhook Endpoint', $webhook_endpoint['name']); + $this->assertEquals('Test Webhook Endpoint Description', $webhook_endpoint['description']); + } + + /** + * Test that create_webhook_endpoint() returns the expected data + * when multiple events are specified. + * + * @since 2.8.0 + * + * @return void + */ + public function testCreateWebhookEndpointWithMultipleEvents() + { + // Create a webhook endpoint. + $events = [ + 'subscriber.created', + 'subscriber.unsubscribed', + 'tag.created', + ]; + $result = $this->api->create_webhook_endpoint( + url: 'https://webhook.site/' . str_shuffle('wfervdrtgsdewrafvwefds'), + events: $events + ); + + // Set webhook_endpoint_ids to ensure webhook endpoints are deleted after test. + $this->webhook_endpoint_ids = [ $result->webhook_endpoint->id ]; + + // Confirm the webhook endpoint subscribed to all of the specified events. + $webhook_endpoint = get_object_vars($result->webhook_endpoint); + $this->assertCount(count($events), $webhook_endpoint['events']); + foreach ($events as $event) { + $this->assertContains($event, $webhook_endpoint['events']); + } + } + + /** + * Test that create_webhook_endpoint() throws a ClientException when a URL + * that is not publicly reachable is specified. + * + * @since 2.8.0 + * + * @return void + */ + public function testCreateWebhookEndpointWithInvalidURL() + { + $this->assertApiError( + function () { + return $this->api->create_webhook_endpoint( + url: 'http://127.0.0.1/webhook', + events: ['subscriber.created'] + ); + } + ); + } + + /** + * Test that get_webhook_endpoint() throws a ClientException when an invalid + * ID is specified. + * + * @since 2.8.0 + * + * @return void + */ + public function testGetWebhookEndpointWithInvalidID() + { + $this->assertApiError( + function () { + return $this->api->get_webhook_endpoint(12345); + } + ); + } + + /** + * Test that update_webhook_endpoint() returns the expected data. + * + * @since 2.8.0 + * + * @return void + */ + public function testUpdateWebhookEndpoint() + { + // Create a webhook endpoint first. + $result = $this->api->create_webhook_endpoint( + url: 'https://webhook.site/' . str_shuffle('wfervdrtgsdewrafvwefds'), + events: ['subscriber.created'], + name: 'Test Webhook Endpoint' + ); + $id = $result->webhook_endpoint->id; + + // Set webhook_endpoint_ids to ensure webhook endpoints are deleted after test. + $this->webhook_endpoint_ids = [ $id ]; + + // Update the webhook endpoint. + $url = 'https://webhook.site/' . str_shuffle('wfervdrtgsdewrafvwefds'); + $result = $this->api->update_webhook_endpoint( + id: $id, + name: 'Updated Webhook Endpoint', + url: $url, + events: ['tag.created'] + ); + + // Confirm the webhook endpoint updated with the correct data. + $webhook_endpoint = get_object_vars($result->webhook_endpoint); + $this->assertEquals('Updated Webhook Endpoint', $webhook_endpoint['name']); + $this->assertEquals($url, $webhook_endpoint['url']); + + // Confirm the specified events replaced the webhook endpoint's existing events. + $this->assertEquals(['tag.created'], $webhook_endpoint['events']); + } + + /** + * Test that update_webhook_endpoint() only updates the specified parameters, + * leaving other values unchanged. + * + * @since 2.8.0 + * + * @return void + */ + public function testUpdateWebhookEndpointStatus() + { + // Create a webhook endpoint first. + $result = $this->api->create_webhook_endpoint( + url: 'https://webhook.site/' . str_shuffle('wfervdrtgsdewrafvwefds'), + events: ['subscriber.created'], + name: 'Test Webhook Endpoint' + ); + $id = $result->webhook_endpoint->id; + $url = $result->webhook_endpoint->url; + + // Set webhook_endpoint_ids to ensure webhook endpoints are deleted after test. + $this->webhook_endpoint_ids = [ $id ]; + + // Disable the webhook endpoint. + $result = $this->api->update_webhook_endpoint( + id: $id, + status: 'disabled' + ); + + // Confirm the webhook endpoint is disabled. + $webhook_endpoint = get_object_vars($result->webhook_endpoint); + $this->assertEquals('disabled', $webhook_endpoint['status']); + + // Confirm the parameters that weren't specified are unchanged. + $this->assertEquals('Test Webhook Endpoint', $webhook_endpoint['name']); + $this->assertEquals($url, $webhook_endpoint['url']); + $this->assertEquals(['subscriber.created'], $webhook_endpoint['events']); + } + + /** + * Test that update_webhook_endpoint() throws a ClientException when an invalid + * ID is specified. + * + * @since 2.8.0 + * + * @return void + */ + public function testUpdateWebhookEndpointWithInvalidID() + { + $this->assertApiError( + function () { + return $this->api->update_webhook_endpoint( + id: 12345, + status: 'disabled' + ); + } + ); + } + + /** + * Test that delete_webhook_endpoint() throws a ClientException when an invalid + * ID is specified. + * + * @since 2.8.0 + * + * @return void + */ + public function testDeleteWebhookEndpointWithInvalidID() + { + $this->assertApiError( + function () { + return $this->api->delete_webhook_endpoint(12345); + } + ); + } + + /** + * Test that rotate_webhook_endpoint_secret() returns the expected data. + * + * @since 2.8.0 + * + * @return void + */ + public function testRotateWebhookEndpointSecret() + { + // Create a webhook endpoint first. + $result = $this->api->create_webhook_endpoint( + url: 'https://webhook.site/' . str_shuffle('wfervdrtgsdewrafvwefds'), + events: ['subscriber.created'] + ); + $id = $result->webhook_endpoint->id; + $secret = $result->webhook_endpoint->secret; + + // Set webhook_endpoint_ids to ensure webhook endpoints are deleted after test. + $this->webhook_endpoint_ids = [ $id ]; + + // Rotate the webhook endpoint's secret. + $result = $this->api->rotate_webhook_endpoint_secret($id); + + // Confirm a new signing secret was returned. + $webhook_endpoint = get_object_vars($result->webhook_endpoint); + $this->assertArrayHasKey('secret', $webhook_endpoint); + $this->assertStringStartsWith('whsec_', $webhook_endpoint['secret']); + $this->assertNotEquals($secret, $webhook_endpoint['secret']); + + // Confirm the previous secret remains valid until the overlap window closes. + $this->assertNotNull($webhook_endpoint['previous_secret_expires_at']); + } + + /** + * Test that rotate_webhook_endpoint_secret() throws a ClientException when the + * previous rotation's overlap window is still open. + * + * @since 2.8.0 + * + * @return void + */ + public function testRotateWebhookEndpointSecretWhenOverlapWindowOpen() + { + // Create a webhook endpoint first. + $result = $this->api->create_webhook_endpoint( + url: 'https://webhook.site/' . str_shuffle('wfervdrtgsdewrafvwefds'), + events: ['subscriber.created'] + ); + $id = $result->webhook_endpoint->id; + + // Set webhook_endpoint_ids to ensure webhook endpoints are deleted after test. + $this->webhook_endpoint_ids = [ $id ]; + + // Rotate the webhook endpoint's secret, which opens the overlap window. + $this->api->rotate_webhook_endpoint_secret($id); + + // Confirm rotating again whilst the overlap window is open returns an error. + $this->assertApiError( + function () use ($id) { + return $this->api->rotate_webhook_endpoint_secret($id); + } + ); + } + + /** + * Test that rotate_webhook_endpoint_secret() returns the expected data when + * the previous rotation's overlap window is still open and force is specified. + * + * @since 2.8.0 + * + * @return void + */ + public function testRotateWebhookEndpointSecretWithForce() + { + // Create a webhook endpoint first. + $result = $this->api->create_webhook_endpoint( + url: 'https://webhook.site/' . str_shuffle('wfervdrtgsdewrafvwefds'), + events: ['subscriber.created'] + ); + $id = $result->webhook_endpoint->id; + + // Set webhook_endpoint_ids to ensure webhook endpoints are deleted after test. + $this->webhook_endpoint_ids = [ $id ]; + + // Rotate the webhook endpoint's secret, which opens the overlap window. + $result = $this->api->rotate_webhook_endpoint_secret($id); + $secret = $result->webhook_endpoint->secret; + + // Rotate again, forcing the older secret to expire immediately. + $result = $this->api->rotate_webhook_endpoint_secret( + id: $id, + force: true + ); + + // Confirm a new signing secret was returned. + $webhook_endpoint = get_object_vars($result->webhook_endpoint); + $this->assertArrayHasKey('secret', $webhook_endpoint); + $this->assertNotEquals($secret, $webhook_endpoint['secret']); + } + + /** + * Test that revoke_webhook_endpoint_previous_secret() returns the expected data. + * + * @since 2.8.0 + * + * @return void + */ + public function testRevokeWebhookEndpointPreviousSecret() + { + // Create a webhook endpoint first. + $result = $this->api->create_webhook_endpoint( + url: 'https://webhook.site/' . str_shuffle('wfervdrtgsdewrafvwefds'), + events: ['subscriber.created'] + ); + $id = $result->webhook_endpoint->id; + + // Set webhook_endpoint_ids to ensure webhook endpoints are deleted after test. + $this->webhook_endpoint_ids = [ $id ]; + + // Rotate the webhook endpoint's secret, which opens the overlap window. + $result = $this->api->rotate_webhook_endpoint_secret($id); + $this->assertNotNull($result->webhook_endpoint->previous_secret_expires_at); + + // Revoke the previous secret, closing the overlap window. + $result = $this->api->revoke_webhook_endpoint_previous_secret($id); + + // Confirm the overlap window is closed. + $webhook_endpoint = get_object_vars($result->webhook_endpoint); + $this->assertNull($webhook_endpoint['previous_secret_expires_at']); + + // Confirm the signing secret is not returned when revoking the previous secret. + $this->assertArrayNotHasKey('secret', $webhook_endpoint); + } + + /** + * Test that verify_webhook_signature() returns true when the signature is valid. + * + * @since 2.8.0 + * + * @return void + */ + public function testVerifyWebhookSignature() + { + $payload = '{"delivery_id":123456,"events":[]}'; + $secret = 'whsec_' . str_shuffle('wfervdrtgsdewrafvwefds'); + + $this->assertTrue( + $this->api->verify_webhook_signature( + payload: $payload, + signature_header: $this->generateWebhookSignature($payload, $secret), + secret: $secret + ) + ); + } + + /** + * Test that verify_webhook_signature() returns false when the payload was + * modified after it was signed. + * + * @since 2.8.0 + * + * @return void + */ + public function testVerifyWebhookSignatureWithTamperedPayload() + { + $payload = '{"delivery_id":123456,"events":[]}'; + $secret = 'whsec_' . str_shuffle('wfervdrtgsdewrafvwefds'); + $signature_header = $this->generateWebhookSignature($payload, $secret); + + $this->assertFalse( + $this->api->verify_webhook_signature( + payload: '{"delivery_id":999999,"events":[]}', + signature_header: $signature_header, + secret: $secret + ) + ); + } + + /** + * Test that verify_webhook_signature() returns false when the payload was + * signed with a different secret. + * + * @since 2.8.0 + * + * @return void + */ + public function testVerifyWebhookSignatureWithIncorrectSecret() + { + $payload = '{"delivery_id":123456,"events":[]}'; + + $this->assertFalse( + $this->api->verify_webhook_signature( + payload: $payload, + signature_header: $this->generateWebhookSignature($payload, 'whsec_incorrect'), + secret: 'whsec_correct' + ) + ); + } + + /** + * Test that verify_webhook_signature() returns false when the delivery is + * older than the permitted tolerance. + * + * @since 2.8.0 + * + * @return void + */ + public function testVerifyWebhookSignatureWithExpiredTimestamp() + { + $payload = '{"delivery_id":123456,"events":[]}'; + $secret = 'whsec_' . str_shuffle('wfervdrtgsdewrafvwefds'); + $timestamp = (time() - 600); + + $this->assertFalse( + $this->api->verify_webhook_signature( + payload: $payload, + signature_header: $this->generateWebhookSignature($payload, $secret, $timestamp), + secret: $secret + ) + ); + + // Confirm the same signature is valid when the tolerance permits it. + $this->assertTrue( + $this->api->verify_webhook_signature( + payload: $payload, + signature_header: $this->generateWebhookSignature($payload, $secret, $timestamp), + secret: $secret, + tolerance: 900 + ) + ); + } + + /** + * Test that verify_webhook_signature() returns true for both the current and + * previous secret whilst a rotation's overlap window is open. + * + * @since 2.8.0 + * + * @return void + */ + public function testVerifyWebhookSignatureDuringSecretRotation() + { + $payload = '{"delivery_id":123456,"events":[]}'; + $current_secret = 'whsec_current'; + $previous_secret = 'whsec_previous'; + $timestamp = time(); + + // Build a header containing a signature for both secrets, as Kit sends + // whilst a rotation's overlap window is open. + $signature_header = sprintf( + 't=%d,v1=%s,v1=%s', + $timestamp, + hash_hmac('sha256', $timestamp . '.' . $payload, $current_secret), + hash_hmac('sha256', $timestamp . '.' . $payload, $previous_secret) + ); + + // Confirm both secrets verify the delivery. + $this->assertTrue( + $this->api->verify_webhook_signature( + payload: $payload, + signature_header: $signature_header, + secret: $current_secret + ) + ); + $this->assertTrue( + $this->api->verify_webhook_signature( + payload: $payload, + signature_header: $signature_header, + secret: $previous_secret + ) + ); + + // Confirm a secret that didn't sign the delivery does not verify it. + $this->assertFalse( + $this->api->verify_webhook_signature( + payload: $payload, + signature_header: $signature_header, + secret: 'whsec_other' + ) + ); + } + + /** + * Test that verify_webhook_signature() returns false when the signature header + * is missing or malformed. + * + * @since 2.8.0 + * + * @return void + */ + public function testVerifyWebhookSignatureWithInvalidHeader() + { + $payload = '{"delivery_id":123456,"events":[]}'; + $secret = 'whsec_' . str_shuffle('wfervdrtgsdewrafvwefds'); + + $headers = [ + '', + 'not-a-signature', + 't=' . time(), + 'v1=' . hash_hmac('sha256', time() . '.' . $payload, $secret), + 't=abc,v1=' . hash_hmac('sha256', time() . '.' . $payload, $secret), + ]; + + foreach ($headers as $signature_header) { + $this->assertFalse( + $this->api->verify_webhook_signature( + payload: $payload, + signature_header: $signature_header, + secret: $secret + ), + sprintf('Signature header "%s" should not verify.', $signature_header) + ); + } + } + /** * Test that get_custom_fields() returns the expected data. * @@ -7008,6 +7752,32 @@ function () use ($emailAddress) { return $subscriberID; } + /** + * Generates an X-Kit-Signature header value for the given payload and secret, + * signed as Kit signs webhook endpoint deliveries. + * + * @since 2.8.0 + * + * @see https://developers.kit.com/webhooks/verifying-signatures + * + * @param string $payload Raw request body. + * @param string $secret Webhook endpoint signing secret. + * @param integer|null $timestamp Timestamp to sign with. Defaults to now. + * @return string Signature header value. + */ + public function generateWebhookSignature($payload, $secret, $timestamp = null) + { + if (is_null($timestamp)) { + $timestamp = time(); + } + + return sprintf( + 't=%d,v1=%s', + $timestamp, + hash_hmac('sha256', $timestamp . '.' . $payload, $secret) + ); + } + /** * Checks if string is html. *