kaidn / kaidn-php
Official PHP client for Kaidn, the fraud and abuse scoring API.
Package info
pkg:composer/kaidn/kaidn-php
Requires
- php: >=7.4
- ext-curl: *
- ext-json: *
Requires (Dev)
- phpunit/phpunit: ^9.6
README
kaidn/kaidn-php
Official PHP client for Kaidn, the fraud and abuse scoring API.
Send one user action, get back allow, review or block, with the reasons attached.
composer require kaidn/kaidn-php
use Kaidn\Client; $client = new Client(); // reads KAIDN_API_KEY $r = $client->score([ 'event' => 'signup', 'ip' => $_SERVER['REMOTE_ADDR'], 'email' => $email, ]); if ($r->isBlocked()) { throw new Denied($r->reason_text); }
No Composer dependencies. This runs in your signup and checkout path, so every package
it pulled in would be one more thing that can break your deploy or appear in your audit.
It uses ext-curl, which is on effectively every PHP host including shared cPanel.
PHP 7.4 and up, deliberately. 7.4 is long past end of life upstream and it is what a lot of the rewards and affiliate shops using this actually run: one live site on Kaidn today is on 7.4.33. A client that refuses to install is worse than one written without enums and constructor promotion.
Server-side only: it holds your secret key, so never expose it to a browser. The browser
half is @kaidn/fp and uses a separate
publishable key.
Score an event
event is the only required key, and the name is yours to choose. Send whatever else you
already collect; the answer sharpens as you send more.
$r = $client->score([ 'event' => 'signup', 'user_id' => $user->id, 'ip' => $_SERVER['REMOTE_ADDR'], 'email' => $_POST['email'], 'device_id' => $_POST['kaidn_device_id'] ?? null, // from @kaidn/fp ]); $r->verdict; // "allow" | "review" | "block" $r->reasons; // ["datacenter_ip", "disposable_email"] $r->reason_text; // a sentence you could send to the customer $r->score; // 0-100. Bookkeeping, not a probability
The three cases people actually use:
if ($r->isBlocked()) { return deny(); // generic message: a specific one teaches the next attempt } if ($r->needsReview()) { createAccount($holdRewards = true); // they can use the product, they just cannot earn yet flagForReview($r->event_id, $r->reason_text); } else { createAccount(); }
Read the evidence
Every verdict shows its work. key is the config key you would edit to retune that check.
foreach ($r->checks as $c) { echo "$c->reason $c->weight $c->key " . json_encode($c->evidence) . "\n"; // datacenter_ip 45 datacenterIp {"asn":"16509"} }
Recognise a returning device: rung 1
A browser fingerprint is not a person: on production traffic one iOS Safari fingerprint
covers 2.30 different people. So use resolved_id, never id, and weigh it with
collision_risk.
Better still, stop guessing. Store the token Kaidn returns as a cookie on your own domain
and pass the request's Cookie header back next time:
use Kaidn\Client; use Kaidn\CookieOptions; $client = new Client(null, ['cookie' => new CookieOptions()]); // off unless you ask $r = $client->scoreWithCookie( ['event' => 'login', 'ip' => $_SERVER['REMOTE_ADDR'], 'email' => $email], $_SERVER['HTTP_COOKIE'] ?? null ); if ($r->set_cookie !== null) { header('Set-Cookie: ' . $r->set_cookie, false); } if ($r->device && $r->device->resolution === 'deterministic') { // we have seen THIS browser, not something that hashes like it }
Measured on one browser across two visits, with the IP changed in between:
| visit 1 | visit 2 | |
|---|---|---|
resolution |
probabilistic |
deterministic |
resolution_rung |
2 | 1 |
collision_risk |
0.12 | 0.01 |
Your server has to set the cookie, not us. Browsers judge a cookie by the domain that
sent Set-Cookie, so one set from your backend on your own domain is genuinely
first-party and lasts ~400 days. Anything a vendor sets from its own infrastructure is
capped at 7 days on Safari, including the CNAME'd "custom subdomain" setups other vendors
ask you to configure. No DNS record, no proxy.
It is off until you pass cookie, deliberately. Storing something on a visitor's
device needs consent or a strict-necessity basis under the ePrivacy Directive, and GDPR
legitimate interest does not substitute for it. You are the controller here.
Dedupe one inbox, not one address
bob+1@gmail.com, b.o.b@gmail.com and bob@googlemail.com are one mailbox.
if ($r->identity && User::existsByCanonical($r->identity->email_canonical)) { return reject('an account already uses this inbox'); }
Store both: mail the address they typed, dedupe on the canonical one.
Everything the key can reach
If an endpoint takes an API key, it is a method here.
score($event) |
score one action |
scoreWithCookie($event, $cookieHeader) |
the same, carrying the device identity |
checkEmail() checkIp() checkPhone() |
judge one identifier, no event recorded |
batchScore() batchCheck() batchLists() |
bulk, 1 quota unit per row, 1000 per call |
lists() listAdd() listRemove() |
allow / blocklists |
config() setConfig() |
your weights and thresholds |
label() |
report a real outcome |
forget() suppressions() |
GDPR erasure and its audit |
events() stats() |
read your own data |
graphSharing() |
opt into the cross-operator graph |
health() |
public liveness and intel dataset sizes |
Runnable versions in examples/.
Errors
Everything throws Kaidn\KaidnException, carrying the API's own message.
use Kaidn\KaidnException; try { $r = $client->score(['event' => 'signup', 'email' => $email]); } catch (KaidnException $e) { if ($e->getStatus() === 429) { notifyOps('Kaidn quota exhausted'); } throw $e; }
Network failures, timeouts, 429s and 5xx are retried automatically (2 extra attempts,
honouring Retry-After). A 4xx is not: a bad key fails identically the second time, and
retrying only spends quota and delays the error reaching whoever can fix it.
Fail open. A fraud vendor that can take down your signup form is a worse problem than the fraud:
try { $r = $client->score(['event' => 'signup', 'email' => $email]); } catch (KaidnException $e) { $r = null; // create the account. Do not let our outage become yours. }
Fields we have not named yet
Every response keeps what this version does not recognise, so a signal the API ships next week reaches code running the release you installed last year.
$r->get('a_field_added_after_this_release'); $r->device->get('some_new_signal'); $r->extra; // everything unrecognised
Requests work the same way: any extra key in the array you pass to score() is sent
untouched.
Configuration
new Client($apiKey, [ 'base_url' => 'https://api.kaidn.io', 'timeout' => 10, // seconds per attempt 'retries' => 2, // extra attempts on a transient failure 'cookie' => new CookieOptions(), // omit to keep cookie handling off ]);
Links
MIT