shouldDrop()) { thank_the_visitor(); exit; } // answer as usual, send it nowhere * $lead['botscope_verdict'] = $v->label(); // human | suspect | bot | unknown * send_to_crm($lead); * * One file, no dependencies, PHP 7.4+: it has to drop into a contact-form handler on a * shared host. It reads botscope_token from the POST (the field the Botscope script puts * in every form) unless handed one, and asks POST /api/v1/verdict/verify. * * Never throws, never blocks a lead on our account: if Botscope cannot be reached in * time, the answer is "unknown" with action "tag" — the backend keeps the lead. */ final class FormVerdict { public const FIELD = 'botscope_token'; /** @var array */ private $answer; /** * @param array $answer */ private function __construct(array $answer) { $this->answer = $answer; } /** * @param string|null $token the form token; null reads $_POST[FIELD] * @param float $timeout seconds to wait for Botscope before keeping the lead */ public static function check(string $panelUrl, string $apiKey, ?string $token = null, float $timeout = 2.0): self { if ($token === null) { $token = isset($_POST[self::FIELD]) && is_string($_POST[self::FIELD]) ? $_POST[self::FIELD] : ''; } $answer = self::ask(rtrim($panelUrl, '/') . '/api/v1/verdict/verify', $apiKey, $token, $timeout); return new self($answer ?? ['valid' => false, 'reason' => 'unreachable', 'action' => 'tag']); } /** @param array $answer the decoded body of /api/v1/verdict/verify */ public static function fromAnswer(array $answer): self { return new self($answer); } /** accept | tag | drop — what the site's settings say to do with this submission. */ public function action(): string { $action = $this->answer['action'] ?? 'tag'; return in_array($action, ['accept', 'tag', 'drop'], true) ? $action : 'tag'; } public function shouldDrop(): bool { return $this->action() === 'drop'; } /** * human | suspect | bot for a readable token. A form posted without one, or with one * spent before, is "bot": that is how a script posting to the handler looks. Anything * we could not decide is "unknown". */ public function label(): string { if (($this->answer['valid'] ?? false) === true) { $verdict = $this->answer['verdict'] ?? ''; return in_array($verdict, ['human', 'suspect', 'bot'], true) ? $verdict : 'unknown'; } return in_array($this->answer['reason'] ?? '', ['missing', 'replayed', 'invalid'], true) ? 'bot' : 'unknown'; } public function visitorId(): ?string { $id = $this->answer['visitor_id'] ?? null; return is_string($id) && $id !== '' ? $id : null; } /** @return array the raw answer, for logging next to the lead */ public function toArray(): array { return $this->answer; } /** * @return array|null */ private static function ask(string $url, string $apiKey, string $token, float $timeout): ?array { if ($apiKey === '' || ! function_exists('curl_init')) { return null; } $ch = curl_init($url); if ($ch === false) { return null; } curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => (string) json_encode(['token' => $token]), CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'Accept: application/json', 'X-Botscope-Api-Key: ' . $apiKey, ], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT_MS => (int) ($timeout * 1000), CURLOPT_CONNECTTIMEOUT_MS => (int) (min($timeout, 1.5) * 1000), ]); $body = curl_exec($ch); $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); if (PHP_VERSION_ID < 80000) { curl_close($ch); // a no-op since 8.0, deprecated since 8.5 } if (! is_string($body) || $code !== 200) { return null; } $answer = json_decode($body, true); return is_array($answer) ? $answer : null; } }