reason = $reason; } public function getReason(): string { return $this->reason; } } } namespace CashoutGuard\Exception { /** The API answered with a non-2xx status or with a body that is not JSON. */ class ApiException extends CashoutGuardException { /** @var int */ private $statusCode; /** @var string */ private $errorCode; /** @var array|null */ private $body; /** @param array|null $body */ public function __construct(string $errorCode, int $statusCode, ?array $body = null, string $message = '') { parent::__construct($message !== '' ? $message : 'CashoutGuard API error '.$statusCode.': '.$errorCode, $statusCode); $this->statusCode = $statusCode; $this->errorCode = $errorCode; $this->body = $body; } public function getStatusCode(): int { return $this->statusCode; } /** For example "missing_or_invalid_secret_key", "not_found", "validation_failed", "http_500". */ public function getErrorCode(): string { return $this->errorCode; } /** @return array|null */ public function getBody(): ?array { return $this->body; } } } namespace CashoutGuard\Http { final class Response { /** @var int */ public $status; /** @var string */ public $body; public function __construct(int $status, string $body) { $this->status = $status; $this->body = $body; } } } namespace CashoutGuard\Http { use CashoutGuard\Exception\TransportException; /** * Sends one HTTP request. Implement it to plug in Guzzle, Symfony HttpClient, * a test double, etc. Every HTTP status (4xx and 5xx included) must come back * as a Response; throw only when no response was received at all. */ interface TransportInterface { /** * @param array $headers * * @throws TransportException on timeout, DNS/connect/TLS failure, etc. */ public function send(string $method, string $url, array $headers, ?string $body, float $timeout): Response; } } namespace CashoutGuard\Http { use CashoutGuard\Exception\TransportException; /** Default transport: ext-curl, one total timeout in seconds, no redirects, TLS verification on. */ final class CurlTransport implements TransportInterface { public function send(string $method, string $url, array $headers, ?string $body, float $timeout): Response { if (! function_exists('curl_init')) { throw new TransportException('ext-curl is not installed', TransportException::NETWORK_ERROR); } $ch = curl_init($url); if ($ch === false) { throw new TransportException('curl_init() failed', TransportException::NETWORK_ERROR); } $lines = []; foreach ($headers as $name => $value) { $lines[] = $name.': '.$value; } $ms = max(1, (int) round($timeout * 1000)); $options = [ CURLOPT_CUSTOMREQUEST => $method, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => $lines, CURLOPT_TIMEOUT_MS => $ms, CURLOPT_CONNECTTIMEOUT_MS => $ms, CURLOPT_NOSIGNAL => true, CURLOPT_FOLLOWLOCATION => false, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, ]; if ($body !== null) { $options[CURLOPT_POSTFIELDS] = $body; } curl_setopt_array($ch, $options); $raw = curl_exec($ch); if ($raw === false) { $errno = curl_errno($ch); $error = curl_error($ch); $this->close($ch); throw new TransportException( $error !== '' ? $error : 'curl error '.$errno, $errno === CURLE_OPERATION_TIMEDOUT ? TransportException::TIMEOUT : TransportException::NETWORK_ERROR ); } $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); $this->close($ch); return new Response($status, (string) $raw); } /** @param resource|\CurlHandle $ch */ private function close($ch): void { // PHP 8 frees CurlHandle objects on its own and deprecates curl_close() in 8.5. if (PHP_VERSION_ID < 80000) { curl_close($ch); } } } } namespace CashoutGuard { /** * Outcome of one API call. Treat the public properties as read-only. * * Act on $action (or isBlocked()/needsReview()), not on $decision: in "monitor" * mode the API always returns action "allow" while decision still shows what it * would have done. * * When the call failed and the client is fail-open, $error is set and * decision/action are "allow". */ final class Result { public const ALLOW = 'allow'; public const REVIEW = 'review'; public const BLOCK = 'block'; /** @var string allow|review|block: what the rules concluded */ public $decision = self::ALLOW; /** @var string allow|review|block: what you should do (always allow in monitor mode) */ public $action = self::ALLOW; /** @var int|null 0..100, null when unknown */ public $score; /** @var list}> */ public $reasons = []; /** @var array|null decoded JSON body (also set for API errors when the body was JSON) */ public $raw; /** @var string|null null on success; otherwise a short code: timeout, network_error, http_500, not_found, ... */ public $error; /** @var string|null human-readable detail for $error */ public $errorMessage; /** @var int HTTP status, 0 when no response was received */ public $statusCode = 0; /** @var string|null evt_... (evaluate only) */ public $eventId; /** @var string|null monitor|enforce (evaluate only) */ public $mode; /** @var array|null account block of the evaluate response */ public $account; private function __construct() { } /** @param array $data */ public static function fromResponse(array $data, int $statusCode): self { $r = new self(); $r->raw = $data; $r->statusCode = $statusCode; $r->decision = self::normalize($data['decision'] ?? null); // /v1/accounts/{id} has no "action": the account decision is the only verdict there. $r->action = self::normalize(array_key_exists('action', $data) ? $data['action'] : ($data['decision'] ?? null)); $r->score = isset($data['score']) && is_numeric($data['score']) ? (int) $data['score'] : null; $r->reasons = isset($data['reasons']) && is_array($data['reasons']) ? array_values(array_filter($data['reasons'], 'is_array')) : []; $r->eventId = isset($data['event_id']) && is_string($data['event_id']) ? $data['event_id'] : null; $r->mode = isset($data['mode']) && is_string($data['mode']) ? $data['mode'] : null; $r->account = isset($data['account']) && is_array($data['account']) ? $data['account'] : null; return $r; } /** @param array|null $raw */ public static function failOpen(string $error, string $message = '', int $statusCode = 0, ?array $raw = null): self { $r = new self(); $r->error = $error; $r->errorMessage = $message !== '' ? $message : $error; $r->statusCode = $statusCode; $r->raw = $raw; return $r; } public function isBlocked(): bool { return $this->action === self::BLOCK; } public function needsReview(): bool { return $this->action === self::REVIEW; } public function isAllowed(): bool { return $this->action === self::ALLOW; } /** True when the API answered with 2xx and a JSON body. */ public function ok(): bool { return $this->error === null; } /** True when this is a fail-open placeholder (no real verdict). */ public function failed(): bool { return $this->error !== null; } /** @return list reason codes, highest weight first */ public function reasonCodes(): array { $codes = []; foreach ($this->reasons as $reason) { if (isset($reason['code']) && is_string($reason['code'])) { $codes[] = $reason['code']; } } return $codes; } public function hasReason(string $code): bool { return in_array($code, $this->reasonCodes(), true); } /** active|allowlisted|blocked when the response carries it (evaluate, account, setAccountStatus). */ public function accountStatus(): ?string { if (is_array($this->account) && isset($this->account['status']) && is_string($this->account['status'])) { return $this->account['status']; } if (is_array($this->raw) && isset($this->raw['status']) && is_string($this->raw['status'])) { return $this->raw['status']; } return null; } /** @param mixed $value */ private static function normalize($value): string { return in_array($value, [self::ALLOW, self::REVIEW, self::BLOCK], true) ? $value : self::ALLOW; } } } namespace CashoutGuard { /** Front-end helpers. */ final class Browser { /** * The recommended snippet: a tiny loader stub (so CashoutGuard.ready() * works before agent.js has loaded) plus the async agent script. * * Then, on the pages that matter: * CashoutGuard.ready(async () => { * const { request_id } = await CashoutGuard.collect({ event: 'cashout' }); * // put request_id in a hidden form field and pass it to Client::evaluate() * }); */ public static function snippet(string $publicKey, string $baseUrl = Client::DEFAULT_BASE_URL): string { $src = rtrim($baseUrl, '/').'/v1/agent.js'; return "\n" .''."\n"; } private static function attr(string $value): string { return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); } } } namespace CashoutGuard { /** * Webhook signature check. CashoutGuard signs every webhook with * X-CashoutGuard-Signature = hex(HMAC-SHA256(raw request body, webhook secret)). */ final class Webhook { public const SIGNATURE_HEADER = 'X-CashoutGuard-Signature'; /** * @param string $rawBody the exact bytes received (file_get_contents('php://input')), not re-encoded JSON * @param string $signatureHeader value of the X-CashoutGuard-Signature header */ public static function verify(string $rawBody, string $signatureHeader, string $secret): bool { $given = strtolower(trim($signatureHeader)); if ($secret === '' || $given === '') { return false; } return hash_equals(hash_hmac('sha256', $rawBody, $secret), $given); } } } namespace CashoutGuard { use CashoutGuard\Exception\ApiException; use CashoutGuard\Exception\TransportException; use CashoutGuard\Http\CurlTransport; use CashoutGuard\Http\TransportInterface; /** * Server-side CashoutGuard client. * * $cg = new \CashoutGuard\Client(getenv('CASHOUTGUARD_SECRET')); * $risk = $cg->evaluate(['event' => 'cashout', 'account_id' => $userId, 'request_id' => $_POST['cg_request_id'] ?? null]); * if ($risk->isBlocked()) { ... } * * Fail-open (default): a timeout, network error, 5xx, 4xx, invalid JSON or an * invalid argument never throws. You get a Result whose decision/action are * "allow" and whose $error says what went wrong, so an outage on our side can * never stop your users from being paid. With failOpen=false these cases throw * TransportException / ApiException / \InvalidArgumentException instead. */ final class Client { public const VERSION = '1.0.0'; public const DEFAULT_BASE_URL = 'https://cashoutguard.com'; public const DEFAULT_TIMEOUT = 2.0; public const EVENTS = ['signup', 'login', 'offer_click', 'conversion', 'cashout', 'custom']; public const STATUSES = ['active', 'allowlisted', 'blocked']; /** Max string lengths accepted by POST /v1/evaluate. */ private const LIMITS = [ 'email' => 191, 'payout_address' => 191, 'payout_method' => 64, 'currency' => 8, 'offer_id' => 191, 'transaction_id' => 191, 'offer_name' => 191, 'click_id' => 191, 'source' => 191, ]; /** @var string */ private $secretKey; /** @var string */ private $baseUrl; /** @var float */ private $timeout; /** @var bool */ private $failOpen; /** @var TransportInterface */ private $transport; /** * Options: * baseUrl default https://cashoutguard.com * timeout seconds for the whole request, default 2.0 * failOpen default true * transport default CurlTransport (inject your own TransportInterface to use another HTTP stack or in tests) * * @param array{baseUrl?: string, timeout?: float|int, failOpen?: bool, transport?: TransportInterface} $options */ public function __construct(string $secretKey, array $options = []) { $this->secretKey = trim($secretKey); $baseUrl = isset($options['baseUrl']) && is_string($options['baseUrl']) && trim($options['baseUrl']) !== '' ? trim($options['baseUrl']) : self::DEFAULT_BASE_URL; $this->baseUrl = rtrim($baseUrl, '/'); $timeout = $options['timeout'] ?? self::DEFAULT_TIMEOUT; $this->timeout = is_numeric($timeout) && (float) $timeout > 0 ? (float) $timeout : self::DEFAULT_TIMEOUT; $this->failOpen = array_key_exists('failOpen', $options) ? (bool) $options['failOpen'] : true; $transport = $options['transport'] ?? null; $this->transport = $transport instanceof TransportInterface ? $transport : new CurlTransport(); } /** * POST /v1/evaluate. * * @param array $input event (required), account_id (required), request_id, ip, email, * payout_address, payout_method, amount, currency, offer_id, transaction_id. * Unknown keys are ignored. Optional values that the API would reject * (malformed IP or request_id, negative amount) are dropped so a bad * optional field never turns a real verdict into a 422; over-long * strings are truncated to the API limits. */ public function evaluate(array $input): Result { $event = $input['event'] ?? null; if (! is_string($event) || ! in_array($event, self::EVENTS, true)) { return $this->reject('invalid_argument', 'event must be one of: '.implode(', ', self::EVENTS)); } $accountId = $this->accountId($input['account_id'] ?? null); if ($accountId === null) { return $this->reject('invalid_argument', 'account_id must be a non-empty string or integer of at most 191 characters'); } $body = ['event' => $event, 'account_id' => $accountId]; $requestId = $this->str($input['request_id'] ?? null); if ($requestId !== null && preg_match('/^[A-Za-z0-9_.\-]{1,40}$/', $requestId)) { $body['request_id'] = $requestId; } $ip = $this->str($input['ip'] ?? null); if ($ip !== null && filter_var($ip, FILTER_VALIDATE_IP) !== false) { $body['ip'] = $ip; } foreach (self::LIMITS as $key => $max) { $value = $this->str($input[$key] ?? null); if ($value !== null) { $body[$key] = $this->truncate($value, $max); } } $amount = $input['amount'] ?? null; if (is_numeric($amount) && (float) $amount >= 0 && (float) $amount <= 1000000000) { $body['amount'] = (float) $amount; } // Extra key => string pairs stored with the event (max 20, 500 chars each). if (isset($input['meta']) && is_array($input['meta'])) { $meta = []; foreach (array_slice($input['meta'], 0, 20, true) as $k => $v) { $v = $this->str($v); if (is_string($k) && $v !== null) { $meta[$k] = $this->truncate($v, 500); } } if ($meta) { $body['meta'] = $meta; } } return $this->request('POST', '/v1/evaluate', $body); } /** GET /v1/accounts/{id}: current risk view of one account. A 404 comes back with error "not_found". */ public function account(string $accountId): Result { $id = $this->accountId($accountId); if ($id === null) { return $this->reject('invalid_argument', 'account_id must be a non-empty string of at most 191 characters'); } return $this->request('GET', '/v1/accounts/'.rawurlencode($id), null); } /** POST /v1/accounts/{id}/status with status active|allowlisted|blocked. */ public function setAccountStatus(string $accountId, string $status): Result { $id = $this->accountId($accountId); if ($id === null) { return $this->reject('invalid_argument', 'account_id must be a non-empty string of at most 191 characters'); } if (! in_array($status, self::STATUSES, true)) { return $this->reject('invalid_argument', 'status must be one of: '.implode(', ', self::STATUSES)); } return $this->request('POST', '/v1/accounts/'.rawurlencode($id).'/status', ['status' => $status]); } /** * POST /v1/cashouts/status: you paid, held or denied a cashout in your own admin (paid|held|denied|pending), so the * cashout queue follows. $id: the cashout_id or event_id from evaluate(), or your own transaction_id. */ public function cashoutStatus(string $id, string $status, ?string $note = null): Result { $id = trim($id); if ($id === '' || strlen($id) > 191) { return $this->reject('invalid_argument', 'id must be a non-empty string of at most 191 characters'); } if (! in_array($status, ['paid', 'held', 'denied', 'pending'], true)) { return $this->reject('invalid_argument', 'status must be one of: paid, held, denied, pending'); } return $this->request('POST', '/v1/cashouts/status', array_filter(['id' => $id, 'status' => $status, 'note' => $note], fn ($v) => $v !== null)); } /** * POST /v1/outcomes: what happened to conversions after we scored them, reversed by the offerwall or advertiser * (a chargeback) or confirmed valid. $ids: the transaction_id or click_id you sent with each conversion, or the * event_id we returned, up to 1,000 per call. $result->raw['matched'] and ['unmatched'] say which ids counted. * * @param list $ids */ public function outcomes(array $ids, string $outcome = 'reversed'): Result { $clean = []; foreach ($ids as $id) { $id = is_int($id) || is_float($id) ? (string) $id : $id; if (is_string($id) && trim($id) !== '' && strlen(trim($id)) <= 191) { $clean[trim($id)] = true; } } if ($clean === [] || count($clean) > 1000) { return $this->reject('invalid_argument', 'ids must hold 1 to 1,000 non-empty ids of at most 191 characters'); } if (! in_array($outcome, ['reversed', 'valid'], true)) { return $this->reject('invalid_argument', 'outcome must be reversed or valid'); } return $this->request('POST', '/v1/outcomes', ['outcome' => $outcome, 'ids' => array_keys($clean)]); } /** GET /v1/lookup: reputation of an IP and/or an email without an event (never counts as an active user). */ public function lookup(?string $ip = null, ?string $email = null): Result { if (($ip ?? '') === '' && ($email ?? '') === '') { return $this->reject('invalid_argument', 'pass an ip, an email or both'); } return $this->request('GET', '/v1/lookup?'.http_build_query(array_filter(['ip' => $ip, 'email' => $email])), null); } /** Checks X-CashoutGuard-Signature. Pass the raw request body, not re-encoded JSON. */ public function verifyWebhook(string $rawBody, string $signatureHeader, string $secret): bool { return Webhook::verify($rawBody, $signatureHeader, $secret); } /** @param array|null $body */ private function request(string $method, string $path, ?array $body): Result { if ($this->secretKey === '') { return $this->reject('missing_secret_key', 'No CashoutGuard secret key configured'); } $headers = [ 'Authorization' => 'Bearer '.$this->secretKey, 'Accept' => 'application/json', 'User-Agent' => 'cashoutguard-php/'.self::VERSION.' php/'.PHP_VERSION, ]; $json = null; if ($body !== null) { $json = json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE | JSON_PRESERVE_ZERO_FRACTION); if ($json === false) { return $this->reject('invalid_argument', 'Could not JSON-encode the request: '.json_last_error_msg()); } $headers['Content-Type'] = 'application/json'; } try { $response = $this->transport->send($method, $this->baseUrl.$path, $headers, $json, $this->timeout); } catch (TransportException $e) { if (! $this->failOpen) { throw $e; } return Result::failOpen($e->getReason(), $e->getMessage()); } $data = json_decode($response->body, true); $data = is_array($data) ? $data : null; $status = $response->status; if ($status >= 200 && $status < 300) { if ($data !== null) { return Result::fromResponse($data, $status); } return $this->apiError('invalid_response', $status, null, 'Response body is not a JSON object'); } if ($data !== null && isset($data['error']) && is_string($data['error'])) { $code = $data['error']; } elseif ($status === 422) { $code = 'validation_failed'; } else { $code = 'http_'.$status; } $message = $data !== null && isset($data['message']) && is_string($data['message']) ? $data['message'] : ''; return $this->apiError($code, $status, $data, $message); } /** @param array|null $data */ private function apiError(string $code, int $status, ?array $data, string $message): Result { if (! $this->failOpen) { throw new ApiException($code, $status, $data, $message); } return Result::failOpen($code, $message !== '' ? $message : 'HTTP '.$status.': '.$code, $status, $data); } private function reject(string $code, string $message): Result { if (! $this->failOpen) { throw new \InvalidArgumentException($message); } return Result::failOpen($code, $message); } /** @param mixed $value */ private function accountId($value): ?string { $id = $this->str($value); return $id !== null && strlen($id) <= 191 ? $id : null; } /** @param mixed $value */ private function str($value): ?string { if (is_int($value) || is_float($value)) { $value = (string) $value; } elseif (is_object($value) && method_exists($value, '__toString')) { $value = (string) $value; } if (! is_string($value)) { return null; } $value = trim($value); return $value === '' ? null : $value; } private function truncate(string $value, int $max): string { if (function_exists('mb_substr')) { return mb_strlen($value, 'UTF-8') > $max ? mb_substr($value, 0, $max, 'UTF-8') : $value; } return strlen($value) > $max ? substr($value, 0, $max) : $value; } } }