Docs menuAll pages, quickstarts and this page’s contents
- Androidsoon
- iOSsoon
- React Nativesoon
- Fluttersoon
PHP server quickstart
Add Kaidn to a PHP backend and have it blocking real abuse in about fifteen minutes. By the end you will have a signup endpoint that scores every attempt, refuses datacenter and bot traffic, and catches one person opening a second account under a different spelling of the same inbox.
Examples are plain PHP, so they run on anything, including shared hosting. Laravel and CodeIgniter variants are at the bottom.
Before you start
- PHP 7.4 or later, with
curlandjson. Both are on effectively every host. 7.4 is supported on purpose: it is past end of life upstream and it is what a lot of the shops using this actually run. - Composer.
- A free Kaidn account. Register: 10,000 events a month, no card.
Get your API key
Create an account and your first key appears immediately. No sales call, no card. The key is shown once, so copy it now.
Read it from the environment rather than hard-coding it. On shared hosting where you cannot set environment variables, a PHP file outside the web root works just as well.
# .env, or your host's environment panel
KAIDN_API_KEY=kdn_live_your_secret_key_hereTwo kinds of key, and only one belongs on a server. The secret key (kdn_live_…) scores events and reads your data. The publishable tracker key (pk_live_…) is domain-locked, can only send fingerprints, and is meant to be visible in page source. Passing a pk_ key to this client throws immediately with an explanation, rather than failing later as an opaque 401.
Install it
composer require kaidn/kaidn-php
That pulls exactly one package. There are no Composer dependencies, only the curl and json extensions.
Guzzle is the PHP norm here and it is the wrong call for this: the client runs inside your signup and checkout path, so every package it dragged in is one more thing that can break your deploy or turn up in a security audit. The CI build asserts the dependency list is empty rather than trusting anyone to notice.
$ composer show kaidn/kaidn-php 1.0.0 Official PHP client for Kaidn...
Score your first signup
One call. event is the only required key and the name is yours to choose. Send whatever else you already collect: nothing is mandatory, and the answer sharpens as you send more.
<?php require __DIR__ . '/vendor/autoload.php'; use Kaidn\Client; use Kaidn\KaidnException; $client = new Client(); // reads KAIDN_API_KEY $body = json_decode(file_get_contents('php://input'), true); try { $r = $client->score([ 'event' => 'signup', 'ip' => $_SERVER['REMOTE_ADDR'], 'email' => $body['email'], 'device_id' => $body['kaidn_device_id'] ?? null, // from the browser tracker ]); } catch (KaidnException $e) { // FAIL OPEN. An outage in your fraud vendor must never become an // outage in your signup form. echo json_encode(['ok' => true]); exit; } echo json_encode(['verdict' => $r->verdict, 'why' => $r->reason_text]);
Run it and post a signup:
php -S localhost:8000 signup.php curl -X POST localhost:8000 -H 'content-type: application/json' \ -d '{"email":"bob@gmail.com"}'
You get one of three answers, and you decide what each one means. Kaidn never blocks anybody on your behalf.
| verdict | a sensible default | why |
|---|---|---|
$r->verdict === 'allow' | create the account | nothing worth acting on |
$r->needsReview() | create it, withhold what is worth stealing | if you are wrong a real user notices nothing; if you are right you removed the incentive without an appeals queue |
$r->isBlocked() | refuse, with a generic message | a specific error is a free debugging tool for the next attempt |
Always fail open. The client already retries a network failure, a 429 and a 5xx twice, honouring Retry-After, and never retries a 4xx because a bad key fails identically the second time. What it cannot decide for you is what to do when Kaidn is unreachable, and the answer is almost always: let the signup through.
Block bots and datacenter traffic
Real customers browse from a home ISP or a mobile carrier. A signup arriving from AWS is usually a script. Branch on the reason codes rather than the score: they are stable strings and they say what was actually found.
if ($r->isBlocked()) { http_response_code(403); // Do not name the signal. It teaches the next attempt. echo json_encode(['error' => 'We could not create that account.']); exit; } // Or act on one specific finding, whatever the total came to: if (in_array('datacenter_ip', $r->reasons, true) || in_array('headless_browser', $r->reasons, true)) { http_response_code(403); exit; }
Every verdict shows its work. key is the config key you would edit to retune that check, so a decision tells you how to change it next time. This is real output from the example in the repository:
foreach ($r->checks as $c) { printf("%-22s +%-4d %-16s %s\n", $c->reason, $c->weight, $c->key, json_encode($c->evidence)); } abusive_asn +35 abusiveAsn {"asn":"16509"} datacenter_ip +45 datacenterIp {"asn":"16509"} plus_addressing +10 emailPlusTag [] aliased_address +15 emailAliased []
The full list is in the reference, grouped by what each family looks at.
Stop one person opening many accounts
The hardest category, and the one most likely to make you punish a real customer. Two things do most of the work, and neither of them is a device ban.
Dedupe the inbox, not the address
bob@gmail.com, b.o.b@gmail.com and bob+promo@googlemail.comare one mailbox. Every scored event returns the canonical form, so you dedupe on that instead of the address they typed, without encoding any provider's rules yourself.
$db = new PDO('sqlite:users.db'); $db->exec('CREATE TABLE IF NOT EXISTS users (email TEXT, canonical TEXT)'); $canonical = $r->identity ? $r->identity->email_canonical : $body['email']; $dup = $db->prepare('SELECT 1 FROM users WHERE canonical = ?'); $dup->execute([$canonical]); if ($dup->fetch()) { http_response_code(409); echo json_encode(['error' => 'An account already uses this inbox.']); exit; } // Store BOTH: mail the address they typed, dedupe on the canonical one. $db->prepare('INSERT INTO users VALUES (?, ?)') ->execute([$body['email'], $canonical]);
Which does this, verified against the live API:
POST / {"email": "bob@gmail.com"}
-> 200 {"ok":true,"verdict":"allow"}
-> Set-Cookie: __kdn=v1.4be19598...; Max-Age=34560000; HttpOnly; SameSite=Lax; Secure
POST / {"email": "b.o.b+promo@googlemail.com"}
-> 409 {"error":"An account already uses this inbox."}
sqlite> SELECT email, canonical FROM users;
bob@gmail.com | bob@gmail.com <- one row, not twoThe dot trick is a Gmail behaviour, not a universal one. On a provider that treats dots as significant, a.b@x.com and ab@x.com really are two different mailboxes, and the canonical form correctly keeps them apart. That is why you take the canonical from the API rather than writing the rules yourself.
Use the device count you can defend
A browser fingerprint is not a person. On production traffic one iOS Safari fingerprint covers 2.30 different people, because a default iPhone is identical to another default iPhone. So you get two counts, and they are not equally trustworthy.
| field | counts | trust |
|---|---|---|
$r->device->account_count | accounts on the raw fingerprint | includes collisions |
$r->device->account_count_same_network | accounts on that fingerprint AND that network | the number you can defend to an angry customer |
Never hard-ban on a shared device. Families, flatmates, libraries, internet cafés and whole markets where a shared machine is normal all look exactly like a fraud ring if you only count devices. Withhold the thing that made the second account worth creating instead: do not pay the referral, do not grant the second trial, do not count the second entry.
Recognise the browser properly: rung 1
Everything above still identifies a browser by inference. Store the token Kaidn returns as a cookie on your own domain and the next visit is remembered instead:
use Kaidn\CookieOptions; $client = new Client(null, ['cookie' => new CookieOptions()]); // off unless you ask $r = $client->scoreWithCookie([ 'event' => 'signup', 'ip' => $_SERVER['REMOTE_ADDR'], 'email' => $body['email'], 'device_id' => $body['kaidn_device_id'] ?? null, ], $_SERVER['HTTP_COOKIE'] ?? null); if ($r->set_cookie !== null) { header('Set-Cookie: ' . $r->set_cookie, false); }
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 |
A network change is exactly what splits a fingerprint-derived identity in half. The token does not care. It is off unless you pass cookie, because storing something on a visitor's device needs consent or a strict-necessity basis under ePrivacy, and that is your call rather than a library's. The full reasoning.
Test it
Three inputs that should give three different answers. If they do, you are integrated.
# 1. clean signup -> allow curl -X POST localhost:8000 -H 'content-type: application/json' \ -d '{"email":"real.person@outlook.com"}' # 2. disposable inbox -> the reasons name it curl -X POST localhost:8000 -H 'content-type: application/json' \ -d '{"email":"x9f2kq@mailinator.com"}' # 3. the same inbox again, respelt -> 409 from YOUR check curl -X POST localhost:8000 -H 'content-type: application/json' \ -d '{"email":"real.person+promo@outlook.com"}'
Then open the dashboard: every call is there with the checks that fired, their weights and the raw evidence. That screen is the one that matters in an argument, because when a customer asks why they were treated unfairly you answer with a fact rather than a score.
Score the cashout too, not just the signup. Signup is where fraud starts; cashout is where it costs you. An account that looked fine in January can take money in March, and by then it has history for the reuse and velocity signals to work with.
Laravel and CodeIgniter
Nothing above depends on plain PHP. The client is an ordinary object, so it goes wherever you keep services.
Laravel
Bind it once as a singleton in a service provider, then inject it:
// AppServiceProvider::register() $this->app->singleton(Kaidn\Client::class, fn () => new Kaidn\Client( config('services.kaidn.key'), ['cookie' => new Kaidn\CookieOptions()] )); // then, in a controller or a FormRequest public function store(Request $request, Kaidn\Client $kaidn) { $r = $kaidn->scoreWithCookie([ 'event' => 'signup', 'ip' => $request->ip(), 'email' => $request->input('email'), ], $request->header('Cookie')); abort_if($r->isBlocked(), 403, 'We could not create that account.'); }
CodeIgniter 4
// app/Config/Services.php public static function kaidn($getShared = true) { return $getShared ? static::getSharedInstance('kaidn') : new \Kaidn\Client(getenv('KAIDN_API_KEY')); } // in a controller $r = service('kaidn')->score([ 'event' => 'signup', 'ip' => $this->request->getIPAddress(), 'email' => $this->request->getPost('email'), ]);
Construct the client once, not per request. It holds no connection state, so a singleton is safe and saves you rebuilding it on every hit.
Every endpoint an API key can reach is a method on the client, and each has a runnable example in the repository. Something here that did not work? support@kaidn.io. A quickstart whose examples fail is a bug, and worth reporting like one.