From 92bd0b58fdd94f50c7f130dc58b962f468ae5909 Mon Sep 17 00:00:00 2001 From: Aaron Greenberg Date: Wed, 18 Mar 2026 14:53:32 +0100 Subject: [PATCH 01/13] Use apiEndpoint instead of siteverifyEndpoint --- src/client.php | 17 +++++++-------- src/config.php | 31 ++++++++++++++++++++++++---- tests/configTest.php | 49 ++++++++++++++++++++++++++++++++++++++++++++ tests/verifyTest.php | 18 ++-------------- 4 files changed, 87 insertions(+), 28 deletions(-) create mode 100644 tests/configTest.php diff --git a/src/client.php b/src/client.php index efc8202..0a49593 100644 --- a/src/client.php +++ b/src/client.php @@ -6,9 +6,10 @@ use FriendlyCaptcha\SDK\{ClientConfig, VerifyResult, ErrorCodes}; -const VERSION = "0.1.2"; -const EU_API_ENDPOINT = "https://eu.frcapi.com/api/v2/captcha/siteverify"; -const GLOBAL_API_ENDPOINT = "https://global.frcapi.com/api/v2/captcha/siteverify"; +const VERSION = "0.2.0"; +const EU_API_ENDPOINT = "https://eu.frcapi.com"; +const GLOBAL_API_ENDPOINT = "https://global.frcapi.com"; +const SITEVERIFY_PATH = "/api/v2/captcha/siteverify"; class Client { @@ -16,9 +17,9 @@ class Client private $config; /** - * @var string the resolved siteverify endpoint, with any shorthands resolved to their full URL. + * @var string the resolved API endpoint, with any shorthands resolved to their full URL. */ - private $resolvedSiteverifyEndpoint; + private $resolvedApiEndpoint; public function __construct(ClientConfig $config) { @@ -28,7 +29,7 @@ public function __construct(ClientConfig $config) throw new \Exception("API key is required"); } - $endpoint = $this->config->siteverifyEndpoint; + $endpoint = $this->config->apiEndpoint ?: $this->config->siteverifyEndpoint ?: "global"; if ($endpoint === "eu") { $endpoint = EU_API_ENDPOINT; @@ -36,7 +37,7 @@ public function __construct(ClientConfig $config) $endpoint = GLOBAL_API_ENDPOINT; } - $this->resolvedSiteverifyEndpoint = $endpoint; + $this->resolvedApiEndpoint = $endpoint; } public function verifyCaptchaResponse(?string $response): VerifyResult @@ -67,7 +68,7 @@ public function verifyCaptchaResponse(?string $response): VerifyResult } $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, $this->resolvedSiteverifyEndpoint); + curl_setopt($ch, CURLOPT_URL, $this->resolvedApiEndpoint . SITEVERIFY_PATH); curl_setopt($ch, CURLOPT_POST, true); // Return response instead of outputting diff --git a/src/config.php b/src/config.php index 748c4a4..2db6cd3 100644 --- a/src/config.php +++ b/src/config.php @@ -11,7 +11,8 @@ class ClientConfig public $apiKey = ""; public $sitekey = ""; public $sdkTrailer = ""; - public $siteverifyEndpoint = "global"; + public $apiEndpoint = ""; + public $siteverifyEndpoint = ""; public $strict = false; public $timeout = 30; public $connectTimeout = 20; @@ -54,12 +55,34 @@ public function setSDKTrailer(string $sdkTrailer): self } /** - * @param string $siteverifyEndpoint a full URL, or the shorthands `"global"` or `"eu"`. + * @param string $apiEndpoint a base URL (no path), or the shorthands `"global"` or `"eu"`. + */ + public function setApiEndpoint(string $apiEndpoint): self + { + if ($apiEndpoint != "global" && $apiEndpoint != "eu") { + $parts = parse_url($apiEndpoint); + if ($parts === false || empty($parts["scheme"]) || empty($parts["host"])) { + throw new Exception("Invalid argument '" . $apiEndpoint . "' to setApiEndpoint, it must be a base URL or one of the shorthands 'global' or 'eu'."); + } + $apiEndpoint = $parts["scheme"] . "://" . $parts["host"] . (isset($parts["port"]) ? ":" . $parts["port"] : ""); + } + $this->apiEndpoint = $apiEndpoint; + return $this; + } + + /** + * @param string $siteverifyEndpoint a base URL (no path), or the shorthands `"global"` or `"eu"`. + * + * @deprecated Use `setApiEndpoint` instead. $siteverifyEndpoint will be removed in a future version. */ public function setSiteverifyEndpoint(string $siteverifyEndpoint): self { - if ($siteverifyEndpoint != "global" && $siteverifyEndpoint != "eu" && substr($siteverifyEndpoint, 0, 4) != "http") { - throw new Exception("Invalid argument '" . $siteverifyEndpoint . "' to setSiteverifyEndpoint, it must be a full URL or one of the shorthands 'global' or 'eu'."); + if ($siteverifyEndpoint != "global" && $siteverifyEndpoint != "eu") { + $parts = parse_url($siteverifyEndpoint); + if ($parts === false || empty($parts["scheme"]) || empty($parts["host"])) { + throw new Exception("Invalid argument '" . $siteverifyEndpoint . "' to setSiteverifyEndpoint, it must be a base URL or one of the shorthands 'global' or 'eu'."); + } + $siteverifyEndpoint = $parts["scheme"] . "://" . $parts["host"] . (isset($parts["port"]) ? ":" . $parts["port"] : ""); } $this->siteverifyEndpoint = $siteverifyEndpoint; return $this; diff --git a/tests/configTest.php b/tests/configTest.php new file mode 100644 index 0000000..359c8e2 --- /dev/null +++ b/tests/configTest.php @@ -0,0 +1,49 @@ +expectException(Exception::class); + $this->expectExceptionMessage("API key is required"); + $opts = new ClientConfig(); + $client = new Client($opts); + } + + public function testConfigInvalidSiteverifyEndpointThrows(): void + { + $this->expectException(Exception::class); + $opts = new ClientConfig(); + $opts->setSiteverifyEndpoint("something-invalid-that-is-not-a-url"); + } + + public function testConfigInvalidApiEndpointThrows(): void + { + $this->expectException(Exception::class); + $opts = new ClientConfig(); + $opts->setApiEndpoint("something-invalid-that-is-not-a-url"); + } + + public function testConfigApiEndpointStripsPaths(): void + { + $opts = new ClientConfig(); + $opts->setApiEndpoint("https://exmple.com/a/b/c"); + $this->assertEquals("https://exmple.com", $opts->apiEndpoint); + } + + public function testConfigSiteverifyEndpointStripsPaths(): void + { + $opts = new ClientConfig(); + $opts->setSiteverifyEndpoint("https://exmple.com/a/b/c"); + $this->assertEquals("https://exmple.com", $opts->siteverifyEndpoint); + } +} diff --git a/tests/verifyTest.php b/tests/verifyTest.php index b55af37..b0c2859 100644 --- a/tests/verifyTest.php +++ b/tests/verifyTest.php @@ -29,20 +29,6 @@ function loadSDKTestsFromServer(string $serverURL) final class VerifyTest extends TestCase { - public function testConfigWithoutAPIKeyThrows(): void - { - $this->expectException(Exception::class); - $this->expectExceptionMessage("API key is required"); - $opts = new ClientConfig(); - $client = new Client($opts); - } - public function testConfigInvalidEndpointThrows(): void - { - $this->expectException(Exception::class); - $opts = new ClientConfig(); - $opts->setSiteverifyEndpoint("something-invalid-that-is-not-a-url"); - } - public function testNonEncodeableResponse(): void { $opts = new ClientConfig(); @@ -61,7 +47,7 @@ public function testNonEncodeableResponse(): void public function testNonReachableEndpoint(): void { $opts = new ClientConfig(); - $opts->setAPIKey("some-key")->setSiteverifyEndpoint("https://localhost:9999"); // Assuming there's nothing running on that port.. + $opts->setAPIKey("some-key")->setApiEndpoint("https://localhost:9999"); // Assuming there's nothing running on that port.. $client = new Client($opts); $result = $client->verifyCaptchaResponse("my-response"); @@ -91,7 +77,7 @@ public static function sdkMockTestsProvider(): array public function testSDKTestServerCase($test): void { $opts = new ClientConfig(); - $opts->setAPIKey("some-key")->setSiteverifyEndpoint(MOCK_SERVER_URL . "/api/v2/captcha/siteverify")->setStrict($test["strict"]); // Assuming there's nothing running on that port.. + $opts->setAPIKey("some-key")->setApiEndpoint(MOCK_SERVER_URL)->setStrict($test["strict"]); $client = new Client($opts); $result = $client->verifyCaptchaResponse($test["response"]); From 51e1bd1636e15c6bfeb03050d8f7b12b4c6e3b21 Mon Sep 17 00:00:00 2001 From: Aaron Greenberg Date: Wed, 18 Mar 2026 17:11:32 +0100 Subject: [PATCH 02/13] Update response types for Risk Intelligence --- src/errors.php | 9 + src/response.php | 131 +++++-- src/result.php | 2 +- src/risk_intelligence.php | 736 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 841 insertions(+), 37 deletions(-) create mode 100644 src/risk_intelligence.php diff --git a/src/errors.php b/src/errors.php index c61a0e9..02e7742 100644 --- a/src/errors.php +++ b/src/errors.php @@ -39,6 +39,15 @@ class ErrorCodes /** (200) The response has already been used. */ public static $ResponseDuplicate = "response_duplicate"; + /** (200) The Risk Intelligence token is invalid. */ + public static $TokenInvalid = "token_invalid"; + + /** (200) The Risk Intelligence token has expired. */ + public static $TokenExpired = "token_expired"; + + /** (400) The Risk Intelligence token is missing from the request. */ + public static $TokenMissing = "token_missing"; + /** (400) Something else is wrong with your request, e.g. the request body was empty. */ public static $BadRequest = "bad_request"; } diff --git a/src/response.php b/src/response.php index 9a691cd..9e8a82f 100644 --- a/src/response.php +++ b/src/response.php @@ -6,82 +6,77 @@ use DateTimeImmutable; -class VerifyResponseChallengeData +class APIResponseError { - /** @var DateTimeImmutable */ - public $timestamp; /** @var string */ - public $origin; + public $error_code; + /** @var string */ + public $detail; - public static function fromJson($json): ?VerifyResponseChallengeData + public static function fromJson($json): ?APIResponseError { $data = json_decode($json); if ($data == null || !is_object($data)) { return null; } - $instance = new self(); - $instance->timestamp = DateTimeImmutable::createFromFormat("c", $data->timestamp); - $instance->origin = $data->origin; - return $instance; + return self::fromStdClass($data); } - public static function fromStdClass($obj): VerifyResponseChallengeData + public static function fromStdClass($obj): APIResponseError { $instance = new self(); - $instance->timestamp = DateTimeImmutable::createFromFormat("c", $obj->timestamp); - $instance->origin = $obj->origin; + $instance->error_code = $obj->error_code; + $instance->detail = $obj->detail; return $instance; } } -class VerifyResponseData +class VerifyResponseChallengeData { - /** @var VerifyResponseChallengeData */ - public $challenge; + /** @var DateTimeImmutable */ + public $timestamp; + /** @var string */ + public $origin; - public static function fromJson($json): ?VerifyResponseData + public static function fromJson($json): ?VerifyResponseChallengeData { $data = json_decode($json); if ($data == null || !is_object($data)) { return null; } - $instance = new self(); - $instance->challenge = VerifyResponseChallengeData::fromStdClass($data->challenge); - return $instance; + return self::fromStdClass($data); } - public static function fromStdClass($obj): VerifyResponseData + public static function fromStdClass($obj): VerifyResponseChallengeData { $instance = new self(); - $instance->challenge = VerifyResponseChallengeData::fromStdClass($obj->challenge); + $instance->timestamp = DateTimeImmutable::createFromFormat("c", $obj->timestamp); + $instance->origin = $obj->origin; return $instance; } } -class VerifyResponseError +class VerifyResponseData { - /** @var string */ - public $error_code; - /** @var string */ - public $detail; + /** @var string Unique identifier for this siteverify call. */ + public $event_id; + /** @var VerifyResponseChallengeData Information about the challenge that was solved. */ + public $challenge; - public static function fromJson($json): ?VerifyResponseError + public static function fromJson($json): ?VerifyResponseData { $data = json_decode($json); if ($data == null || !is_object($data)) { return null; } - $instance = new self(); - $instance->error_code = $data->error_code; - $instance->detail = $data->detail; - return $instance; + return self::fromStdClass($data); } - public static function fromStdClass($obj): VerifyResponseError + public static function fromStdClass($obj): VerifyResponseData { $instance = new self(); - $instance->error_code = $obj->error_code; - $instance->detail = $obj->detail; + $instance->event_id = $obj->event_id; + $instance->challenge = VerifyResponseChallengeData::fromStdClass($obj->challenge); return $instance; } } @@ -92,7 +87,7 @@ class VerifyResponse public $success; /** @var VerifyResponseData|null */ public $data; - /** @var VerifyResponseError|null */ + /** @var APIResponseError|null */ public $error; public static function fromJson($json): ?VerifyResponse @@ -114,9 +109,73 @@ public static function fromJson($json): ?VerifyResponse } if (isset($d->error)) { - $instance->error = VerifyResponseError::fromStdClass($d->error); + $instance->error = APIResponseError::fromStdClass($d->error); } return $instance; } } + +class RiskIntelligenceRetrieveTokenData +{ + /** @var DateTimeImmutable Timestamp when the token was generated. */ + public $timestamp; + /** @var DateTimeImmutable Timestamp when the token expires. */ + public $expires_at; + /** @var int Number of times the token has been used. */ + public $num_users; + /** @var string The origin of the site where the token was generated. */ + public $origin; + + public static function fromJson($json): ?RiskIntelligenceRetrieveTokenData + { + $data = json_decode($json); + if ($data == null || !is_object($data)) { + return null; + } + $instance = new self(); + $instance->timestamp = DateTimeImmutable::createFromFormat("c", $data->timestamp); + $instance->expires_at = DateTimeImmutable::createFromFormat("c", $data->expires_at); + $instance->num_uses = $data->num_uses; + $instance->origin = $data->origin; + return $instance; + } + + public static function fromStdClass($obj): RiskIntelligenceRetrieveTokenData + { + $instance = new self(); + $instance->timestamp = DateTimeImmutable::createFromFormat("c", $obj->timestamp); + $instance->expires_at = DateTimeImmutable::createFromFormat("c", $obj->expires_at); + $instance->num_uses = $obj->num_uses; + $instance->origin = $obj->origin; + return $instance; + } +} + +class RiskIntelligenceRetrieveResponseData +{ + /** @var string Unique identifier for this Risk Intelligence retrieve call. */ + public $event_id; + /** @var RiskIntelligenceRetrieveTokenData Metadata about the token used for retrieval. */ + public $token; + /** @var RiskIntelligenceData Risk information retrieved with the provided token. */ + public $risk_intelligence; + + public static function fromJson($json): ?RiskIntelligenceRetrieveResponseData + { + $data = json_decode($json); + if ($data == null || !is_object($data)) { + return null; + } + return self::fromStdClass($data); + } + + public static function fromStdClass($obj): RiskIntelligenceRetrieveResponseData + { + $instance = new self(); + $instance->event_id = $obj->event_id; + $instance->token = RiskIntelligenceRetrieveTokenData::fromStdClass($obj->token); + $instance->risk_intelligence = RiskIntelligenceData::fromStdClass($obj->risk_intelligence); + return $instance; + } +} diff --git a/src/result.php b/src/result.php index 7360c56..a221670 100644 --- a/src/result.php +++ b/src/result.php @@ -116,7 +116,7 @@ public function getResponse(): ?VerifyResponse /** * Get the error field from the response as was returned by the API, or null if the field is not present. */ - public function getResponseError(): ?VerifyResponseError + public function getResponseError(): ?APIResponseError { if ($this->response === null) { return null; diff --git a/src/risk_intelligence.php b/src/risk_intelligence.php new file mode 100644 index 0000000..68e9ddf --- /dev/null +++ b/src/risk_intelligence.php @@ -0,0 +1,736 @@ +overall = $obj->overall; + $instance->network = $obj->network; + $instance->browser = $obj->browser; + return $instance; + } +} + +class NetworkAutonomousSystemData +{ + /** + * NetworkAutonomousSystemData contains information about the AS that owns the IP. + * + * Available when the IP Intelligence module is enabled for your account. + * Null when the IP Intelligence module is not enabled for your account. + */ + + /** + * @var int Autonomous System Number (ASN) identifier. + * Example: 3209 for Vodafone GmbH + */ + public $number; + /** + * @var string Name of the autonomous system. This is usually a short name or handle. + * Example: "VODANET" + */ + public $name; + /** + * @var string Company is the organization name that owns the ASN. + * Example: "Vodafone GmbH" + */ + public $company; + /** + * @var string Description of the company that owns the ASN. + * Example: "Provides mobile and fixed broadband and telecommunication services to consumers and businesses." + */ + public $description; + /** + * @var string Domain name associated with the ASN. + * Example: "vodafone.de" + */ + public $domain; + /** + * @var string Two-letter ISO 3166-1 alpha-2 country code where the ASN is registered. + * Example: "DE" + */ + public $country; + /** + * @var string Regional Internet Registry that allocated the ASN. + * Example: "RIPE" + */ + public $rir; + /** + * @var string IP route associated with the ASN in CIDR notation. + * Example: "88.64.0.0/12" + */ + public $route; + /** + * @var string Autonomous system type. + * Example: "isp" + */ + public $type; + + public static function fromStdClass($obj): NetworkAutonomousSystemData + { + $instance = new self(); + $instance->number = $obj->number; + $instance->name = $obj->name; + $instance->company = $obj->company; + $instance->description = $obj->description; + $instance->domain = $obj->domain; + $instance->country = $obj->country; + $instance->rir = $obj->rir; + $instance->route = $obj->route; + $instance->type = $obj->type; + return $instance; + } +} + +class NetworkGeolocationCountryData +{ + /** NetworkGeolocationCountryData contains detailed country data. */ + + /** + * @var string Two-letter ISO 3166-1 alpha-2 country code. + * Example: "DE" + */ + public $iso2; + /** + * @var string Three-letter ISO 3166-1 alpha-3 country code. + * Example: "DEU" + */ + public $iso3; + /** + * @var string English name of the country. + * Example: "Germany" + */ + public $name; + /** + * @var string Native name of the country. + * Example: "Deutschland" + */ + public $name_native; + /** + * @var string Major world region. + * Example: "Europe" + */ + public $region; + /** + * @var string More specific world region. + * Example: "Western Europe" + */ + public $subregion; + /** + * @var string ISO 4217 currency code. + * Example: "EUR" + */ + public $currency; + /** + * @var string Full name of the currency. + * Example: "Euro" + */ + public $currency_name; + /** + * @var string International dialing code. + * Example: "49" + */ + public $phone_code; + /** + * @var string Name of the capital city. + * Example: "Berlin" + */ + public $capital; + + public static function fromStdClass($obj): NetworkGeolocationCountryData + { + $instance = new self(); + $instance->iso2 = $obj->iso2; + $instance->iso3 = $obj->iso3; + $instance->name = $obj->name; + $instance->name_native = $obj->name_native; + $instance->region = $obj->region; + $instance->subregion = $obj->subregion; + $instance->currency = $obj->currency; + $instance->currency_name = $obj->currency_name; + $instance->phone_code = $obj->phone_code; + $instance->capital = $obj->capital; + return $instance; + } +} + +class NetworkGeolocationData +{ + /** + * NetworkGeolocationData contains geographic location of the IP address. + * + * Available when the IP Intelligence module is enabled. + * Null when the IP Intelligence module is not enabled. + */ + + /** @var NetworkGeolocationCountryData Country information. */ + public $country; + /** + * @var string City name. Empty string if unknown. + * Example: "Eschborn" + */ + public $city; + /** + * @var string State, region, or province. Empty string if unknown. + * Example: "Hessen" + */ + public $state; + + public static function fromStdClass($obj): NetworkGeolocationData + { + $instance = new self(); + $instance->country = NetworkGeolocationCountryData::fromStdClass($obj->country); + $instance->city = $obj->city; + $instance->state = $obj->state; + return $instance; + } +} + +class NetworkAbuseContactData +{ + /** + * NetworkAbuseContactData contains contact details for reporting abuse. + * + * Available when the IP Intelligence module is enabled. + * Null when the IP Intelligence module is not enabled. + */ + + /** + * @var string Postal address of the abuse contact. + * Example: "Vodafone GmbH, Campus Eschborn, Duesseldorfer Strasse 15, D-65760 Eschborn, Germany" + */ + public $address; + /** + * @var string Name of the abuse contact person or team. + * Example: "Vodafone Germany IP Core Backbone" + */ + public $name; + /** + * @var string Abuse contact email address. + * Example: "abuse.de@vodafone.com" + */ + public $email; + /** + * @var string Abuse contact phone number. + * Example: "+49 6196 52352105" + */ + public $phone; + + public static function fromStdClass($obj): NetworkAbuseContactData + { + $instance = new self(); + $instance->address = $obj->address; + $instance->name = $obj->name; + $instance->email = $obj->email; + $instance->phone = $obj->phone; + return $instance; + } +} + +class NetworkAnonymizationData +{ + /** + * NetworkAnonymizationData contains detection of VPNs, proxies, and anonymization services. + * + * Available when the Anonymization Detection module is enabled. + * Null when the Anonymization Detection module is not enabled. + */ + + /** @var int Likelihood that the IP is from a VPN service. */ + public $vpn_score; + /** @var int Likelihood that the IP is from a proxy service. */ + public $proxy_score; + /** @var bool Whether the IP is a Tor exit node. */ + public $tor; + /** @var bool Whether the IP is from iCloud Private Relay. */ + public $icloud_private_relay; + + public static function fromStdClass($obj): NetworkAnonymizationData + { + $instance = new self(); + $instance->vpn_score = $obj->vpn_score; + $instance->proxy_score = $obj->proxy_score; + $instance->tor = $obj->tor; + $instance->icloud_private_relay = $obj->icloud_private_relay; + return $instance; + } +} + +class NetworkData +{ + /** NetworkData contains information about the network. */ + + /** + * @var string IP address used when requesting the challenge. + * Example: "88.64.4.22" + */ + public $ip; + /** + * @var NetworkAutonomousSystemData|null Autonomous System information. + * + * Available when the IP Intelligence module is enabled. + * Null when the IP Intelligence module is not enabled. + */ + public $as; + /** + * @var NetworkGeolocationData|null Geolocation information. + * + * Available when the IP Intelligence module is enabled. + * Null when the IP Intelligence module is not enabled. + */ + public $geolocation; + /** + * @var NetworkAbuseContactData|null Abuse contact information. + * + * Available when the IP Intelligence module is enabled. + * Null when the IP Intelligence module is not enabled. + */ + public $abuse_contact; + /** + * @var NetworkAnonymizationData|null IP masking/anonymization information. + * + * Available when the Anonymization Detection module is enabled. + * Null when the Anonymization Detection module is not enabled. + */ + public $anonymization; + + public static function fromStdClass($obj): NetworkData + { + $instance = new self(); + $instance->ip = $obj->ip; + $instance->as = isset($obj->as) ? NetworkAutonomousSystemData::fromStdClass($obj->as) : null; + $instance->geolocation = isset($obj->geolocation) ? NetworkGeolocationData::fromStdClass($obj->geolocation) : null; + $instance->abuse_contact = isset($obj->abuse_contact) ? NetworkAbuseContactData::fromStdClass($obj->abuse_contact) : null; + $instance->anonymization = isset($obj->anonymization) ? NetworkAnonymizationData::fromStdClass($obj->anonymization) : null; + return $instance; + } +} + +class ClientTimeZoneData +{ + /** + * ClientTimeZoneData contains IANA time zone data. + * + * Available when the Browser Identification module is enabled. + * Null when the Browser Identification module is not enabled. + */ + + /** + * @var string IANA time zone name reported by the browser. + * Example: "America/New_York" or "Europe/Berlin" + */ + public $name; + /** + * @var string Two-letter ISO 3166-1 alpha-2 country code derived from the time zone. + * "XU" if timezone is missing or cannot be mapped to a country (e.g., "Etc/UTC"). + * Example: "US" or "DE" + */ + public $country_iso2; + + public static function fromStdClass($obj): ClientTimeZoneData + { + $instance = new self(); + $instance->name = $obj->name; + $instance->country_iso2 = $obj->country_iso2; + return $instance; + } +} + +class ClientBrowserData +{ + /** + * ClientBrowserData contains detected browser details. + * + * Available when the Browser Identification module is enabled. + * Null when the Browser Identification module is not enabled. + */ + + /** + * @var string Unique browser identifier. Empty string if browser could not be identified. + * Example: "firefox", "chrome", "chrome_android", "edge", "safari", "safari_ios", "webview_ios" + */ + public $id; + /** + * @var string Human-readable browser name. Empty string if browser could not be identified. + * Example: "Firefox", "Chrome", "Edge", "Safari", "Safari on iOS", "WebView on iOS" + */ + public $name; + /** + * @var string Browser version name. Assumed to be the most recent release matching the signature if exact version unknown. Empty if unknown. + * Example: "146.0" or "16.5" + */ + public $version; + /** + * @var string Release date of the browser version in "YYYY-MM-DD" format. Empty string if unknown. + * Example: "2026-01-28" + */ + public $release_date; + + public static function fromStdClass($obj): ClientBrowserData + { + $instance = new self(); + $instance->id = $obj->id; + $instance->name = $obj->name; + $instance->version = $obj->version; + $instance->release_date = $obj->release_date; + return $instance; + } +} + +class ClientBrowserEngineData +{ + /** + * ClientBrowserEngineData contains detected rendering engine details. + * + * Available when the Browser Identification module is enabled. + * Null when the Browser Identification module is not enabled. + */ + + /** + * @var string Unique rendering engine identifier. Empty string if engine could not be identified. + * Example: "gecko", "blink", "webkit" + */ + public $id; + /** + * @var string Human-readable engine name. Empty string if engine could not be identified. + * Example: "Gecko", "Blink", "WebKit" + */ + public $name; + /** + * @var string Rendering engine version. Assumed to be the most recent release matching the signature if exact version unknown. Empty if unknown. + * Example: "146.0" or "16.5" + */ + public $version; + + public static function fromStdClass($obj): ClientBrowserEngineData + { + $instance = new self(); + $instance->id = $obj->id; + $instance->name = $obj->name; + $instance->version = $obj->version; + return $instance; + } +} + +class ClientDeviceData +{ + /** + * ClientDeviceData contains detected device details. + * + * Available when the Browser Identification module is enabled. + * Null when the Browser Identification module is not enabled. + */ + + /** + * @var string Device type. + * Example: "desktop", "mobile", "tablet" + */ + public $type; + /** + * @var string Device brand. + * Example: "Apple", "Samsung", "Google" + */ + public $brand; + /** + * @var string Device model name. + * Example: "iPhone 17", "Galaxy S21 (SM-G991B)", "Pixel 10" + */ + public $model; + + public static function fromStdClass($obj): ClientDeviceData + { + $instance = new self(); + $instance->type = $obj->type; + $instance->brand = $obj->brand; + $instance->model = $obj->model; + return $instance; + } +} + +class ClientOSData +{ + /** + * ClientOSData contains detected OS details. + * + * Available when the Browser Identification module is enabled. + * Null when the Browser Identification module is not enabled. + */ + + /** + * @var string Unique operating system identifier. Empty string if OS could not be identified. + * Example: "windows", "macos", "ios", "android", "linux" + */ + public $id; + /** + * @var string Human-readable operating system name. Empty string if OS could not be identified. + * Example: "Windows", "macOS", "iOS", "Android", "Linux" + */ + public $name; + /** + * @var string Operating system version. + * Example: "10", "11.2.3", "14.4" + */ + public $version; + + public static function fromStdClass($obj): ClientOSData + { + $instance = new self(); + $instance->id = $obj->id; + $instance->name = $obj->name; + $instance->version = $obj->version; + return $instance; + } +} + +class TLSSignatureData +{ + /** + * TLSSignatureData contains TLS client hello signatures. + * + * Available when the Bot Detection module is enabled. + * Null when the Bot Detection module is not enabled. + */ + + /** + * @var string JA3 hash. + * Example: "d87a30a5782a73a83c1544bb06332780" + */ + public $ja3; + /** + * @var string JA3N hash. + * Example: "28ecc2d2875b345cecbb632b12d8c1e0" + */ + public $ja3n; + /** + * @var string JA4 signature. + * Example: "t13d1516h2_8daaf6152771_02713d6af862" + */ + public $ja4; + + public static function fromStdClass($obj): TLSSignatureData + { + $instance = new self(); + $instance->ja3 = $obj->ja3; + $instance->ja3n = $obj->ja3n; + $instance->ja4 = $obj->ja4; + return $instance; + } +} + +class ClientAutomationKnownBotData +{ + /** ClientAutomationKnownBotData contains detected known bot details. */ + + /** @var bool Whether a known bot was detected. */ + public $detected; + /** + * @var string Bot identifier. Empty if no bot detected. + * Example: "googlebot", "bingbot", "chatgpt" + */ + public $id; + /** + * @var string Human-readable bot name. Empty if no bot detected. + * Example: "Googlebot", "Bingbot", "ChatGPT" + */ + public $name; + /** @var string Bot type classification. Empty if no bot detected. */ + public $type; + /** + * @var string Link to bot documentation. Empty if no bot detected. + * Example: "https://developers.google.com/search/docs/crawling-indexing/googlebot" + */ + public $url; + + public static function fromStdClass($obj): ClientAutomationKnownBotData + { + $instance = new self(); + $instance->detected = $obj->detected; + $instance->id = $obj->id; + $instance->name = $obj->name; + $instance->type = $obj->type; + $instance->url = $obj->url; + return $instance; + } +} + +class ClientAutomationToolData +{ + /** ClientAutomationToolData contains detected automation tool details. */ + + /** @var bool Whether an automation tool was detected. */ + public $detected; + /** + * @var string Automation tool identifier. Empty if no tool detected. + * Example: "puppeteer", "selenium", "playwright" + */ + public $id; + /** + * @var string Human-readable tool name. Empty if no tool detected. + * Example: "Puppeteer", "Selenium WebDriver", "Playwright" + */ + public $name; + /** @var string Automation tool type. Empty if no tool detected. */ + public $type; + + public static function fromStdClass($obj): ClientAutomationToolData + { + $instance = new self(); + $instance->detected = $obj->detected; + $instance->id = $obj->id; + $instance->name = $obj->name; + $instance->type = $obj->type; + return $instance; + } +} + +class ClientAutomationData +{ + /** + * ClientAutomationData contains information about detected automation. + * + * Available when the Bot Detection module is enabled. + * Null when the Bot Detection module is not enabled. + */ + + /** @var ClientAutomationToolData Detected automation tool information. */ + public $automation_tool; + /** @var ClientAutomationKnownBotData Detected known bot information. */ + public $known_bot; + + public static function fromStdClass($obj): ClientAutomationData + { + $instance = new self(); + $instance->automation_tool = ClientAutomationToolData::fromStdClass($obj->automation_tool); + $instance->known_bot = ClientAutomationKnownBotData::fromStdClass($obj->known_bot); + return $instance; + } +} + +class ClientData +{ + /** ClientData contains information about the user agent and device. */ + + /** + * @var string User-Agent HTTP header value. + * Example: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:146.0) Gecko/20100101 Firefox/146.0" + */ + public $header_user_agent; + /** + * @var ClientTimeZoneData|null Time zone information. + * + * Available when the Browser Identification module is enabled. + * Null when the Browser Identification module is not enabled. + */ + public $time_zone; + /** + * @var ClientBrowserData|null Browser information. + * + * Available when the Browser Identification module is enabled. + * Null when the Browser Identification module is not enabled. + */ + public $browser; + /** + * @var ClientBrowserEngineData|null Browser engine information. + * + * Available when the Browser Identification module is enabled. + * Null when the Browser Identification module is not enabled. + */ + public $browser_engine; + /** + * @var ClientDeviceData|null Device information. + * + * Available when the Browser Identification module is enabled. + * Null when the Browser Identification module is not enabled. + */ + public $device; + /** + * @var ClientOSData|null OS information. + * + * Available when the Browser Identification module is enabled. + * Null when the Browser Identification module is not enabled. + */ + public $os; + /** + * @var TLSSignatureData|null TLS signatures. + * + * Available when the Bot Detection module is enabled. + * Null when the Bot Detection module is not enabled. + */ + public $tls_signature; + /** + * @var ClientAutomationData|null Automation detection data. + * + * Available when the Bot Detection module is enabled. + * Null when the Bot Detection module is not enabled. + */ + public $automation; + + public static function fromStdClass($obj): ClientData + { + $instance = new self(); + $instance->header_user_agent = $obj->header_user_agent; + $instance->time_zone = isset($obj->time_zone) ? ClientTimeZoneData::fromStdClass($obj->time_zone) : null; + $instance->browser = isset($obj->browser) ? ClientBrowserData::fromStdClass($obj->browser) : null; + $instance->browser_engine = isset($obj->browser_engine) ? ClientBrowserEngineData::fromStdClass($obj->browser_engine) : null; + $instance->device = isset($obj->device) ? ClientDeviceData::fromStdClass($obj->device) : null; + $instance->os = isset($obj->os) ? ClientOSData::fromStdClass($obj->os) : null; + $instance->tls_signature = isset($obj->tls_signature) ? TLSSignatureData::fromStdClass($obj->tls_signature) : null; + $instance->automation = isset($obj->automation) ? ClientAutomationData::fromStdClass($obj->automation) : null; + return $instance; + } +} + +class RiskIntelligenceData +{ + /** + * RiskIntelligenceData contains all risk intelligence information. + * + * Field availability depends on enabled modules. + */ + + /** + * @var RiskScoresData|null Risk scores from various signals, these summarize the risk intelligence assessment. + * + * Available when the Risk Scores module is enabled. + * Null when the Risk Scores module is not enabled. + */ + public $risk_scores; + /** @var NetworkData Network-related risk intelligence. */ + public $network; + /** @var ClientData Client/device risk intelligence. */ + public $client; + + public static function fromStdClass($obj): RiskIntelligenceData + { + $instance = new self(); + $instance->risk_scores = isset($obj->risk_scores) ? RiskScoresData::fromStdClass($obj->risk_scores) : null; + $instance->network = NetworkData::fromStdClass($obj->network); + $instance->client = ClientData::fromStdClass($obj->client); + return $instance; + } +} From 1b43c79bd2c9d27d8622b1ea408fe5a668a021db Mon Sep 17 00:00:00 2001 From: Aaron Greenberg Date: Thu, 19 Mar 2026 10:08:16 +0100 Subject: [PATCH 03/13] Add risk_intelligence to siteverify response --- src/response.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/response.php b/src/response.php index 9e8a82f..4fced31 100644 --- a/src/response.php +++ b/src/response.php @@ -62,6 +62,11 @@ class VerifyResponseData public $event_id; /** @var VerifyResponseChallengeData Information about the challenge that was solved. */ public $challenge; + /** + * @var RiskIntelligenceData|null Risk Intelligence data about the solver of the challenge. + * If Risk Intelligence is not enabled for your Friendly Captcha account, this field will be null. + */ + public $risk_intelligence; public static function fromJson($json): ?VerifyResponseData { @@ -77,6 +82,7 @@ public static function fromStdClass($obj): VerifyResponseData $instance = new self(); $instance->event_id = $obj->event_id; $instance->challenge = VerifyResponseChallengeData::fromStdClass($obj->challenge); + $instance->risk_intelligence = isset($obj->risk_intelligence) ? RiskIntelligenceData::fromStdClass($obj->risk_intelligence) : null; return $instance; } } From 8c478c6c555f98d15af6ad158f2fc81b75f07f7a Mon Sep 17 00:00:00 2001 From: Aaron Greenberg Date: Thu, 19 Mar 2026 10:30:45 +0100 Subject: [PATCH 04/13] Update mock test endpoint for siteverify --- tests/verifyTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/verifyTest.php b/tests/verifyTest.php index b0c2859..6efd7ae 100644 --- a/tests/verifyTest.php +++ b/tests/verifyTest.php @@ -15,7 +15,7 @@ function loadSDKTestsFromServer(string $serverURL) { $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, $serverURL . "/api/v1/tests"); + curl_setopt($ch, CURLOPT_URL, $serverURL . "/api/v1/captcha/siteverifyTests"); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); $response = curl_exec($ch); if ($response === false) { From 70f7b3e4b8dd5e37c760b88cf715c0fac3602680 Mon Sep 17 00:00:00 2001 From: Aaron Greenberg Date: Thu, 19 Mar 2026 11:05:43 +0100 Subject: [PATCH 05/13] Assert response equality for siteverify tests --- tests/verifyTest.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/verifyTest.php b/tests/verifyTest.php index 6efd7ae..3bee375 100644 --- a/tests/verifyTest.php +++ b/tests/verifyTest.php @@ -4,7 +4,7 @@ namespace FriendlyCaptcha\SDK\Test; -use FriendlyCaptcha\SDK\{Client, ClientConfig}; +use FriendlyCaptcha\SDK\{Client, ClientConfig, VerifyResponse}; use Exception; use PHPUnit\Framework\Attributes\DataProvider; @@ -65,7 +65,7 @@ public static function sdkMockTestsProvider(): array $cases = loadSDKTestsFromServer(MOCK_SERVER_URL)["tests"]; $testCases = array(); foreach ($cases as $case) { - $testCases[] = array($case); + $testCases[$case["name"]] = array($case); } return $testCases; } @@ -107,5 +107,9 @@ public function testSDKTestServerCase($test): void $this->assertTrue($result->shouldAccept(), "non-strict mode should accept when not able to verify"); } } + + $expectedResponse = VerifyResponse::fromJson(json_encode($test["siteverify_response"])); + $actualResponse = $result->getResponse(); + $this->assertEquals($expectedResponse, $actualResponse); } } From f1c85f2b5c9093611ff7f4ee9be8f432c928945a Mon Sep 17 00:00:00 2001 From: Aaron Greenberg Date: Thu, 19 Mar 2026 11:52:17 +0100 Subject: [PATCH 06/13] Allow passing sitekey to verifyCaptchaResponse --- src/client.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/client.php b/src/client.php index 0a49593..3ab1bb7 100644 --- a/src/client.php +++ b/src/client.php @@ -40,7 +40,7 @@ public function __construct(ClientConfig $config) $this->resolvedApiEndpoint = $endpoint; } - public function verifyCaptchaResponse(?string $response): VerifyResult + public function verifyCaptchaResponse(?string $response, string $sitekey = ""): VerifyResult { $verifyResult = new VerifyResult($this->config->strict); $verifyResult->status = -1; // So that it is always set, this will only be -1 if the request fails. @@ -51,8 +51,8 @@ public function verifyCaptchaResponse(?string $response): VerifyResult } $requestFields = array("response" => $response); - if ($this->config->sitekey != "") { - $requestFields["sitekey"] = $this->config->sitekey; + if ($sitekey != "" || $this->config->sitekey != "") { + $requestFields["sitekey"] = $sitekey ?: $this->config->sitekey; } $frcSdk = 'friendly-captcha-php@' . VERSION; From eb9ed5e7280c89708204381e9cfb57598f448242 Mon Sep 17 00:00:00 2001 From: Aaron Greenberg Date: Thu, 19 Mar 2026 12:09:09 +0100 Subject: [PATCH 07/13] fixup! Update response types for Risk Intelligence --- src/response.php | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/response.php b/src/response.php index 4fced31..8cc9082 100644 --- a/src/response.php +++ b/src/response.php @@ -185,3 +185,38 @@ public static function fromStdClass($obj): RiskIntelligenceRetrieveResponseData return $instance; } } + +class RiskIntelligenceRetrieveResponse +{ + /** @var bool */ + public $success; + /** @var RiskIntelligenceRetrieveResponseData|null */ + public $data; + /** @var APIResponseError|null */ + public $error; + + public static function fromJson($json): ?RiskIntelligenceRetrieveResponse + { + $d = json_decode($json); + if ($d == null || !is_object($d)) { + return null; + } + + $instance = new self(); + $instance->success = false; + if (isset($d->success)) { + $instance->success = $d->success; + } + + + if (isset($d->data)) { + $instance->data = RiskIntelligenceRetrieveResponseData::fromStdClass($d->data); + } + + if (isset($d->error)) { + $instance->error = APIResponseError::fromStdClass($d->error); + } + + return $instance; + } +} From b2aae510268b5ba90c993504f635dfc535bcf8a9 Mon Sep 17 00:00:00 2001 From: Aaron Greenberg Date: Thu, 19 Mar 2026 13:04:28 +0100 Subject: [PATCH 08/13] Support retrieving Risk Intelligence data --- src/client.php | 104 +++++++++++++++++++++++++++++++-------------- src/result.php | 112 ++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 183 insertions(+), 33 deletions(-) diff --git a/src/client.php b/src/client.php index 3ab1bb7..988414d 100644 --- a/src/client.php +++ b/src/client.php @@ -4,12 +4,13 @@ namespace FriendlyCaptcha\SDK; -use FriendlyCaptcha\SDK\{ClientConfig, VerifyResult, ErrorCodes}; +use FriendlyCaptcha\SDK\{ClientConfig, VerifyResult, RiskIntelligenceRetrieveResult, ErrorCodes}; const VERSION = "0.2.0"; const EU_API_ENDPOINT = "https://eu.frcapi.com"; const GLOBAL_API_ENDPOINT = "https://global.frcapi.com"; const SITEVERIFY_PATH = "/api/v2/captcha/siteverify"; +const RETRIEVE_PATH = "/api/v2/riskIntelligence/retrieve"; class Client { @@ -40,35 +41,25 @@ public function __construct(ClientConfig $config) $this->resolvedApiEndpoint = $endpoint; } - public function verifyCaptchaResponse(?string $response, string $sitekey = ""): VerifyResult + /** + * Makes a POST request to the API and returns the HTTP status and response body. + * Returns ['status' => int, 'body' => string] on success, or ['errorCode' => string] on failure. + */ + private function makeRequest(string $path, array $fields): array { - $verifyResult = new VerifyResult($this->config->strict); - $verifyResult->status = -1; // So that it is always set, this will only be -1 if the request fails. - - - if ($response === null) { - $response = ""; - } - $requestFields = array("response" => $response); - - if ($sitekey != "" || $this->config->sitekey != "") { - $requestFields["sitekey"] = $sitekey ?: $this->config->sitekey; - } - $frcSdk = 'friendly-captcha-php@' . VERSION; if ($this->config->sdkTrailer != "") { $frcSdk = $frcSdk . "; " . $this->config->sdkTrailer; } - $payload = json_encode($requestFields); + $payload = json_encode($fields); if ($payload === false) { - // TODO: should we put `json_last_error()` somewhere on the object? - $verifyResult->errorCode = ErrorCodes::$FailedToEncodeRequest; - return $verifyResult; + // TODO: should we expose `json_last_error()`? + return ['errorCode' => ErrorCodes::$FailedToEncodeRequest]; } $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, $this->resolvedApiEndpoint . SITEVERIFY_PATH); + curl_setopt($ch, CURLOPT_URL, $this->resolvedApiEndpoint . $path); curl_setopt($ch, CURLOPT_POST, true); // Return response instead of outputting @@ -91,32 +82,81 @@ public function verifyCaptchaResponse(?string $response, string $sitekey = ""): $resp = curl_exec($ch); - if ($resp === false) { - // TODO: should we put `curl_errno($ch)` somewhere on the object? - $verifyResult->errorCode = ErrorCodes::$RequestFailed; + // TODO: should we expose `curl_errno($ch)`? curl_close($ch); - return $verifyResult; + return ['errorCode' => ErrorCodes::$RequestFailed]; } - // Get HTTP status code - $verifyResult->status = curl_getinfo($ch, CURLINFO_HTTP_CODE); - + $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); - $response = VerifyResponse::fromJson($resp); - if ($response == null) { - // TODO: should we put `json_last_error()` somewhere on the object? + return ['status' => $status, 'body' => $resp]; + } + + public function verifyCaptchaResponse(?string $response, string $sitekey = ""): VerifyResult + { + $verifyResult = new VerifyResult($this->config->strict); + $verifyResult->status = -1; // So that it is always set, this will only be -1 if the request fails. + + $fields = array("response" => $response ?? ""); + if ($sitekey != "" || $this->config->sitekey != "") { + $fields["sitekey"] = $sitekey ?: $this->config->sitekey; + } + + $apiResult = $this->makeRequest(SITEVERIFY_PATH, $fields); + if (isset($apiResult['errorCode'])) { + $verifyResult->errorCode = $apiResult['errorCode']; + return $verifyResult; + } + + $verifyResult->status = $apiResult['status']; + + $parsedResponse = VerifyResponse::fromJson($apiResult['body']); + if ($parsedResponse == null) { + // TODO: should we expose `json_last_error()`? $verifyResult->errorCode = ErrorCodes::$FailedToDecodeResponse; return $verifyResult; } - $verifyResult->response = $response; + $verifyResult->response = $parsedResponse; if ($verifyResult->status >= 400 && $verifyResult->status < 500) { $verifyResult->errorCode = ErrorCodes::$FailedDueToClientError; - return $verifyResult; } return $verifyResult; } + + public function retrieveRiskIntelligence(?string $token, string $sitekey = ""): RiskIntelligenceRetrieveResult + { + $result = new RiskIntelligenceRetrieveResult(); + $result->status = -1; // So that it is always set, this will only be -1 if the request fails. + + $fields = array("token" => $token ?? ""); + if ($sitekey != "" || $this->config->sitekey != "") { + $fields["sitekey"] = $sitekey ?: $this->config->sitekey; + } + + $apiResult = $this->makeRequest(RETRIEVE_PATH, $fields); + if (isset($apiResult['errorCode'])) { + $result->errorCode = $apiResult['errorCode']; + return $result; + } + + $result->status = $apiResult['status']; + + $parsedResponse = RiskIntelligenceRetrieveResponse::fromJson($apiResult['body']); + if ($parsedResponse == null) { + // TODO: should we expose `json_last_error()`? + $result->errorCode = ErrorCodes::$FailedToDecodeResponse; + return $result; + } + $result->response = $parsedResponse; + + if ($result->status >= 400 && $result->status < 500) { + $result->errorCode = ErrorCodes::$FailedDueToClientError; + } + + return $result; + } } diff --git a/src/result.php b/src/result.php index a221670..81a7ab2 100644 --- a/src/result.php +++ b/src/result.php @@ -4,7 +4,7 @@ namespace FriendlyCaptcha\SDK; -use FriendlyCaptcha\SDK\VerifyResponse; +use FriendlyCaptcha\SDK\{VerifyResponse, RiskIntelligenceRetrieveResponse}; use Exception; class VerifyResult @@ -147,3 +147,113 @@ public function wasAbleToVerify(): bool return $this->status == 200 && !$this->isRequestError() && !$this->isDecodeError(); } } + +/** + * The result of a risk intelligence retrieve request. + */ +class RiskIntelligenceRetrieveResult +{ + /** @var int The HTTP status code of the response. */ + public $status; + + /** @var RiskIntelligenceRetrieveResponse|null The response body. */ + public $response; + + /** + * `null` if Risk Intelligence data could be retrieved; in other words we got a 200 response. + * \ + * Otherwise this will be set to one of the error codes in `ErrorCodes`: + * * `ErrorCodes::$RequestFailed` + * * `ErrorCodes::$FailedDueToClientError` (see $response->error for more details, your API key might be wrong). + * * `ErrorCodes::$FailedToEncodeRequest` + * * `ErrorCodes::$FailedToDecodeResponse` + * + * @var string|null + */ + public $errorCode = null; + + /** + * Get the response that was sent from the server. + * This can be null if the request to the API could not be made succesfully. + */ + public function getResponse(): ?RiskIntelligenceRetrieveResponse + { + return $this->response; + } + + /** + * Get the error field from the response as was returned by the API, or null if the field is not present. + */ + public function getResponseError(): ?APIResponseError + { + if ($this->response === null) { + return null; + } + return $this->response->error; + } + + /** + * Something went wrong on the client side, this generally means your configuration is wrong. + * Check your secrets (API key) and sitekey. + * + * See `$this->response->error` for more details. + */ + public function isClientError(): bool + { + return $this->errorCode === ErrorCodes::$FailedDueToClientError; + } + + /** + * Failed to encode the Risk Intelligence token. + */ + public function isEncodeError(): bool + { + return $this->errorCode === ErrorCodes::$FailedToEncodeRequest; + } + + /** + * Something went wrong making the request to the Friendly Captcha API, perhaps there is a network connection issue? + */ + public function isRequestError(): bool + { + return $this->errorCode === ErrorCodes::$RequestFailed; + } + + /** + * Something went wrong decoding the response from the Friendly Captcha API. + */ + public function isDecodeError(): bool + { + return $this->errorCode === ErrorCodes::$FailedToDecodeResponse; + } + + /** + * Whether the request to retrieve risk intelligence was completed. In other words: the API responded with status 200. + * If this is false, you should notify yourself and use `getResponseError()` to see what is wrong. + */ + public function wasAbleToRetrieve(): bool + { + // If we failed to encode, we actually consider `wasAbleToRetrieve` to be true. This is because we don't want to + // alert on failed encoding: an attacker could send such malformed data that it fails to encode. + if ($this->isEncodeError()) { + return true; + } + + return $this->status == 200 && !$this->isRequestError() && !$this->isDecodeError(); + } + + /** + * Whether the Risk Intelligence data was successfully retrieved and is valid. + */ + public function isValid(): bool { + if ($this->wasAbleToRetrieve()) { + if ($this->isEncodeError()) { + return false; + } + if ($this->response != null) { + return $this->response->success; + } + } + return false; + } +} From 11b45b180bed8953d3332911bb7a76f4eb7773a3 Mon Sep 17 00:00:00 2001 From: Aaron Greenberg Date: Thu, 19 Mar 2026 14:03:47 +0100 Subject: [PATCH 09/13] Add Risk Intelligence tests --- tests/retrieveTest.php | 105 +++++++++++++++++++++++++++++++++++++++++ tests/verifyTest.php | 4 +- 2 files changed, 107 insertions(+), 2 deletions(-) create mode 100644 tests/retrieveTest.php diff --git a/tests/retrieveTest.php b/tests/retrieveTest.php new file mode 100644 index 0000000..e7442f8 --- /dev/null +++ b/tests/retrieveTest.php @@ -0,0 +1,105 @@ +setAPIKey("some-key"); + $client = new Client($opts); + $result = $client->retrieveRiskIntelligence("\xB1\x31"); // This fails to encode to JSON in PHP. + + $this->assertTrue($result->isEncodeError()); + $this->assertFalse($result->isClientError()); + $this->assertFalse($result->isRequestError()); + $this->assertFalse($result->isDecodeError()); + $this->assertTrue($result->wasAbleToRetrieve()); + $this->assertFalse($result->isValid()); + } + + public function testNonReachableEndpoint(): void + { + $opts = new ClientConfig(); + $opts->setAPIKey("some-key")->setApiEndpoint("https://localhost:9999"); // Assuming there's nothing running on that port.. + $client = new Client($opts); + $result = $client->retrieveRiskIntelligence("my-response"); + + $this->assertTrue($result->isRequestError()); + $this->assertFalse($result->isClientError()); + $this->assertFalse($result->isEncodeError()); + $this->assertFalse($result->isDecodeError()); + $this->assertFalse($result->wasAbleToRetrieve()); + $this->assertFalse($result->isValid()); + } + + + + public static function sdkMockTestsProvider(): array + { + $cases = loadRetrieveSDKTestsFromServer(MOCK_SERVER_URL)["tests"]; + $testCases = array(); + foreach ($cases as $case) { + $testCases[$case["name"]] = array($case); + } + return $testCases; + } + + /** + * @dataProvider sdkMockTestsProvider + */ + #[DataProvider('sdkMockTestsProvider')] + public function testSDKTestServerCase($test): void + { + $opts = new ClientConfig(); + $opts->setAPIKey("some-key")->setApiEndpoint(MOCK_SERVER_URL); + $client = new Client($opts); + $result = $client->retrieveRiskIntelligence($test["token"]); + + $expectWasAbleToRetrieve = $test["expectation"]["was_able_to_retrieve"]; + $expectIsValid = $test["expectation"]["is_valid"]; + $expectIsClientError = $test["expectation"]["is_client_error"]; + $wasAbleToRetrieve = $result->wasAbleToRetrieve(); + $isValid = $result->isValid(); + $isClientError = $result->isClientError(); + + $this->assertEquals($expectWasAbleToRetrieve, $wasAbleToRetrieve, + "'was_able_to_retrieve' is not as expected: " . json_encode($wasAbleToRetrieve) . " result: " . print_r($result, true)); + + $this->assertEquals($expectIsValid, $isValid, + "'is_valid' is not as expected: " . json_encode($isValid) . " result: " . print_r($result, true)); + + $this->assertEquals($expectIsClientError, $isClientError, + "'is_client_error' is not as expected: " . json_encode($isClientError) . " result: " . print_r($result, true)); + + $expectedResponse = RiskIntelligenceRetrieveResponse::fromJson(json_encode($test["retrieve_response"])); + $actualResponse = $result->getResponse(); + $this->assertEquals($expectedResponse, $actualResponse); + } +} diff --git a/tests/verifyTest.php b/tests/verifyTest.php index 3bee375..8e871fe 100644 --- a/tests/verifyTest.php +++ b/tests/verifyTest.php @@ -12,7 +12,7 @@ const MOCK_SERVER_URL = "http://localhost:1090"; -function loadSDKTestsFromServer(string $serverURL) +function loadSiteverifySDKTestsFromServer(string $serverURL) { $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $serverURL . "/api/v1/captcha/siteverifyTests"); @@ -62,7 +62,7 @@ public function testNonReachableEndpoint(): void public static function sdkMockTestsProvider(): array { - $cases = loadSDKTestsFromServer(MOCK_SERVER_URL)["tests"]; + $cases = loadSiteverifySDKTestsFromServer(MOCK_SERVER_URL)["tests"]; $testCases = array(); foreach ($cases as $case) { $testCases[$case["name"]] = array($case); From dd85e24992d117e95af7ed466b1b765967736a8e Mon Sep 17 00:00:00 2001 From: Aaron Greenberg Date: Thu, 19 Mar 2026 16:43:00 +0100 Subject: [PATCH 10/13] Update README with Risk Intelligence documentation --- README.md | 58 +++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 46 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index f5a83f6..64aca22 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # friendly-captcha-php -A PHP client for the [Friendly Captcha](https://friendlycaptcha.com) service. This client allows for easy integration and verification of captcha responses with the Friendly Captcha API. +A PHP client for the [Friendly Captcha](https://friendlycaptcha.com) service. This client makes it easy to connect to the Friendly Captcha API for captcha verification or [Risk Intelligence](https://developer.friendlycaptcha.com/docs/v2/risk-intelligence/) retrieval. > Note, this is for [Friendly Captcha v2](https://developer.friendlycaptcha.com) only. @@ -16,7 +16,11 @@ composer require friendlycaptcha/sdk ## Usage -First configure and create a SDK client +Below are some basic examples that demonstrate how to use this SDK. + +For complete examples, take a look at the [examples](./examples/) directory. + +### Initialization ```php use FriendlyCaptcha\SDK\{Client, ClientConfig} @@ -25,12 +29,12 @@ $config = new ClientConfig(); $config->setAPIKey("")->setSitekey(""); // You can also specify which endpoint to use, for example `"global"` or `"eu"`. -// $config->setEndpoint("eu") +// $config->setApiEndpoint("eu") $captchaClient = new Client($config) ``` -Then use it in the endpoint you want to protect +### Verifying a Captcha Response ```php function handleLoginRequest() { @@ -61,6 +65,38 @@ function handleLoginRequest() { } ``` +### Retrieving Risk Intelligence + +You can retrieve [Risk Intelligence](https://developer.friendlycaptcha.com/docs/v2/risk-intelligence/) data using a token. This data provides detailed information about the risk profile of a request, including network data, geolocation, browser details, and risk scores. + +```php +function getRiskIntelligence() { + global $frcClient; + + $token = isset($_POST["frc-risk-intelligence-token"]) ? $_POST["frc-risk-intelligence-token"] : null; + $result = $frcClient->retrieveRiskIntelligence($token); + + if ($result->wasAbleToRetrieve()) { + // Risk Intelligence token is valid and data was retrieved successfully. + if ($result->isValid()) { + // Token was invalid or expired. + $response = $result->getResponse(); + echo "Risk Intelligence data", $response->data; + } else { + $error = $result->getResponseError(); + error_log("Error:", $error); + } + } else { + // Network issue or configuration problem. + if ($result->isClientError()) { + error_log("Configuration error - check your API key"); + } else { + error_log("Network issue or service temporarily unavailable"); + } + } +} +``` + ## Development Make sure you have PHP installed (e.g. with `brew install php` on a Macbook). @@ -111,17 +147,15 @@ OK (28 tests, 110 assertions) ### Optional -Install an old version of PHP (to be sure it works in that version). The oldest PHP version this SDK supports is 7.1. - -```php -brew install shivammathur/php/php@7.1 -echo 'export PATH="/opt/homebrew/opt/php@7.1/bin:$PATH"' >> ~/.zshrc -echo 'export PATH="/opt/homebrew/opt/php@7.1/sbin:$PATH"' >> ~/.zshrc +To make sure that the SDK is backwards compatible, make sure the tests pass in PHP 7.1. We recommend using Docker. -# open a new terminal and check the new version -php --version +```console +$ docker run --rm -it --network host -v $PWD:/php -w /php php:7.1-alpine /bin/sh +/php # apk update && apk add git ``` +Then follow the steps above starting with [**Install Composer**](#install-composer). You can run `./friendly-captcha-sdk-testserver serve` outside the Docker container. + ### Some features you can't use to be compatible with PHP 7.1 - Typings of class member variables. From ae74820b30f6e80133ff69d8ac9177bd9f4de26f Mon Sep 17 00:00:00 2001 From: Aaron Greenberg Date: Thu, 19 Mar 2026 18:57:25 +0100 Subject: [PATCH 11/13] Add Risk Intelligence to example --- examples/form/README.md | 4 ++-- examples/form/index.php | 53 +++++++++++++++++++++++++++++++---------- 2 files changed, 42 insertions(+), 15 deletions(-) diff --git a/examples/form/README.md b/examples/form/README.md index e3463f9..23dc47b 100644 --- a/examples/form/README.md +++ b/examples/form/README.md @@ -1,6 +1,6 @@ # form -Example of using the Friendly Captcha SDK for PHP. +Example of using the Friendly Captcha SDK for PHP. It displays a form with a captcha and verifies the captcha response on the back-end. If the application has [**Risk Intelligence**](https://developer.friendlycaptcha.com/docs/v2/risk-intelligence/) enabled, it will also generate a token and use it to retrieve the Risk Intelligence data. ## Running the example @@ -11,7 +11,7 @@ FRC_APIKEY=YOUR_API_KEY FRC_SITEKEY=YOUR_SITEKEY php -S localhost:8000 Alternatively, you can also specify custom endpoints: ```shell -FRC_SITEKEY=YOUR_API_KEY FRC_APIKEY=YOUR_SITEKEY FRC_SITEVERIFY_ENDPOINT=https://eu-dev.frcapi.com/api/v2/captcha/siteverify FRC_WIDGET_ENDPOINT=https://eu-dev.frcapi.com/api/v2/captcha php -S localhost:8000 +FRC_SITEKEY=YOUR_API_KEY FRC_APIKEY=YOUR_SITEKEY FRC_API_ENDPOINT=https://eu-dev.frcapi.com FRC_WIDGET_ENDPOINT=https://eu-dev.frcapi.com php -S localhost:8000 ``` Now open your browser and navigate to [http://localhost:8000](http://localhost:8000). diff --git a/examples/form/index.php b/examples/form/index.php index c74289f..88c1e60 100644 --- a/examples/form/index.php +++ b/examples/form/index.php @@ -10,17 +10,17 @@ $apikey = getenv('FRC_APIKEY'); // Optionally we can pass in custom endpoints to be used, such as "eu". -$siteverifyEndpoint = getenv('FRC_SITEVERIFY_ENDPOINT'); +$apiEndpoint = getenv('FRC_API_ENDPOINT'); $widgetEndpoint = getenv('FRC_WIDGET_ENDPOINT'); -const MODULE_SCRIPT_URL = "https://cdn.jsdelivr.net/npm/@friendlycaptcha/sdk@0.1.8/site.min.js"; -const NOMODULE_SCRIPT_URL = "https://cdn.jsdelivr.net/npm/@friendlycaptcha/sdk@0.1.8/site.compat.min.js";; // Compatibility fallback for old browsers. +const MODULE_SCRIPT_URL = "https://cdn.jsdelivr.net/npm/@friendlycaptcha/sdk@0.2.0/site.min.js"; +const NOMODULE_SCRIPT_URL = "https://cdn.jsdelivr.net/npm/@friendlycaptcha/sdk@0.2.0/site.compat.min.js";; // Compatibility fallback for old browsers. if (empty($sitekey) || empty($apikey)) { die("Please set the FRC_SITEKEY and FRC_APIKEY environment values before running this example."); } -function generateForm(bool $didSubmit, bool $captchaOK, string $sitekey) +function generateForm(bool $didSubmit, bool $captchaOK, string $sitekey, string $riskIntelligence) { global $widgetEndpoint; $html = ''; @@ -31,10 +31,12 @@ function generateForm(bool $didSubmit, bool $captchaOK, string $sitekey) } else { $html .= '

❌ Anti-robot check failed, please try again.
See console output for details.

'; } + if (!empty($riskIntelligence)) { + $html .= $riskIntelligence; + } $html .= 'Back to form'; - } - - if (!$didSubmit) { + } else { + $dataEndpoint = empty($widgetEndpoint) ? "" : (' data-api-endpoint="' . $widgetEndpoint . '"'); $html .= '
@@ -42,9 +44,8 @@ function generateForm(bool $didSubmit, bool $captchaOK, string $sitekey)


-
+
+
'; @@ -55,8 +56,8 @@ function generateForm(bool $didSubmit, bool $captchaOK, string $sitekey) $config = new \FriendlyCaptcha\SDK\ClientConfig(); $config->setAPIKey($apikey); $config->setSitekey($sitekey); -if (!empty($siteverifyEndpoint)) { - $config->setSiteverifyEndpoint($siteverifyEndpoint); // Optional, it defaults to "global". +if (!empty($apiEndpoint)) { + $config->setApiEndpoint($apiEndpoint); // Optional, it defaults to "global". } $frcClient = new \FriendlyCaptcha\SDK\Client($config); @@ -64,6 +65,8 @@ function generateForm(bool $didSubmit, bool $captchaOK, string $sitekey) $didSubmit = $_SERVER['REQUEST_METHOD'] === 'POST'; $captchaOK = false; +$riskIntelligence = ""; + if ($didSubmit) { $captchaResponse = isset($_POST["frc-captcha-response"]) ? $_POST["frc-captcha-response"] : null; $captchaResult = $frcClient->verifyCaptchaResponse($captchaResponse); @@ -91,6 +94,30 @@ function generateForm(bool $didSubmit, bool $captchaOK, string $sitekey) // In this example we will simply print the message to the console using `error_log`. error_log("Message submitted by \"" . $name . "\": \"" . $message . "\""); } + + $riskIntelligenceToken = isset($_POST["frc-risk-intelligence-token"]) ? $_POST["frc-risk-intelligence-token"] : null; + $result = $frcClient->retrieveRiskIntelligence($riskIntelligenceToken); + if ($result->wasAbleToRetrieve()) { + if ($result->isValid()) { + $data = $result->getResponse()->data->risk_intelligence; + $riskIntelligence = ' +

Risk Intelligence Data

+
+
Location
+
' . $data->network->geolocation->city . ', ' . $data->network->geolocation->country->name . '
+
Device
+
Make: ' . $data->client->device->brand . '
Model: ' . $data->client->device->model . '
+
Overall Risk Score
+
' . $data->risk_scores->overall . '
+
+ '; + } else { + $error = $result->getResponseError(); + error_log("Friendly Captcha API Error:", $error); + } + } else { + error_log("Failed to make the request", $result->errorCode); + } } ?> @@ -112,7 +139,7 @@ function generateForm(bool $didSubmit, bool $captchaOK, string $sitekey)

PHP Form Example

- +