From 9ce83507e197f29d014479ee7a2ddebb71986d0d Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Tue, 3 Mar 2026 15:33:25 +0800 Subject: [PATCH 1/6] Add `filter_subscribers` method --- src/ConvertKit_API_Traits.php | 66 ++++++++++++++ tests/ConvertKitAPITest.php | 160 ++++++++++++++++++++++++++++++++++ 2 files changed, 226 insertions(+) diff --git a/src/ConvertKit_API_Traits.php b/src/ConvertKit_API_Traits.php index 4511a73..7ee9ec6 100644 --- a/src/ConvertKit_API_Traits.php +++ b/src/ConvertKit_API_Traits.php @@ -945,6 +945,72 @@ public function create_subscribers(array $subscribers, string $callback_url = '' ); } + /** + * Filter subscribers based on engagement. + * + * @param array> $all Array of filter conditions where ALL must be met (AND logic) + * - 'type' (string) Type of filter condition ('opens', 'clicks', 'sent', 'delivered', 'subscribed'). + * - 'count_greater_than' (int) Minimum count (exclusive). Not applicable for 'subscribed' type. + * - 'count_less_than' (int) Maximum count (exclusive). Not applicable for 'subscribed' type. + * - 'after' (\DateTime|null) Start date. For 'subscribed' type, filters by subscriber_created_at. For other types, filters by event date. + * - 'before' (\DateTime|null) End date. For 'subscribed' type, filters by subscriber_created_at. For other types, filters by event date. + * - 'any' (array) Array of OR conditions for filtering by specific broadcasts or URLs. + * @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.4.0 + * + * @see https://developers.kit.com/api-reference/subscribers/filter-subscribers-based-on-engagement + * + * @return mixed + */ + public function filter_subscribers( + array $all = [], + bool $include_total_count = false, + string $after_cursor = '', + string $before_cursor = '', + int $per_page = 100 + ) { + // Build parameters. + $options = []; + + foreach ($all as $condition) { + $option = []; + if (!is_null($condition['count_greater_than'])) { + $option['count_greater_than'] = (int) $condition['count_greater_than']; + } + if (!is_null($condition['count_less_than'])) { + $option['count_less_than'] = (int) $condition['count_less_than']; + } + if (!is_null($condition['after'])) { + $option['after'] = $condition['after']->format('Y-m-d'); + } + if (!is_null($condition['before'])) { + $option['before'] = $condition['before']->format('Y-m-d'); + } + if (!empty($condition['any'])) { + $option['any'] = (array) $condition['any']; + } + + // Add to options array. + $options[] = $option; + }//end foreach + + // Send request. + return $this->post( + 'subscribers/filter', + $this->build_total_count_and_pagination_params( + ['all' => $options], + $include_total_count, + $after_cursor, + $before_cursor, + $per_page + ) + ); + } + /** * Get the ConvertKit subscriber ID associated with email address if it exists. * Return false if subscriber not found. diff --git a/tests/ConvertKitAPITest.php b/tests/ConvertKitAPITest.php index 70bbf12..f04eaa7 100644 --- a/tests/ConvertKitAPITest.php +++ b/tests/ConvertKitAPITest.php @@ -3679,6 +3679,166 @@ public function testCreateSubscribersWithInvalidEmailAddresses() $this->assertCount(2, $result->failures); } + /** + * Test that filter_subscribers() returns the expected data. + * + * @since 2.4.0 + * + * @return void + */ + public function testFilterSubscribers() + { + $result = $this->api->filter_subscribers( + [ + [ + 'type' => 'opens', + 'count_greater_than' => 10, + 'count_less_than' => 100, + 'after' => new \DateTime('2024-01-01'), + 'before' => new \DateTime('2027-01-01'), + ] + ] + ); + + // Assert subscribers and pagination exist. + $this->assertDataExists($result, 'subscribers'); + $this->assertPaginationExists($result); + } + + /** + * Test that filter_subscribers() returns the expected data + * when multiple all conditions are specified. + * + * @since 2.4.0 + * + * @return void + */ + public function testFilterSubscribersWithMultipleConditions() + { + $result = $this->api->filter_subscribers( + [ + [ + 'type' => 'opens', + 'count_greater_than' => 10, + 'count_less_than' => 100, + 'after' => new \DateTime('2024-01-01'), + 'before' => new \DateTime('2027-01-01'), + ], + [ + 'type' => 'clicks', + 'count_greater_than' => 1, + 'count_less_than' => 100, + 'after' => new \DateTime('2024-01-01'), + 'before' => new \DateTime('2027-01-01'), + ] + ] + ); + + // Assert subscribers and pagination exist. + $this->assertDataExists($result, 'subscribers'); + $this->assertPaginationExists($result); + } + + /** + * Test that filter_subscribers() returns the expected data + * when multiple any conditions are specified. + * + * @since 2.4.0 + * + * @return void + */ + public function testFilterSubscribersWithNoParameters() + { + $result = $this->api->filter_subscribers(); + + // Assert subscribers and pagination exist. + $this->assertDataExists($result, 'subscribers'); + $this->assertPaginationExists($result); + } + + /** + * Test that filter_subscribers() returns the expected data + * when the total count is included. + * + * @since 2.4.0 + * + * @return void + */ + public function testFilterSubscribersWithTotalCount() + { + $result = $this->api->filter_subscribers( + include_total_count: true + ); + + // Assert subscribers and pagination exist. + $this->assertDataExists($result, 'subscribers'); + $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 filter_subscribers() returns the expected data + * when pagination parameters and per_page limits are specified. + * + * @since 2.4.0 + * + * @return void + */ + public function testFilterSubscribersPagination() + { + $result = $this->api->filter_subscribers( + per_page: 1 + ); + + // Assert subscribers and pagination exist. + $this->assertDataExists($result, 'subscribers'); + $this->assertPaginationExists($result); + + // Assert a single subscriber was returned. + $this->assertCount(1, $result->subscribers); + + // 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->filter_subscribers( + per_page: 1, + after_cursor: $result->pagination->end_cursor + ); + + // Assert subscribers and pagination exist. + $this->assertDataExists($result, 'subscribers'); + $this->assertPaginationExists($result); + + // Assert a single subscriber was returned. + $this->assertCount(1, $result->subscribers); + + // Assert has_previous_page and has_next_page are correct. + $this->assertTrue($result->pagination->has_previous_page); + $this->assertTrue($result->pagination->has_next_page); + + // Use pagination to fetch previous page. + $result = $this->api->filter_subscribers( + per_page: 1, + before_cursor: $result->pagination->start_cursor + ); + + // Assert subscribers and pagination exist. + $this->assertDataExists($result, 'subscribers'); + $this->assertPaginationExists($result); + + // Assert a single subscriber was returned. + $this->assertCount(1, $result->subscribers); + + // Assert has_previous_page and has_next_page are correct. + $this->assertTrue($result->pagination->has_previous_page); + $this->assertFalse($result->pagination->has_next_page); + } + /** * Test that get_subscriber_id() returns the expected data. * From 4e4f4a0d402eeb5f273143beca0ac9b32fbf41a5 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Tue, 3 Mar 2026 16:36:45 +0800 Subject: [PATCH 2/6] Add `update_subscriber_custom_field_values` method --- src/ConvertKit_API_Traits.php | 30 +++++++++++++++++++++ tests/ConvertKitAPIKeyTest.php | 27 +++++++++++++++++++ tests/ConvertKitAPITest.php | 49 ++++++++++++++++++++++++++++++++++ 3 files changed, 106 insertions(+) diff --git a/src/ConvertKit_API_Traits.php b/src/ConvertKit_API_Traits.php index 7ee9ec6..56ff3c8 100644 --- a/src/ConvertKit_API_Traits.php +++ b/src/ConvertKit_API_Traits.php @@ -1683,6 +1683,36 @@ public function create_custom_fields(array $labels, string $callback_url = '') ); } + /** + * Bulk update subscriber custom field values + * + * @param array> $custom_field_values Array of custom field values to update. + * - 'subscriber_id' (int) Subscriber ID. + * - 'subscriber_custom_field_id' (int) Custom Field ID. + * - 'value' (string|integer) Value to update. + * @param string $callback_url URL to notify for large batch size when async processing complete. + * + * @since 2.4.0 + * + * @see https://developers.kit.com/api-reference/custom-fields/bulk-update-subscriber-custom-field-values + * + * @return mixed|object + */ + public function update_subscriber_custom_field_values(array $custom_field_values, string $callback_url = '') + { + // Build parameters. + $options = ['custom_field_values' => $custom_field_values]; + if (!empty($callback_url)) { + $options['callback_url'] = $callback_url; + } + + // Send request. + return $this->post( + 'bulk/custom_fields/subscribers', + $options + ); + } + /** * Update a custom field. * diff --git a/tests/ConvertKitAPIKeyTest.php b/tests/ConvertKitAPIKeyTest.php index 43dca9e..28ec468 100644 --- a/tests/ConvertKitAPIKeyTest.php +++ b/tests/ConvertKitAPIKeyTest.php @@ -475,6 +475,33 @@ public function testCreateCustomFields() $result = $this->api->create_custom_fields($labels); } + /** + * Test that update_subscriber_custom_field_values() throws a ClientException + * as this is only supported using OAuth. + * + * @since 2.4.0 + * + * @return void + */ + public function testUpdateSubscriberCustomFieldValues() + { + $this->expectException(ClientException::class); + $result = $this->api->update_subscriber_custom_field_values( + [ + [ + 'subscriber_id' => 1, + 'subscriber_custom_field_id' => (int) $_ENV['CONVERTKIT_API_CUSTOM_FIELD_ID'], + 'value' => '100', + ], + [ + 'subscriber_id' => 2, + 'subscriber_custom_field_id' => (int) $_ENV['CONVERTKIT_API_CUSTOM_FIELD_ID'], + 'value' => '200', + ], + ] + ); + } + /** * Test that get_purchases() throws a ClientException * as this is only supported using OAuth. diff --git a/tests/ConvertKitAPITest.php b/tests/ConvertKitAPITest.php index f04eaa7..7642f78 100644 --- a/tests/ConvertKitAPITest.php +++ b/tests/ConvertKitAPITest.php @@ -5059,6 +5059,55 @@ public function testCreateCustomFields() $this->assertIsArray($result->custom_fields); } + /** + * Test that update_subscriber_custom_field_values() works. + * + * @since 2.4.0 + * + * @return void + */ + public function testUpdateSubscriberCustomFieldValues() + { + // Create subscribers. + $subscribers = [ + [ + 'email_address' => str_replace('@kit.com', '-1@kit.com', $this->generateEmailAddress()), + ], + [ + 'email_address' => str_replace('@kit.com', '-2@kit.com', $this->generateEmailAddress()), + ], + ]; + $result = $this->api->create_subscribers($subscribers); + + // Set subscriber_id to ensure subscriber is unsubscribed after test. + foreach ($result->subscribers as $i => $subscriber) { + $this->subscriber_ids[] = $subscriber->id; + } + + // Bulk update subscriber custom field values. + $result = $this->api->update_subscriber_custom_field_values( + [ + [ + 'subscriber_id' => $this->subscriber_ids[0], + 'subscriber_custom_field_id' => (int) $_ENV['CONVERTKIT_API_CUSTOM_FIELD_ID'], + 'value' => '100', + ], + [ + 'subscriber_id' => $this->subscriber_ids[1], + 'subscriber_custom_field_id' => (int) $_ENV['CONVERTKIT_API_CUSTOM_FIELD_ID'], + 'value' => '200', + ], + ] + ); + + // Assert no failures. + $this->assertCount(0, $result->failures); + + // Confirm result is an array comprising of each custom field value that was updated. + $this->assertIsArray($result->custom_field_values); + $this->assertCount(2, $result->custom_field_values); + } + /** * Test that update_custom_field() works. * From 4ca4c06271293eb2cab210550d748dcba30d4d13 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Tue, 3 Mar 2026 16:43:42 +0800 Subject: [PATCH 3/6] Added `custom_field.*` event types to `create_webhook` method --- src/ConvertKit_API_Traits.php | 9 +++++++++ tests/ConvertKitAPITest.php | 10 ++++++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/ConvertKit_API_Traits.php b/src/ConvertKit_API_Traits.php index 56ff3c8..a7900be 100644 --- a/src/ConvertKit_API_Traits.php +++ b/src/ConvertKit_API_Traits.php @@ -1534,6 +1534,8 @@ public function create_webhook(string $url, string $event, string $parameter = ' case 'subscriber.subscriber_bounce': case 'subscriber.subscriber_complain': case 'purchase.purchase_create': + case 'custom_field.field_created': + case 'custom_field.field_deleted': $eventData = ['name' => $event]; break; @@ -1574,6 +1576,13 @@ public function create_webhook(string $url, string $event, string $parameter = ' ]; break; + case 'custom_field.field_value_updated': + $eventData = [ + 'name' => $event, + 'custom_field_id' => $parameter, + ]; + break; + default: throw new \InvalidArgumentException(sprintf('The event %s is not supported', $event)); }//end switch diff --git a/tests/ConvertKitAPITest.php b/tests/ConvertKitAPITest.php index 7642f78..6c48680 100644 --- a/tests/ConvertKitAPITest.php +++ b/tests/ConvertKitAPITest.php @@ -4853,17 +4853,19 @@ public function testCreateWebhookWithEventParameter() $url = 'https://webhook.site/' . str_shuffle('wfervdrtgsdewrafvwefds'); $result = $this->api->create_webhook( url: $url, - event: 'subscriber.form_subscribe', - parameter: $_ENV['CONVERTKIT_API_FORM_ID'] + event: 'custom_field.field_value_updated', + parameter: $_ENV['CONVERTKIT_API_CUSTOM_FIELD_ID'] ); // Confirm webhook created with correct data. $this->assertArrayHasKey('webhook', get_object_vars($result)); $this->assertArrayHasKey('id', get_object_vars($result->webhook)); + $this->assertArrayHasKey('account_id', get_object_vars($result->webhook)); + $this->assertArrayHasKey('event', get_object_vars($result->webhook)); $this->assertArrayHasKey('target_url', get_object_vars($result->webhook)); $this->assertEquals($result->webhook->target_url, $url); - $this->assertEquals($result->webhook->event->name, 'form_subscribe'); - $this->assertEquals($result->webhook->event->form_id, $_ENV['CONVERTKIT_API_FORM_ID']); + $this->assertEquals($result->webhook->event->name, 'field_value_updated'); + $this->assertEquals($result->webhook->event->custom_field_id, $_ENV['CONVERTKIT_API_CUSTOM_FIELD_ID']); // Delete the webhook. $result = $this->api->delete_webhook($result->webhook->id); From 07db56cc35a72801c55c7e6cd46101be9e01c2af Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Tue, 3 Mar 2026 17:17:48 +0800 Subject: [PATCH 4/6] PHPStan compat. --- src/ConvertKit_API.php | 8 ++-- src/ConvertKit_API_Traits.php | 70 ++++++++++++++++++----------------- 2 files changed, 40 insertions(+), 38 deletions(-) diff --git a/src/ConvertKit_API.php b/src/ConvertKit_API.php index 9a8e1b9..a44401b 100644 --- a/src/ConvertKit_API.php +++ b/src/ConvertKit_API.php @@ -348,13 +348,13 @@ public function get_resource(string $url) /** * Performs an API request using Guzzle. * - * @param string $endpoint API Endpoint. - * @param string $method Request method. - * @param array>> $args Request arguments. + * @param string $endpoint API Endpoint. + * @param string $method Request method. + * @param array>> $args Request arguments. * * @throws \Exception If JSON encoding arguments failed. * - * @return mixed|object + * @return false|mixed */ public function request(string $endpoint, string $method, array $args = []) { diff --git a/src/ConvertKit_API_Traits.php b/src/ConvertKit_API_Traits.php index 7ee9ec6..635a617 100644 --- a/src/ConvertKit_API_Traits.php +++ b/src/ConvertKit_API_Traits.php @@ -948,17 +948,17 @@ public function create_subscribers(array $subscribers, string $callback_url = '' /** * Filter subscribers based on engagement. * - * @param array> $all Array of filter conditions where ALL must be met (AND logic) - * - 'type' (string) Type of filter condition ('opens', 'clicks', 'sent', 'delivered', 'subscribed'). - * - 'count_greater_than' (int) Minimum count (exclusive). Not applicable for 'subscribed' type. - * - 'count_less_than' (int) Maximum count (exclusive). Not applicable for 'subscribed' type. - * - 'after' (\DateTime|null) Start date. For 'subscribed' type, filters by subscriber_created_at. For other types, filters by event date. - * - 'before' (\DateTime|null) End date. For 'subscribed' type, filters by subscriber_created_at. For other types, filters by event date. - * - 'any' (array) Array of OR conditions for filtering by specific broadcasts or URLs. - * @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. + * @param array> $all Array of filter conditions where ALL must be met (AND logic). Each condition can have. + * - 'type' (string). + * - 'count_greater_than' (int|null). + * - 'count_less_than' (int|null). + * - 'after' (\DateTime|null). + * - 'before' (\DateTime|null). + * - 'any' (array|null). + * @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.4.0 * @@ -973,32 +973,34 @@ public function filter_subscribers( string $before_cursor = '', int $per_page = 100 ) { - // Build parameters. $options = []; foreach ($all as $condition) { $option = []; - if (!is_null($condition['count_greater_than'])) { - $option['count_greater_than'] = (int) $condition['count_greater_than']; + + if (array_key_exists('count_greater_than', $condition) && $condition['count_greater_than'] !== null) { + $option['count_greater_than'] = $condition['count_greater_than']; } - if (!is_null($condition['count_less_than'])) { - $option['count_less_than'] = (int) $condition['count_less_than']; + + if (array_key_exists('count_less_than', $condition) && $condition['count_less_than'] !== null) { + $option['count_less_than'] = $condition['count_less_than']; } - if (!is_null($condition['after'])) { + + if (array_key_exists('after', $condition) && $condition['after'] instanceof \DateTime) { $option['after'] = $condition['after']->format('Y-m-d'); } - if (!is_null($condition['before'])) { + + if (array_key_exists('before', $condition) && $condition['before'] instanceof \DateTime) { $option['before'] = $condition['before']->format('Y-m-d'); } - if (!empty($condition['any'])) { + + if (array_key_exists('any', $condition) && !empty($condition['any'])) { $option['any'] = (array) $condition['any']; } - // Add to options array. $options[] = $option; }//end foreach - // Send request. return $this->post( 'subscribers/filter', $this->build_total_count_and_pagination_params( @@ -1920,15 +1922,15 @@ public function strip_html_head_body_tags(string $markup) /** * Adds total count and pagination parameters to the given array of existing API parameters. * - * @param array $params API parameters. - * @param boolean $include_total_count Return total count of records. - * @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. + * @param array>> $params API parameters. + * @param boolean $include_total_count Return total count of records. + * @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.0.0 * - * @return array + * @return array>> */ private function build_total_count_and_pagination_params( array $params = [], @@ -1954,8 +1956,8 @@ private function build_total_count_and_pagination_params( /** * Performs a GET request to the API. * - * @param string $endpoint API Endpoint. - * @param array|string> $args Request arguments. + * @param string $endpoint API Endpoint. + * @param array|list>> $args Request arguments. * * @return false|mixed */ @@ -1967,8 +1969,8 @@ public function get(string $endpoint, array $args = []) /** * Performs a POST request to the API. * - * @param string $endpoint API Endpoint. - * @param array>> $args Request arguments. + * @param string $endpoint API Endpoint. + * @param array|boolean|integer|float|string>> $args Request arguments. * * @return false|mixed */ @@ -2006,9 +2008,9 @@ public function delete(string $endpoint, array $args = []) /** * Performs an API request. * - * @param string $endpoint API Endpoint. - * @param string $method Request method. - * @param array>> $args Request arguments. + * @param string $endpoint API Endpoint. + * @param string $method Request method. + * @param array>> $args Request arguments. * * @throws \Exception If JSON encoding arguments failed. * From 7ac2f4f084afdd18214e4cace31a2ce3e54a1f6a Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Tue, 3 Mar 2026 17:18:14 +0800 Subject: [PATCH 5/6] Coding Standards compat. --- phpcs.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/phpcs.xml b/phpcs.xml index 9de2bbd..3b059b2 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -72,7 +72,7 @@ - + From 4cca3a0a8d51a4c73f55ffb753869c30f1932930 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Tue, 3 Mar 2026 19:33:37 +0800 Subject: [PATCH 6/6] Add missing CONVERTKIT_API_CUSTOM_FIELD_ID --- .env.dist.testing | 1 + .env.example | 1 + 2 files changed, 2 insertions(+) diff --git a/.env.dist.testing b/.env.dist.testing index ca3522d..f61980a 100644 --- a/.env.dist.testing +++ b/.env.dist.testing @@ -1,4 +1,5 @@ CONVERTKIT_API_BROADCAST_ID="8697158" +CONVERTKIT_API_CUSTOM_FIELD_ID="264073" CONVERTKIT_API_FORM_ID="2765139" CONVERTKIT_API_FORM_ID_2="2780977" CONVERTKIT_API_LEGACY_FORM_URL="https://app.convertkit.com/landing_pages/470099" diff --git a/.env.example b/.env.example index 2628529..a995c61 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,7 @@ CONVERTKIT_OAUTH_CLIENT_ID= CONVERTKIT_OAUTH_CLIENT_SECRET= CONVERTKIT_OAUTH_REDIRECT_URI="https://convertkit-github.local/wp-admin/options-general.php?page=_wp_convertkit_settings" CONVERTKIT_API_BROADCAST_ID="8697158" +CONVERTKIT_API_CUSTOM_FIELD_ID="264073" CONVERTKIT_API_FORM_ID="2765139" CONVERTKIT_API_FORM_ID_2="2780977" CONVERTKIT_API_LEGACY_FORM_URL="https://app.convertkit.com/landing_pages/470099"