Docs menuAll pages, quickstarts and this page’s contents

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 curl and json. 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.
01

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
# .env, or your host's environment panel
KAIDN_API_KEY=kdn_live_your_secret_key_here

Two 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.

02

Install it

shell
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.

shell
$ composer show
kaidn/kaidn-php 1.0.0  Official PHP client for Kaidn...
03

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.

signup.php
<?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:

shell
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.

verdicta sensible defaultwhy
$r->verdict === 'allow'create the accountnothing worth acting on
$r->needsReview()create it, withhold what is worth stealingif 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 messagea 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.

04

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.

signup.php
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:

signup.php
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.

05

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.

signup.php
$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:

output
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 two

The 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.

fieldcountstrust
$r->device->account_countaccounts on the raw fingerprintincludes collisions
$r->device->account_count_same_networkaccounts on that fingerprint AND that networkthe 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:

signup.php
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 1visit 2
resolutionprobabilisticdeterministic
resolution_rung21
collision_risk0.120.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.

06

Test it

Three inputs that should give three different answers. If they do, you are integrated.

shell
# 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.php
// 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
// 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.