Docs menuAll pages, quickstarts and this page’s contents

Python server quickstart

Add Kaidn to a Python backend and have it blocking real abuse in about fifteen minutes. By the end you will have an endpoint that scores every signup, refuses datacenter and bot traffic, and catches one person opening a second account under a different spelling of the same inbox.

Examples use FastAPI because it is the common default for a new Python service. Nothing here depends on it: the client is a plain object and the same four lines work in Django, Flask or a worker with no web framework at all.

Before you start

  • Python 3.9 or later. Nothing newer is required.
  • A free Kaidn account. Register: 10,000 events a month, no card.
  • Optional but worth it: the browser tracker. Without it Kaidn scores the IP, email and phone. With it you also get the device signals, which is what makes steps 4 and 5 sharp. See installing the tracker.
01

Get your API key

Create an account and your first key appears immediately. There is no sales call and no card. The key is shown once, so copy it now.

Put it in a .env file, never in your source:

.env
# .env
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 raises immediately with an explanation, rather than letting it fail later as an opaque 401.

02

Set up your project

shell
mkdir kaidn-quickstart && cd kaidn-quickstart
python -m venv .venv && source .venv/bin/activate

pip install kaidn fastapi "uvicorn[standard]" python-dotenv

kaidn has no dependencies of its own. Everything else on that line is your web server, not ours. This runs in your signup path, so every dependency it carried would be one more thing that can break your deploy or turn up in your vulnerability scanner.

Create main.py:

main.py
import os
from dotenv import load_dotenv
from fastapi import FastAPI, Request, Response
from kaidn import KaidnClient, KaidnError

load_dotenv()

# Reads $KAIDN_API_KEY. Construct it once, at import, not per request.
client = KaidnClient()
app = FastAPI()
03

Score your first signup

One call. event is the only required field and the name is yours to choose. Send whatever else you already collect: the answer sharpens as you send more, and nothing is mandatory.

main.py
@app.post("/signup")
async def signup(request: Request, response: Response):
    body = await request.json()

    try:
        r = client.score(
            event="signup",
            ip=request.client.host,
            email=body["email"],
            device_id=body.get("kaidn_device_id"),   # from the browser tracker
        )
    except KaidnError:
        # FAIL OPEN. An outage in your fraud vendor must never become an
        # outage in your signup form.
        return {"ok": True}

    return {"verdict": r.verdict, "why": r.reason_text}

Run it, and post a signup:

shell
uvicorn main:app --reload

curl -X POST localhost:8000/signup -H 'content-type: application/json' \
  -d '{"email":"bob@gmail.com"}'

You get one of three answers back, 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.needs_reviewcreate 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.blockedrefuse, with a generic messagea specific error is a free debugging tool for the next attempt

Always set a timeout and 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.

main.py
    if r.blocked:
        response.status_code = 403
        # Do not name the signal. It teaches the next attempt.
        return {"error": "We could not create that account."}

    # Or act on one specific finding, whatever the total came to:
    if "datacenter_ip" in r.reasons or "headless_browser" in r.reasons:
        response.status_code = 403
        return {"error": "We could not create that account."}

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:

main.py
for c in r.checks:
    print(c.reason, c.weight, c.key, c.evidence)

# abusive_asn      35  abusiveAsn    {'asn': '16509'}
# datacenter_ip    45  datacenterIp  {'asn': '16509'}
# plus_addressing  10  emailPlusTag  {}

The full list of codes is in the reference, grouped by what they look 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 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.

main.py
import sqlite3

db = sqlite3.connect("users.db", check_same_thread=False)
db.execute("CREATE TABLE IF NOT EXISTS users (email TEXT, canonical TEXT)")

    # ...inside the handler, after scoring:
    canonical = r.identity.email_canonical if r.identity else body["email"]

    if db.execute("SELECT 1 FROM users WHERE canonical=?", (canonical,)).fetchone():
        response.status_code = 409
        return {"error": "An account already uses this inbox."}

    # Store BOTH: mail the address they typed, dedupe on the canonical one.
    db.execute("INSERT INTO users VALUES (?,?)", (body["email"], canonical))
    db.commit()

Which does this, verified against the live API:

output
POST /signup  {"email": "bob@gmail.com"}
  -> 200  {"ok": true}

POST /signup  {"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. This 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 the response gives you two counts, and they are not equally trustworthy.

fieldcountstrust
device.account_countaccounts on the raw fingerprintincludes collisions
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:

Needs kaidn 1.1.0 or later: pip install -U kaidn.

main.py
from kaidn import CookieOptions

client = KaidnClient(cookie=CookieOptions())   # off unless you ask

    r = client.score_with_cookie(
        event="signup",
        ip=request.client.host,
        email=body["email"],
        device_id=body.get("kaidn_device_id"),
        cookies=request.headers.get("cookie"),
    )
    if r.set_cookie:
        response.headers["set-cookie"] = r.set_cookie

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/signup -H 'content-type: application/json' \
  -d '{"email":"real.person@outlook.com"}'

# 2. disposable inbox -> the reasons name it
curl -X POST localhost:8000/signup -H 'content-type: application/json' \
  -d '{"email":"x9f2kq@mailinator.com"}'

# 3. the same inbox again, respelled -> 409 from YOUR check
curl -X POST localhost:8000/signup -H 'content-type: application/json' \
  -d '{"email":"real.person+promo@outlook.com"}'

Then open the dashboard: every call you just made 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.