Integrate the fraud detection system
How a client product (the customer of our service) wires fraud detection into their signup and login flows.
1. Onboarding (we do this)
- Super admin creates the new tenant in /admin/clients with a name and slug.
- Optionally create a CLIENT_ADMIN user for them.
- Hand the tenant slug and admin credentials to the client.
The client logs into /client-admin, creates an API key under API keys, copies it once (it's hashed in our DB), and gives it to their backend.
2. Client-side: collect signals in the browser
On every signup and login form, run a small JS snippet that captures device fingerprint and behavioural signals. The data is submitted to the client's own backend (never directly to our API from the browser).
<!-- on the signup / login page -->
<script src="https://cdn.jsdelivr.net/npm/@fingerprintjs/fingerprintjs@4/dist/fp.umd.min.js"></script>
<script>
const startedAt = Date.now();
const mousePath = [];
const form = document.querySelector('form#signup');
let lastT = 0;
form.addEventListener('mousemove', (e) => {
const now = performance.now();
if (now - lastT < 50 || mousePath.length >= 300) return;
lastT = now;
mousePath.push({ x: e.clientX, y: e.clientY, t: Math.round(now) });
});
let keystrokeCount = 0;
form.addEventListener('keydown', () => keystrokeCount++);
form.addEventListener('submit', async (e) => {
e.preventDefault();
const fp = await FingerprintJS.load().then(f => f.get());
const gl = (() => {
const c = document.createElement('canvas');
const g = c.getContext('webgl') || c.getContext('experimental-webgl');
if (!g) return {};
const ext = g.getExtension('WEBGL_debug_renderer_info');
return ext ? {
vendor: g.getParameter(ext.UNMASKED_VENDOR_WEBGL),
renderer: g.getParameter(ext.UNMASKED_RENDERER_WEBGL),
} : {};
})();
const payload = {
email: form.email.value,
password: form.password.value,
fraudSignals: {
device: {
userAgent: navigator.userAgent,
screen: { width: screen.width, height: screen.height, pixelRatio: devicePixelRatio },
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
language: navigator.language,
webglVendor: gl.vendor,
webglRenderer: gl.renderer,
hardwareConcurrency: navigator.hardwareConcurrency,
deviceMemory: navigator.deviceMemory,
platform: navigator.platform,
clientFingerprint: fp.visitorId,
},
behavior: {
formStartedAt: startedAt,
formSubmittedAt: Date.now(),
mousePath,
keystrokeCount,
},
},
};
await fetch('/api/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
});
</script>3. Server-side: forward to fraud API and act on the score
Before creating the user record, the client's backend calls our ingest endpoint and decides what to do with the returned decision (ALLOW / CHALLENGE / BLOCK).
Node.js (TypeScript)
import express from 'express';
const router = express.Router();
const FRAUD_API = 'https://fraud.mandet.co/api/v1/events/ingest';
const FRAUD_KEY = process.env.FRAUD_API_KEY!;
router.post('/api/signup', async (req, res) => {
const { email, password, fraudSignals } = req.body;
// 1. Run fraud check
const r = await fetch(FRAUD_API, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': FRAUD_KEY,
},
body: JSON.stringify({
type: 'SIGNUP',
email,
externalUserId: email, // or your own user id
ip: req.ip, // pass real client IP
device: fraudSignals.device,
behavior: fraudSignals.behavior,
}),
});
const fraud = await r.json();
// 2. Branch on the decision
if (fraud.decision === 'BLOCK') {
return res.status(403).json({ error: 'Signup blocked', reason: 'risk' });
}
if (fraud.decision === 'CHALLENGE') {
// Stash partial signup, send email/SMS code, require completion later
await sendVerificationEmail(email);
return res.json({ challenge: 'email_verification', eventId: fraud.eventId });
}
// ALLOW
const user = await createUser({ email, password });
return res.json({ user, eventId: fraud.eventId });
});Python (Flask)
import os, requests
from flask import request, jsonify
FRAUD_API = 'https://fraud.mandet.co/api/v1/events/ingest'
FRAUD_KEY = os.environ['FRAUD_API_KEY']
@app.post('/api/signup')
def signup():
body = request.json
fraud = requests.post(FRAUD_API, json={
'type': 'SIGNUP',
'email': body['email'],
'externalUserId': body['email'],
'ip': request.remote_addr,
'device': body['fraudSignals']['device'],
'behavior': body['fraudSignals']['behavior'],
}, headers={'x-api-key': FRAUD_KEY}).json()
if fraud['decision'] == 'BLOCK':
return jsonify(error='blocked'), 403
if fraud['decision'] == 'CHALLENGE':
send_verification_email(body['email'])
return jsonify(challenge='email_verification', eventId=fraud['eventId'])
user = create_user(body['email'], body['password'])
return jsonify(user=user, eventId=fraud['eventId'])4. What the response contains
{
"eventId": "cmp43...",
"riskScore": 85,
"riskBand": "MONITOR", // ALLOW | MONITOR | BLOCK
"decision": "CHALLENGE", // ALLOW | CHALLENGE | BLOCK
"triggeredRules": [
{ "code": "DEVICE_REUSED", "label": "Device reused across accounts", "weight": 60 },
{ "code": "DISPOSABLE_EMAIL", "label": "Disposable email", "weight": 30 }
],
"allChecks": [ /* every rule evaluated, FIRED | PASS | DISABLED */ ],
"derivedSignals": { /* IP geo, device, behaviour, linkage counts, … */ }
}Recommended branching: ALLOW proceed silently; CHALLENGE add an email/SMS code or KYC step before granting access; BLOCK refuse and surface a generic error (don't reveal the fraud signal).
5. Tuning rules per client
Each tenant has its own rule weights and thresholds. The client's admin tunes them in /client-admin/rules:
- ALLOW / MONITOR / BLOCK score thresholds (default 79 / 119)
- Per-rule weight overrides (e.g. relax DISPOSABLE_EMAIL if your audience legitimately uses temp mail)
- Velocity limits (signups per IP / device per hour, accounts per IP / device)
- Minimum signup completion time
Global rule defaults (set by us) live at /admin/global-rules. Reputation lists (disposable email domains, high-risk ASNs, TOR exits) at /admin/reputation.
6. Security checklist
- Never call the ingest endpoint from the browser — the API key would be exposed. The browser sends signals to the client's own backend, the backend forwards to us with the key.
- Pass req.ip (or your X-Forwarded-For header) so geo and velocity work correctly.
- Rotate the API key periodically via /client-admin/api-keys. Revoking is instant.
- Don't reveal to end-users which rule triggered. A generic “please verify your email” or “contact support” is enough.