abusend: integration guide
abusend is a rule layer on top of the verification tools you already trust: Cloudflare Turnstile, Google reCAPTCHA, hCaptcha, your own SMS/OTP, e-mail, KYC or any other flow. You write readable rules on your business context (account age, amount, country, usage, velocity), and abusend decides for each request who sees which check, runs the flow (challenge, retry, step-up) through its SDKs, and records why. Every rule can be replayed against recent traffic and tried in Monitoring before it is Enforcing — a rule's state is the only monitor/enforce switch.
abusend also measures client signals (TLS/JA4, HTTP/2, a browser probe, a device id, IP data). They are cheap leading signals — hints, not verdicts. Every client-side check can be beaten by a determined attacker, and so can every signal; that is why abusend never decides on a signal alone by default and lets you combine hints with facts only your server knows.
Türkçe: INTEGRATION.tr.md · Rules contract: RULES.md · Privacy: PRIVACY.md
Contents:
Quickstart ·
Recipes (single-page apps and React,
Express, Next.js,
server-rendered forms, mobile and API,
SMS end to end, abusend's own checks, attributes and context,
configuration as code, Watch, webhooks and Slack, other languages) ·
Reference (concepts, user ids,
verdict request, verdict response,
decisions, onReview, 428 contract,
fields, missing values,
checks, check groups, flows, remember,
issue cap, monitor → enforce,
replay, testing, device id,
client IP, tokens,
failures, reason codes, errors,
rate limits, privacy,
checklist, FAQ)
browser (abusend.js) abusend edge your server
POST /v1/assess ───────────────────▶ collect + seal signals (no decision)
◀──────────────── {token}
your request + X-Abusend-Token ────────────────────────────────────────▶
◀── POST /v1/verdict {token, action, user, context}
built-ins → your rules, top to bottom
──▶ allow | review | deny | challenge [{check: "sms"}]
◀── 428 {challenges: […]} (only for challenge) ─────────────────────────
SDK runs the checks (widget, or your OTP UI), then retries with a fresh
token + X-Abusend-Challenge: ch_…[,ch_…] ──────────────────────────────▶ verdict again: rules run again
Quickstart
Three steps, about ten minutes. Nothing is blocked by your rules until you enforce
one: new rules — the two recommended rules every project starts with included — are
saved in Monitoring. Actions (login, topup) are just labels, created from
traffic; they have no mode of their own.
1. Browser
<script src="https://rep.example.com/sdk/v1/abusend.js" data-site-key="pk_live_…"></script>
<script type="module">
const res = await Abusend.fetch("/api/topup", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ amount: 100 }), action: "topup" });
</script>
The script reads its site key from data-site-key and talks to the edge it was loaded
from (data-endpoint overrides it). Abusend.fetch requests a fresh token, sends it as
X-Abusend-Token, and when your server answers 428 it runs the checks it lists, then
retries with a fresh token and X-Abusend-Challenge (up to 5 rounds). It never rejects because of
abusend. Plain <form data-abusend-action="login"> works too
(server-rendered forms); single-page apps use the loader snippet,
a bundler or React (recipe).
2. Server
import express from "express";
import { Abusend } from "@abusend/node";
import { userId } from "./user-id"; // HMAC of your user id — see "Choosing a user id"
const abusend = new Abusend({ secretKey: process.env.ABUSEND_SECRET_KEY!, endpoint: "https://rep.example.com" });
app.post("/api/topup", express.json(), abusend.protect({
action: "topup",
user: (req) => ({ id: userId(req.session.userId), attributes: { api_calls_total: req.session.apiCalls } }),
context: (req) => ({ amount: req.body.amount, currency: "USD" }),
onReview: "continue", // required: what "Your app decides" does on this route
}), (req, res) => res.json({ ok: true }));
protect() (Express) and guard() (Fetch API: Next.js, workers) read the token and
challenge headers, call POST /v1/verdict, and answer for you: 428 with the challenges,
403 on a block, and your onReview choice on "Your app decides". Outages and 429 map
to review, never to allow. With onReview: "continue", a review decided by one of your
rules (or by the token requirement) continues to your handler; a review caused by an
unfinished verification or a limit is answered 403 verification_incomplete — so a
client that skips a check never gets through with the quickstart settings
(onReview).
3. Your first rule, in Monitor
Panel → Rules → Templates → Dormant account, big first top-up → Save.
user.api_calls_total == 0 && context.amount >= 100 → Verify with SMS
- Saving declares the attributes the rule uses (
user.api_calls_total,context.amount, both numbers) and marks them "not seen in traffic yet" until your server sends them. - The rule is saved in Monitoring: it decides nothing and records what would have
happened (
would_decision: "challenge",would_challenge: {"mode": "any", "checks": ["sms"]}). Traffic shows it as "would have: Verify with SMS". The actiontopupwas created when its first verdict arrived; it is only a label. - Until your server sends an attribute the rule reads, the rule lists it under
unseen_attributesand the to-do rule reads an attribute never received points at it: a rule cannot match on a value it never gets. - Replay runs the rule over the last 7 days before you save and after: flows matched, decisions changed, users per day who would see the SMS check.
- When you are happy: connect the SMS check (recipe) and switch the rule to Enforcing. That is the only switch: from then on it decides on every action in its scope.
Keys and packages
| example | where it goes | |
|---|---|---|
| edge URL | https://rep.example.com |
script src, Abusend.configure({ endpoint }), Node new Abusend({ endpoint }) (or ABUSEND_ENDPOINT) |
| site key (public) | pk_live_… / pk_test_… |
the browser (data-site-key or Abusend.configure({ siteKey })) |
| secret key | sk_live_… / sk_test_… (panel → API keys, shown once) |
your server only, e.g. ABUSEND_SECRET_KEY |
test and live are separate projects with separate keys; develop against a test
project (testing). Live projects also need your site in Setup → Allowed
origins (https://shop.example.com or https://*.example.com); a live project without
one refuses every browser request (origin_not_configured).
The SDK packages are served by your edge, not by the public npm registry:
npm install https://rep.example.com/sdk/v1/abusend-node.tgz # server: @abusend/node
npm install https://rep.example.com/sdk/v1/abusend-browser.tgz # bundlers / React: @abusend/browser
Imports stay @abusend/node / @abusend/browser. For reproducible installs pin the
versioned file (abusend-node-<version>.tgz; the X-Abusend-SDK-Version response header
names the current one). 503 sdk_tarball_unavailable means the server was built without
the tarballs (operators: sdk/build-tarballs.sh, or use the Docker image).
Bundled browser SDK. The script served by the edge carries the probe's signing key.
A bundled copy (npm, build step) fetches it once from GET /v1/probe/pubkey
({"v":1,"pin":"…"}, a public key) and pins it; to skip that request, pin it yourself:
configure({ siteKey, endpoint: "https://rep.example.com", probePin }) (React:
<AbusendProvider probePin>). If the key cannot be fetched, the probe runs degraded and
the request carries probe.status = "failed" — a strong risk hint, never a broken page.
Content Security Policy. Allow the edge in script-src and connect-src, and the
providers of the widget checks you connect in script-src and frame-src:
| check | add to script-src and frame-src |
|---|---|
| Cloudflare Turnstile | https://challenges.cloudflare.com |
| Google reCAPTCHA (v3, v2, Enterprise) | https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/ https://www.recaptcha.net |
| hCaptcha | https://hcaptcha.com https://*.hcaptcha.com (also style-src and connect-src) |
| Friendly Captcha | script-src: https://cdn.jsdelivr.net/npm/@friendlycaptcha/ (the SDK loads one pinned version); frame-src: https://*.frcapi.com (global and EU API) |
| GeeTest v4 | script-src: https://static.geetest.com https://gcaptcha4.geetest.com (GeeTest answers through <script> / JSONP) and its fallback hosts https://static.geevisit.com https://gcaptcha4.geevisit.com https://gcaptcha4.gsensebot.com; also style-src and img-src for https://static.geetest.com https://static.geevisit.com, and connect-src / img-src for https://monitor.geetest.com. No frames. GeeTest publishes no CSP guidance: test the widget under your policy before enforcing it. |
| abusend's own checks (abusend_pow, abusend_hold, abusend_trace) | nothing: no script, no frame, no other host. Optional: worker-src blob: (or script-src blob: if your policy has no worker-src) lets the SDK solve the puzzle in Web Workers; without it the SDK solves it on the main thread in small slices (slower, the page stays responsive). |
The SDK only loads provider scripts from these hosts; anything else is reported as
script_blocked. App checks (SMS, e-mail, KYC, custom) load nothing: they are your UI.
Recipes
Each recipe fits one screen. They use one helper, userId(), from
choosing a user id.
Single-page apps and React
Loader snippet (no bundler). Code that calls Abusend.fetch() right away breaks when an
ad blocker, a CSP rule or a flaky network stops abusend.js (window.Abusend is
undefined). Paste this loader (loader.html in the browser SDK) into your page instead of
the <script src> tag; replace the edge URL and the site key in its last line (3000 is
the load timeout in ms):
<script>
/* abusend.js loader: loads the SDK and queues calls until it is ready. getToken()/execute()
never reject: if the script is blocked (ad blocker, CSP, offline) or not loaded within
`timeout` ms, they resolve "" and the verdict runs without a token. Abusend.fetch() then
sends the request without a token; onChallenge() handlers are kept until the SDK loads. */
(function (w, d, src, siteKey, timeout) {
if (w.Abusend) return;
var sdk = null, state = "loading", confs = [], queue = [], done = [], hs = {};
function run(c) {
if (!sdk) return c[1](c[0] && c[0].fallback !== undefined ? c[0].fallback : "");
sdk.execute(c[0]).then(c[1], function () { c[1](""); });
}
function settle(s) {
if (state !== "loading") return;
state = s;
var q = queue; queue = [];
for (var i = 0; i < q.length; i++) run(q[i]);
for (i = 0; i < done.length; i++) done[i]();
}
function token(o) {
return new Promise(function (resolve) {
var c = [typeof o === "string" ? { action: o } : o, resolve];
if (state === "loading" && !sdk) queue.push(c); else run(c);
});
}
var D = w.Abusend = {
version: "loader", getToken: token, execute: token,
configure: function (o) { if (sdk) sdk.configure(o); else confs.push(o); return D; },
protect: function (f, a) { f.setAttribute("data-abusend-action", a || "submit"); return f; },
status: function () { return sdk ? sdk.status() : state; },
challengeStatus: function (a) { return sdk ? sdk.challengeStatus(a) : { state: "none" }; },
onChallenge: function (k, h) {
if (sdk) return sdk.onChallenge(k, h);
hs[k] = h;
return function () { if (sdk) sdk.onChallenge(k, null); else if (hs[k] === h) delete hs[k]; };
},
fetch: function (u, i, o) { return D.ready.then(function () { return sdk ? sdk.fetch(u, i, o) : w.fetch(u, i); }); },
ready: new Promise(function (r) { done.push(r); }),
_loaded: function (api) {
sdk = api; D.version = api.version; D.AbusendError = api.AbusendError;
for (var i = 0; i < confs.length; i++) api.configure(confs[i]);
for (var k in hs) if (hs[k]) api.onChallenge(k, hs[k]);
settle("ready");
}
};
var s = d.createElement("script");
s.src = src; s.async = true; s.setAttribute("data-site-key", siteKey);
s.onerror = function () { settle("unavailable"); };
setTimeout(function () { settle("unavailable"); }, timeout);
(d.head || d.documentElement).appendChild(s);
})(window, document, "https://rep.example.com/sdk/v1/abusend.js", "pk_live_…", 3000);
</script>
It defines window.Abusend at once and queues calls until the SDK has loaded.
getToken() / execute() never reject; if the script is blocked or late,
Abusend.fetch() sends your request without a token (no checks can run then), and
onChallenge() handlers registered early are handed to the SDK when it loads. Under a
strict CSP give the inline script your page's nonce.
Bundlers (npm). The module build does not know its own URL: configure it once.
import { configure, fetch as abusendFetch, onChallenge } from "@abusend/browser";
configure({ siteKey: "pk_live_…", endpoint: "https://rep.example.com" });
onChallenge("sms", async (challenge, { signal }) => { /* your OTP dialog */ });
const res = await abusendFetch("/api/topup", { method: "POST", body: JSON.stringify({ amount: 100 }), action: "topup" });
Without configure() nothing is sent: execute() resolves "", getToken() rejects with
not_configured, and the SDK warns once in the console.
React. Wrap the app in AbusendProvider (or pass siteKey and endpoint to
useAbusend(), or call configure() once at startup — without any of these the hook's
getToken() resolves "" and warns once):
import { AbusendProvider, useAbusend, useOnChallenge } from "@abusend/browser/react";
export function App() {
return (
<AbusendProvider siteKey="pk_live_…" endpoint="https://rep.example.com">
<TopUpForm />
</AbusendProvider>
);
}
function TopUpForm() {
const { fetch, ready, challenge } = useAbusend({ action: "topup" });
useOnChallenge("sms", (ch, { signal }) => openOtpModal(ch, { signal })); // resolve = retry now; reject = give up
const verifying = challenge.state === "running" || challenge.state === "interactive";
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
const res = await fetch("/api/topup", { method: "POST", body: JSON.stringify({ amount: 100 }) });
if (res.status === 428 || res.status === 403) showMessage("We could not verify this request. Please try again.");
}
return <form onSubmit={onSubmit}>…<button disabled={!ready || verifying}>{verifying ? "Verifying…" : "Top up"}</button></form>;
}
useAbusend({ action }) returns { fetch, getToken, ready, status, challenge }: fetch is
Abusend.fetch with the hook's action as the default; challenge is the last challenge of
that action (challenge.state: none, running, interactive, completed, passed,
failed, incomplete, …), so two forms on one page don't both show "Verifying…".
useOnChallenge(check, handler) registers an app-check handler while the component is
mounted. The SDK is a singleton: configuration and handlers are global.
Express
app.set("trust proxy", 1); // req.ip = the client behind your load balancer (sent as client_ip)
app.post("/api/topup", express.json(), abusend.protect({
action: "topup",
user: (req) => ({ id: userId(req.user.id), attributes: { api_calls_total: req.user.apiCalls,
registered_at: req.user.createdAt.getTime() } }),
context: (req) => ({ amount: req.body.amount, currency: req.body.currency }),
checks: { sms: async (challenge, req) => { // runs once per newly issued challenge (never on a re-returned one)
req.session.otpChallenge = challenge.id;
await sms.sendOtp(req.user.phone);
} },
onReview: "continue",
}), topupHandler);
Every option also accepts a function of the request (sync or async). The handler runs
only for allow and for reviews your onReview continues; the verdict is on
req.abusend.
Next.js route handlers
guard() works on the Web Request / Response API (Next.js App Router, Remix, Hono,
workers). It reads headers only and never consumes the request body.
// app/api/topup/route.ts
export async function POST(request: Request) {
const session = await getSession(request);
const body = await request.json();
const blocked = await abusend.guard(request, {
action: "topup",
user: { id: userId(session.userId), attributes: { api_calls_total: session.apiCalls } },
context: { amount: body.amount, currency: "USD" },
checks: { sms: async (challenge) => { await saveOtpChallenge(session, challenge.id); await sms.sendOtp(session.phone); } },
onReview: "continue",
});
if (blocked) return blocked; // 428 challenge · 403 block · your review response
return Response.json(await topup(session, body));
}
onReview can also be a function (request, verdict) => Response | null for your own
step-up; return null to continue (onReview). abusend.verdictOf(request)
returns the verdict afterwards. Without guard(), call abusend.verdict(…) and answer a
challenge decision with Abusend.challengeResponse(verdict) (the 428 Response;
Abusend.challengeBody(verdict) gives { status, headers, body } for other frameworks).
Server-rendered forms
With the SDK (recommended). Mark the form; the SDK submits it with
Abusend.fetch(form.action, { method, body, action }) — URL-encoded by default, FormData
for enctype="multipart/form-data", the fields in the query string for GET — runs any
check the server asks for, and then follows the final response: a redirect is followed
(location.assign), any other response replaces the document. A cancelable
abusend:response event on the form (event.detail.response) lets you handle it yourself.
<form action="/login" method="post" data-abusend-action="login"> … </form>
Answer successful POSTs with a redirect (303 See Other): replacing the document does
not run inline scripts or update the history, so it is only the fallback. Your server
uses protect() / guard() exactly as in the recipes above.
data-abusend-native opts a form out: it is submitted natively with a hidden
abusend_token field (data-abusend-field renames it) and cannot run checks (a challenge
there ends as not completed → the check's on_incomplete). protect() reads that body
field by itself when there is no X-Abusend-Token header (tokenField renames it); with
guard(), pass token: (req) => … from the form you parsed.
Without JavaScript. Use app checks only and make the action token-optional (Actions → topup → Token: optional), because there is no browser SDK to mint a token:
POST /topup→abusend.verdict({ action: "topup", user, context, clientIp }).decision: "challenge"(challenges: [{check: "sms", …}]) → store the ids ofchallengesin the session, send the OTP, redirect to/verify./verify→ the user enters the code → your server checks it →abusend.challenges.result(id, "passed" | "failed", { userId }).- Redirect back and repeat the verdict with
challengeIds: ids(within 5 minutes of the pass) → the rules run again;passed("sms")is true →allow(or the next step).
Token-less challenges are bound to the user, so always send user.id here.
Mobile apps and API-only clients
Native apps and API clients have no browser token. Make their actions
token-optional: the rules then run with token.status = "missing",
probe.status = "none" and risk.level = "unknown" (IP data comes from client_ip).
Use app checks — widget checks need a browser.
- Your server uses
guard()/protect()as usual; a challenge becomes a 428 with{"error": "challenge_required", "challenges": [{…}]}and headerAbusend-Challenge: 1. - The app runs every entry of
challenges(1–3; several only when a rule asks for checks "all at once"): it readscheck(for examplesms) andid, shows its own OTP screen, and posts the code to your server, which reports it withabusend.challenges.result(id, …, { userId }). - The app retries the original request with header
X-Abusend-Challenge: ch_…[,ch_…](every id of the 428, comma-separated);guard()/protect()forward them aschallenge_ids. reissued: trueon a challenge means the same verification is still pending (a retry before the result): show the OTP screen again without sending a new code. Offer a "resend code" button on that screen: if the response that issued the verification was lost, the retry is alreadyreissuedand no code went out.
Keep one action per surface (login for the web, app_login for the app) so the token
requirement and the rules can differ.
SMS verification end to end
-
Connect the check. Panel → Checks → Add → SMS, key
sms. Defaults for app checks:on_incomplete: deny,on_error: review,timeout_seconds: 600,max_issued_per_hour: 5,remember_seconds: 0. abusend never sends the SMS: it asks for the check and records the outcome your server reports. -
Rule. Any rule with the outcome Verify with SMS, e.g. the dormant-account template of the quickstart.
-
Server: send the code in the
checks.smshook ofprotect()/guard(). It runs only when a challenge is first issued (challenge.reissuedis false), so retries never resend. A "resend code" button is your own endpoint. -
Browser: show your UI.
Abusend.onChallenge("sms", async (challenge, { signal }) => { await openOtpModal({ challengeId: challenge.id, signal }); // resolve = retry now; reject / abort = give up }); -
Server: verify and report. Report
failedonly after your own retry limit — a mistyped code is not a failed check:app.post("/otp/verify", express.json(), async (req, res) => { const id = req.session.otpChallenge; // or req.body.challengeId (the userId binding stops cross-user use) const ok = await sms.checkOtp(req.user.phone, req.body.code); const tries = (req.session.otpTries ??= {}); tries[id] = (tries[id] ?? 0) + 1; if (!ok && tries[id] < 3) return res.status(400).json({ retry: true }); // let the user type again try { await abusend.challenges.result(id, ok ? "passed" : "failed", { userId: userId(req.user.id) }); } catch (e) { if (e.code === "challenge_resolved") return res.status(409).json({ restart: true }); // already abandoned or expired throw e; } res.json({ ok }); }); -
Retry. The modal resolves,
Abusend.fetchretries with a fresh token andX-Abusend-Challenge, and the verdict runs the rules again: SMS passed → the next rule orallow; failed → the check'son_fail(Block).
If the user closes the modal, the handler rejects and the SDK reports the verification as
abandoned (first report wins: a later passed gets 409 challenge_resolved). The next
verdict of that user for the action takes up the unfinished verification and answers the
check's on_incomplete (Block for app checks); the attempt after that starts a new flow,
within the issue cap. A verification nobody reports is
incomplete once timeout_seconds pass.
abusend's own checks
Three widget checks that abusend verifies itself: no provider account, no site key, no secret, no third-party script, and nothing sent to anyone but your abusend edge.
| provider | what the visitor does | when to use it |
|---|---|---|
abusend_pow |
nothing visible: the SDK solves a proof-of-work puzzle in the background | a first step that costs every attempt CPU time, more as the risk grows |
abusend_hold |
presses and holds two dots in order in the verification dialog | a quick visible step (mouse, touch or pen) |
abusend_trace |
moves the pointer or a finger around a box for 3–5 s | a visible step with more movement to score |
- Connect. Panel → Checks → Add → one of the three. Nothing to paste: the
check is ready at once (Test answers ok; there is no provider to reach). Set the
puzzle size: by the verdict's risk level (default 15 / 18 / 20 / 22 for low / medium /
high / critical; each step doubles the work; no browser token counts as low), fixed, or
off (hold and trace only). The form estimates the solve time in your browser (a phone
about 4× slower) and lets you try the puzzle. For hold and trace pick the
strictness (
lenient·normal·strict) and try the task in the form's preview: it shows the score and what lowered it. - Rule. Verify with the check, like any widget. It needs the browser SDK 3.3.0 or
later (older SDKs report the mode as
widget_error: the check's "cannot verify" outcome, never Allow). - Attempts. A hold or trace that did not convince starts over with a short hint (at
most 3 attempts per verification). After the last one it fails (
not_human_like→on_fail) or, when the task was not done, is not completed (task_incomplete→on_incomplete). A wrong puzzle answer fails at once (pow_invalid); the same recording sent again fails astoken_reused. - Keyboard-only visitors cannot do hold or trace. Where they must get through, don't
make a visible task the only way: pair it with a check they can do (a One of these
group with SMS, say — the pick is random by weight, so a visitor may get either), keep
on_incomplete: reviewso a task that could not be done reaches your app, or useabusend_pow, which needs no interaction.
Like every client-side check these can be beaten: a solved puzzle proves spent CPU, not a person, and movement can be recorded or synthesised. The movement score comes from heuristics — a hint that the strictness turns into pass / fail. The pointer events on the task's box go to your edge only; they are scored there and not stored (the verification keeps its status, detail, score and a one-day replay hash).
Wire format (for your own client): the challenge carries mode (the provider name)
and params — pow: {alg: "sha256", seed, bits, puzzles, work} (for each i <
puzzles, find n with SHA-256("<seed>.<i>.<n>") starting with bits zero bits;
absent when the check has no puzzle) and task: {type: "hold", targets: [{x, y}, {x, y}], hold_ms} (fractions of the box) or {type: "trace", duration_ms}. Answer with
POST /v1/challenges/{id}/verify {"site_key": "pk_…", "solution": {"pow": [n, …], "telemetry": {"v": 1, "w", "h", "dpr", "pt": "mouse" | "touch" | "pen", "ev": [[t, x, y, type], …], "ut": 0}}} (ev: at most 3000 events, t in ms since the box was shown, x / y in
CSS px inside it, type 0 move · 1 down · 2 up; ut: events your page dispatched itself).
While attempts remain, an attempt that did not pass answers {"status": "pending", "retry": true, "missing": "hold_short"} — missing is presses, target_missed,
hold_short, trace_short or trace_small, omitted when the task was done but did not
convince; run the task again.
Declaring attributes and sending context
user.attributesare facts about the account; they are stored on the end user and merged on every call (nulldeletes a key).contextdescribes this request (amount, currency, plan) and is stored only with the verdict.- Both are typed:
number,string,boolortimestamp(unitms,soriso). An attribute is declared when a template or the rule builder declares it on save, when you set it under Setup → Context, or when abusend has observed it in traffic (the type is guessed; check the guess). A raw expression using an undeclared attribute is refused withattribute_undeclared(and a one-click declare in the panel). - Send the time itself, not a precomputed age:
registered_at: user.createdAt.getTime()(milliseconds), thendays_since(user.registered_at) < 7. - A value of the wrong type is read as missing and counted on the attribute
("
context.amountsent as string 3× today"); it never fails the request. Changing the type of, or deleting, an attribute a rule uses is refused (attribute_in_use). - Limits: at most 32 keys each; keys
^[A-Za-z_][A-Za-z0-9_]{0,63}$; values string (≤ 512 characters), number, bool ornull;context≤ 4 KB. User attribute keys may not shadow built-in fields (id,present,age_days,verdicts_1h,devices_30d,new_device,device_age_seconds,challenges_issued_1h). - Facts, not identities: no e-mail addresses, phone numbers, names or national ids (privacy notes).
Events
Some facts abusend cannot see: a top-up that went through, a payment, a withdrawal. Your server reports them after they happened, and rules count them per user over a window.
// after the payment succeeded
const r = await abusend.events.track({
userId: hmac(session.userId), // the same opaque id as in your verdicts
type: "topup", // ^[a-z0-9_]{1,32}$
value: payment.amount, // summed by event_sum
id: payment.id, // idempotency: a retried event is stored once
});
// never throws: r.ok === false carries r.error (also sent to onError)
curl -sS https://rep.example.com/v1/events \
-H "Authorization: Bearer $ABUSEND_SECRET_KEY" -H "Content-Type: application/json" \
-d '{"events": [{"user_id": "u_5f1c9a…", "type": "topup", "value": 250, "id": "pay_81f3",
"occurred_at": "2026-09-28T09:30:00Z"}]}'
# → {"accepted": 1, "duplicates": 0}
Rules on them (windows "1h", "24h", "7d", "30d" only):
event_count("topup", "1h") >= 5 → Verify with SMS (5 top-ups in an hour already)
event_sum("topup", "24h") > 2000 → Your app decides (more than 2000 topped up in 24 h)
days_since(event_last("withdraw")) < 1 → Verify with SMS (a withdrawal in the last day)
Both first two are templates (Many top-ups in a short time, High total in 24 hours), saved in Monitoring like every new rule.
POST /v1/events(secret key):{"events": [{user_id, type, value?, properties?, occurred_at?, id?}]}, 1–100 per call, all or nothing: one invalid event refuses the batch, and the error names it (details.index,details.field).user_id: your opaque user id (1–256 characters); an id abusend never saw creates the user, as a verdict does.value: a finite number within ±1e15 (absent adds 0 toevent_sum; a larger one is refused withevent_value_out_of_range).properties: up to 32 scalar values, 4 KB — stored, not read by rules.occurred_at: RFC 3339, default the time abusend receives it; at most 30 days old (rules look back 30 days at most) and not in the future (5 minutes of clock skew are stored as received) — elseinvalid_occurred_at.id(1–128 printable characters): an event whose id was already reported in the project (in the last 31 days) is a duplicate — counted induplicates, not stored again. Use your own id (payment, order) so your retries count once; the Node SDK assigns a random id when you give none, which protects its own retries only.- Values:
event_count(type, window)counts the user's events of the type withoccurred_atin the window;event_sum(type, window)adds theirvalue;event_last(type)is the newestoccurred_atwithin 30 days, or missing (comparisons with it are false;has(event_last("topup"))tests it). Without a user in the verdict, counts and sums are 0. The window is yours to pick, in minutes, hours or days from"1m"to"30d":"5m","90m","2h","3d". - Attempts need no events:
attempts("topup", "5m")is how many times the user tried the action in the window, this request included — every verdict call counts, whatever its decision, soattempts("topup", "5m") > 3→ Block stops the fourth top-up in five minutes. The retry after a verification is the same attempt (it does not count again). Without a user it is 0. - Only the event types your rules read are computed, in one query per verdict; the values are stored with the verdict so a replay shows what the rule saw.
- A rule reading a type no event was ever received of can't fire yet: the panel shows
event.topupin its health and the to-do can't fire yet. Setup → step 5 lists the event types received (with a label such as "Top-ups" the rule sentences use). - Events are kept for the project's event retention (default 90 days, up to 3 years; panel → Data retention; rules read the last 30 days), and deleted with the user (erasure). Limits in rate limits.
Shared counters
Everything above is one user's. A counter adds up across all users — nothing to send:
it counts the attempts at an action (every verdict call, the retry after a verification not
again) or the events your server already reports, over a recent window, optionally per
group. Define it in the panel (Protect → Counters) or in configuration as code,
then read it in a rule with counter("key", "1h") (window up to "24h"): the total of the
request's group.
Payments per country: counts attempts at "payment", per ip.country
ip.country == "SG" && counter("payments_by_country", "1h") > 100 → Verify with SMS
Hotmail purchases: sums the value of "purchase" events, only when user.email contains "hotmail"
user.email contains "hotmail" && counter("hotmail_purchases", "15m") > 50000 → Verify with a captcha
- A counter's filter chooses what it counts; repeat it in the rule when only those
requests should be affected (without
ip.country == "SG"the first rule would ask everyone once Singapore is busy). Per (group_by) gives each value its own total —ip.country,email_domain(user.email)— and the rule reads the request's group. - Counters on events know
user.*only (an event carries no request): send the attribute (user.email) with your verdicts or users API so the event's user has it. - Totals are a few seconds behind (every server writes them every 5 s) and a counter counts from when it is created; changing what it counts starts it from zero. A verdict never counts itself.
Configuration as code
Keep a project's configuration in git, review changes in a pull request, and copy a tested setup from your test project to the live one: panel → Project settings → Configuration as code [admin].
apiVersion: abusend/v1
kind: ProjectConfig
checks:
- {key: captcha, provider: turnstile, config: {site_key: 0x4AAA…}}
- {key: sms, provider: sms, max_issued_per_hour: 5}
rules: # evaluated in this order
- key: big_new_account
name: Big purchase from a new account
when: hours_since(user.registered_at) < 1 && context.amount_usd > 50
then: challenge
checks: {mode: sequence, items: [{check: captcha}, {check: sms}]}
actions: [pay]
state: monitoring # monitoring | enforcing | off
- Export downloads the whole document: settings and allowed origins, actions, attributes, event type labels, checks, list definitions, your own fingerprints, signal weights and the rules in order. It never contains secrets, API keys, list entries or traffic.
- Import is a plan, then an apply. The plan shows what would be created, changed, deleted or reordered, and runs the whole import against your project without saving it, so it reports every error a real change would (a condition using an undeclared attribute, a rule asking for a check that does not exist, …). Apply saves it all at once or not at all, and only if nobody changed the project since the plan.
- Sections you leave out are untouched; an item you list replaces the stored one;
settings merge into the current ones. "Delete what the file doesn't list" is
off by default. Give rules a
key: it matches them across projects (and is their reason code). - Rules that would start Enforcing are listed and need your confirmation. A new Turnstile / reCAPTCHA / hCaptcha / Friendly Captcha / GeeTest check needs its secret: type it in the plan, or set it on the Checks page afterwards. Importing into a live project skips test-only settings (forced decision) and http / localhost origins, with a warning.
The API behind it (a personal access token, abp_…, works too):
GET /api/panel/v1/projects/{p}/config (YAML), POST …/config/plan {yaml, prune} and POST …/config/apply {yaml, prune, base, confirm_enforce, secrets}.
Watch ("Nöbet")
Watch keeps an eye on your traffic around the clock and tells you within minutes when something looks unusual, then lets your own AI assistant investigate and report. It is off by default: panel → Monitor → Watch [admin]. It needs nothing in your code.
- What it notices: a spike in attempts (against the same time on the previous days, or the last two hours for a young project), a country or network that suddenly holds a large share, a jump in failing verifications, a change in the share of blocked or verified attempts, and a shared counter close to the threshold of a rule you enforce. Sensitivity (low · normal · high) scales the thresholds. A new finding sends an alert; one that doubles sends another; an ongoing one a short update every 30 minutes; the end a "back to normal" note. When an enforcing rule that reads a counter starts applying (the counter is at or over its threshold, for example many IP addresses in one burst), Watch sends one note per rule, not one per value: it names the rule, what it does, the counter, how many IP addresses (users, devices, …) it now applies to and up to five examples. It is a note, not an alarm: no investigation, nothing to do unless the traffic is unexpected. Updates come only when the number grew or a new value appeared, after 30 minutes, then 1 hour, 2 hours, … (at most once a day), at most 6 notes an hour per project (the ones over the limit are listed on the Watch page, marked as not sent, and counted in the next note); one more note says when the rule stops applying.
- The investigation uses the LLM key of your organization's assistant (panel → Organization settings). It reads your traffic with the same tools as the assistant and writes a report. It is a conversation in the assistant drawer of the admin who switched Watch on; open it from the Watch page. Without an assistant you still get the alerts.
- What it may do alone: add a Monitoring rule (it only records what it would do; at most five a day) and create a new counter. Nothing else. Every other change it wants — enforcing a rule, editing or deleting one, lists, action settings, risk thresholds — waits in the report as a proposal until that admin approves it in the assistant; nothing it reads from your traffic can approve anything. Watch never changes a decision on its own.
- Limits: at most 4 investigations per hour and 24 per day per project (both settable), each at most 3 minutes; alerts are not limited. Switching Watch off stops it at once.
- Notifications: e-mail to the team members you choose (needs a mail server set
by the platform operator), and the webhook events
watch.alertandwatch.report(below). They carry numbers, the action name, a country code or an ASN and the model's summary. A note about a rule that applies to a counter's values also names up to five of those values as examples, and they can be IP addresses, end-user ids or device ids when the counter is grouped by them (the Watch page keeps up to 50 per note); no e-mail addresses or other data of an end user. - Your data and your provider: during an investigation the assistant's tool results — excerpts of your traffic: user ids, IP addresses, device ids, attributes — go to the LLM provider you configured, exactly as when you chat with the assistant. See the privacy notes.
- The settings are part of the configuration as code (not the owner, the e-mail recipients or the language, which belong to one environment). Rules Watch created carry a Watch badge on the Rules page.
Webhooks and Slack
abusend can deliver reports and alerts to a webhook or a Slack channel: panel →
Project settings → Notifications [admin], up to five destinations per project.
Each has an https:// URL (stored encrypted, shown masked: a Slack incoming-webhook
URL is a secret), a format, the events it receives and a signing secret
(whsec_…) that is shown once when you create it. Send test queues a test
event so you can check the endpoint and your verification code.
Events: watch.report (a report), watch.alert (an anomaly alert), test.
JSON format — one POST per event, Content-Type: application/json:
{
"event": "watch.alert",
"id": "0198f1c2-7d3e-7a10-9c55-2f4c1b8e6a01",
"created_at": "2026-09-28T10:00:00Z",
"project": {"id": "0198a0c4-…", "name": "Acme wallet"},
"data": {"project": "Acme wallet", "title": "login: attempts are 10× the usual", "detail": "In the last 15 minutes, \"login\" received 6180 attempts (about 412 per minute); the usual level at this time is about 38 per minute (median of the same time on the previous 7 days).", "current": "412 / min", "baseline": "38 / min", "url": "https://panel.example.com/p/0198a0c4-…/watch"}
}
| header | value |
|---|---|
Abusend-Event |
the event name |
Abusend-Delivery |
the delivery id (id in the body); the same for every retry: use it to ignore duplicates |
Abusend-Timestamp |
Unix seconds, set again on every attempt |
Abusend-Signature |
v1= + hex HMAC-SHA256 of <timestamp>.<raw body>, keyed with the secret (the whole whsec_… string) |
data of the events (numbers, the action, a country code or an ASN and the
model's words; a note about a counter also carries up to five values of what the counter
is grouped by, which can be IP addresses, end-user ids or device ids; never e-mail
addresses; the text is in the language of the person who last saved the
Watch settings):
| event | data |
|---|---|
watch.alert |
project (name), title (one line: what spiked; a status update starts "Still ongoing:", a growing one "Growing:", the end "Back to normal:"), detail (a sentence or two with the numbers), current and baseline (with their unit, e.g. "412 / min", "45%"), url (the Watch page; empty when the panel address is not configured) |
watch.report |
project, window ("2026-09-28 14:02 UTC": when the investigation ran), summary (the assistant's report in Markdown, at most 1200 characters: headings, lists, **bold**, `code`, links), summary_format (always "markdown": render it, or show it as text), findings ([{title, detail}]: what the scanner found), proposals (how many proposed changes wait for the owner), url (the Watch page with ?run=<id>) |
test |
message |
An alert's data may also carry the optional keys below; each is left out when
empty, and baseline may be empty.
| key | meaning |
|---|---|
group |
the value the finding belongs to (an IP address, a user id, a country, …), set when a note or an approaching finding is about exactly one value |
group_label |
what group is, e.g. "IP address" |
baseline_label |
what baseline means when it is not the usual level, e.g. "Threshold" |
current_label |
what current means, e.g. "Highest now" |
kind |
"note" for a message that is not an alarm (a rule now applies, or no longer applies), "alert" for an anomaly; a note's title has no "Alert:" prefix |
lang |
the language of the texts, "en" or "tr" |
rule |
the rule the note is about, e.g. Rule "Top-up IP burst" |
rule_action |
what the rule does, e.g. "Verify with captcha · Enforcing" |
counter |
the counter in one line, e.g. "topup_ip_burst · per IP address · last 15 minutes" |
count |
a number: how many values the note is about (for the end of an episode, the most it applied to at once); one note covers all the values of a rule |
count_label |
what count counts, e.g. "IP addresses" |
examples |
up to five {group, value} (the highest values, or the ones that are new since the last note; value is text) |
examples_label |
the heading of examples, e.g. "Highest values" |
next |
what to do and when the next note comes |
A note about a rule that applies to a counter's values, for example:
"data": {"project": "Acme wallet", "kind": "note", "lang": "en", "title": "Rule \"Top-up IP burst\" now applies to 143 IP addresses", "detail": "The enforcing rule \"Top-up IP burst\" now applies to 143 IP addresses. …", "current": "1,377", "current_label": "Highest now", "baseline": "≥ 20", "baseline_label": "Threshold", "rule": "Rule \"Top-up IP burst\"", "rule_action": "Verify with captcha · Enforcing", "counter": "topup_ip_burst · per IP address · last 15 minutes", "count": 143, "count_label": "IP addresses", "examples": [{"group": "198.51.100.81", "value": "1,377"}], "examples_label": "Highest values", "next": "This is a note, not an alarm: …", "url": "https://panel.example.com/p/0198a0c4-…/watch"}
Alerts and reports follow the Send alerts / Send reports switches of the Watch settings (they apply to e-mail and webhooks alike).
Verify every delivery: recompute the HMAC over the raw request body (not a re-serialised copy), compare in constant time, and reject timestamps older than a few minutes (replays). Node.js (Express):
import crypto from "node:crypto";
import express from "express";
const secret = process.env.ABUSEND_WEBHOOK_SECRET; // whsec_…
const app = express();
// The signature covers the raw bytes: read the body raw, parse it afterwards.
app.post("/abusend-webhook", express.raw({ type: "application/json" }), (req, res) => {
const ts = req.get("Abusend-Timestamp") ?? "";
const sig = req.get("Abusend-Signature") ?? "";
const want = "v1=" + crypto.createHmac("sha256", secret).update(`${ts}.`).update(req.body).digest("hex");
const fresh = Math.abs(Date.now() / 1000 - Number(ts)) < 300;
const ok = fresh && sig.length === want.length && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(want));
if (!ok) return res.status(400).end();
const delivery = JSON.parse(req.body.toString("utf8"));
// delivery.event: "watch.report" | "watch.alert" | "test"; delivery.id dedupes retries.
res.status(204).end();
});
Go:
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"math"
"net/http"
"strconv"
"time"
)
// verify checks Abusend-Signature over "<timestamp>.<raw body>".
func verify(secret string, r *http.Request, body []byte) bool {
ts := r.Header.Get("Abusend-Timestamp")
n, err := strconv.ParseInt(ts, 10, 64)
if err != nil || math.Abs(float64(time.Now().Unix()-n)) > 300 {
return false
}
m := hmac.New(sha256.New, []byte(secret))
m.Write([]byte(ts + "."))
m.Write(body)
want := "v1=" + hex.EncodeToString(m.Sum(nil))
return hmac.Equal([]byte(want), []byte(r.Header.Get("Abusend-Signature")))
}
func handler(secret string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil || !verify(secret, r, body) {
http.Error(w, "bad signature", http.StatusBadRequest)
return
}
// decode body; answer 2xx quickly
w.WriteHeader(http.StatusNoContent)
}
}
Slack destinations get an incoming-webhook body instead (text plus blocks: a
header, the summary, the project and event, and a button to the panel); the Markdown
summary is converted to Slack formatting (*bold*, <url|text>, • bullets) and
split into a few sections when long, ending with a link to the report if it does not
fit. It is signed with the same headers, which Slack ignores.
Delivery. Answer with a 2xx within 10 seconds. 5xx, 408, 429, timeouts and
connection errors are retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 6
hours (six attempts in all, then the delivery is given up); redirects are not
followed and any other 4xx fails at once, so update the URL. Requests come from the
abusend servers; on a production instance private and internal addresses are refused.
Other languages
Any language works: one HTTPS call, five outcomes.
curl -sS https://rep.example.com/v1/verdict \
-H "Authorization: Bearer $ABUSEND_SECRET_KEY" -H "Content-Type: application/json" \
-d '{"token":"abt_…","action":"topup","client_ip":"203.0.113.7",
"user":{"id":"3f9a1c…","attributes":{"api_calls_total":0}},
"context":{"amount":100,"currency":"USD"},
"challenge_ids":["ch_…"],"review_handling":"custom"}'
import hashlib, hmac, os, requests
session = requests.Session()
session.headers["Authorization"] = "Bearer " + os.environ["ABUSEND_SECRET_KEY"]
def user_id(internal_id): # opaque id: HMAC with a key only you hold, never the e-mail
return hmac.new(os.environ["ABUSEND_USER_ID_KEY"].encode(), str(internal_id).encode(), hashlib.sha256).hexdigest()
def verdict(request, action, user, context):
body = {"token": request.headers.get("X-Abusend-Token", ""), "action": action, "client_ip": request.remote_addr,
"user": user, "context": context, "review_handling": "custom"}
if request.headers.get("X-Abusend-Challenge"): # "ch_…" or "ch_…,ch_…" (at most 3)
body["challenge_ids"] = [i.strip() for i in request.headers["X-Abusend-Challenge"].split(",") if i.strip()][:3]
try:
r = session.post(os.environ["ABUSEND_URL"] + "/v1/verdict", json=body, timeout=1.5)
except requests.RequestException:
return {"decision": "review", "degraded": True} # outage: never allow
if r.status_code == 429 or r.status_code >= 500:
return {"decision": "review", "degraded": True} # a 429 can be caused by an attacker
r.raise_for_status() # other 4xx: an integration bug
return r.json()
# v = verdict(request, "topup", {"id": user_id(current_user.id)}, {"amount": 100})
# challenge → 428 {"error": "challenge_required", "challenges": v["challenges"]}, header Abusend-Challenge: 1
# deny → 403 · review → your decision (see decided_by) · allow → proceed
Send review_handling (continue, block or custom) so the panel can show what
"Your app decides" does on each action. It is informational: it never changes a decision.
Reference
Concepts
| concept | meaning |
|---|---|
| Action | A protected flow (login, topup): a label with token required · optional and token_missing review · deny. Created from traffic (or by hand). Actions have no mode: your enforcing rules apply to an action from its first request. |
| Signal | Something abusend measures: TLS/JA4, HTTP/2, the probe, browser traits, IP (datacenter, VPN, Tor, relay, country, ASN), device, velocity. Signals are rule fields and hints, not verdicts. |
| Risk | A derived signal: risk.score 0–100 and risk.level low · medium · high · critical · unknown, from built-in weighted signal definitions (weights editable per project under Signals; the scores where the levels start are per-project thresholds, 25 / 50 / 75 by default). It never decides by itself. |
| Context | What your server sends: user.attributes (persistent) and context (this request). Typed. |
| Rule | when <condition> → allow · review · deny · challenge(check group); ordered; optional action scope; state Off · Monitoring · Enforcing — the only monitor/enforce switch. |
| Check | A connected verification tool. Widget: turnstile, recaptcha, recaptcha_enterprise, hcaptcha, friendly_captcha, geetest, and abusend's own abusend_pow, abusend_hold, abusend_trace — run by the browser SDK, verified by the edge. App: sms, email, kyc, custom — run by your app, reported by your server. |
| Challenge | One issued verification of a check: pending → passed · failed · incomplete · error (a pending challenge past its expiry is incomplete). |
| Check group | What a challenge rule asks for: 1–3 checks, one of these (random, by weight), all, one after another or all at once (check groups). One check is a group of one. |
| Flow | Verdict → challenge(s) → final verdict of one attempt. At most the project's challenge budget (Project settings → Verifications per attempt: 1–5, default 3; every issued one counts, those issued together included), each check once. |
Panel words: allow Allow · review Your app decides · deny Block ·
challenge Verify with ‹check› (groups: "Verify with Captcha or hCaptcha (70/30)",
"Verify with Captcha, then SMS", "Verify with Captcha and SMS at once") · a challenge is
a Verification · rule states
Monitoring · Enforcing · Off (rules only; actions have none) · signals are labelled
(hint). The full contract is
RULES.md.
Choosing a user id
user.id links verdicts, flows, lists and erasure to one of your users. It must be
opaque and stable, and never an e-mail address, phone number, national id or name.
import { createHmac } from "node:crypto";
export const userId = (internalId: string) =>
createHmac("sha256", process.env.ABUSEND_USER_ID_KEY!).update(String(internalId)).digest("hex");
- Known user: HMAC of your internal id (as above), with a key only you hold
(
ABUSEND_USER_ID_KEY, stored like the secret key). - Login or sign-up form (only what the user typed): HMAC of the normalised login name
(
login.trim().toLowerCase()), or look up the account and use its internal id. - Same person, same id in every call — and for
DELETE /v1/users/{id}. A plain, unkeyed SHA-256 of an e-mail is not opaque: it can be reversed from a list of addresses. - App checks are reported with the same id (
challenges.result(id, status, { userId })).
Verdict request
POST /v1/verdict, Authorization: Bearer sk_…, JSON body (max 64 KiB):
| field | ||
|---|---|---|
token |
string, optional | The browser's token (X-Abusend-Token). Missing → token_status: "missing" (see decisions). |
action |
string | ^[a-z0-9_.-]{1,32}$. Required: it selects the action's token requirement and the rules in scope, and is compared with the token's action (checks.action). |
user |
object, optional | {"id": "…", "attributes": {…}}; id 1–256 characters, opaque. |
context |
object, optional | This request's facts (amount, currency, …). ≤ 32 keys, ≤ 4 KB. |
client_ip |
string, optional | The end-user IP your server saw (no port). See client IP. |
challenge_ids |
array, optional | 0–3 ch_… ids from the X-Abusend-Challenge header of a retried request (comma-separated there). More than 3 or a malformed id → 400 invalid_request; an unusable id is never an HTTP error, it simply gives no credit. |
review_handling |
string, optional | continue · block · custom: what your server does with review. Informational (panel). |
simulate |
string, optional | allow · review · deny. Test projects only; live projects ignore it. |
Verdict response
HTTP 200 for every well-formed request — unusable tokens included.
{ "id": "0192…", "flow_id": "fl_…", "decision": "challenge", "would_decision": null, "would_challenge": null,
"decided_by": {"type": "rule", "id": "…", "name": "Dormant account, big first top-up", "code": "rule_3c1f09aa"},
"challenges": [{"id": "ch_…", "check": "sms", "name": "SMS", "kind": "app", "provider": "sms", "expires_in": 600, "reissued": false}],
"risk": {"score": 12, "level": "low"},
"reasons": [
{"code": "rule_3c1f09aa", "label": "Dormant account, big first top-up", "kind": "rule", "effect": "challenge", "rule_id": "…", "category": "account"},
{"code": "ip_vpn", "label": "VPN IP", "kind": "signal", "weight": 15, "group": "network", "category": "network"}],
"signals": {"ip": {"address": "203.0.113.7", "country": "SG", "asn": 7473, "datacenter": false, "vpn": true, "tor": false, "relay": false},
"client": {"kind": "browser", "label": "chrome", "ja4": "t13d…", "ua_family": "chrome"},
"probe": "ok", "device": {"present": true, "id": "d1.…", "new": false, "users": 1}},
"token_status": "valid", "user": {"id": "3f9a1c…", "attributes": {"api_calls_total": 0}},
"action": "topup", "test": false, "created_at": "2026-09-27T10:00:00Z" }
| field | |
|---|---|
id |
Verdict id. Log it; use it for feedback. |
flow_id |
The attempt this verdict belongs to (fl_…); retries after a check share it. |
decision |
allow · review · deny · challenge. Act on this field. |
would_decision, would_challenge |
What the matching rules would decide if every one of them (monitoring rules included) enforced; would_challenge is {mode, checks} when that would be a challenge (for "one of these": the candidates). null when no monitoring rule matched. |
decided_by |
{type, id?, name?, code}: what decided — see decisions. |
challenges |
When decision is challenge: 1–3 verifications to run now (several only for a rule that asks for checks "all at once"), each with id, check (key), name, kind (widget · app), provider, expires_in (s), reissued. Widget checks add site_key, mode, action, nonce, script_url, timeout_ms, ui (the verification dialog look) and, when the SDK shows the widget, appearance: "visible" (Friendly Captcha EU: region: "eu"; abusend's own checks: params, no site_key or script_url — see abusend's own checks). Otherwise []. |
risk |
{score, level} — a hint. level is unknown without a valid token. |
reasons |
Why: the deciding rule or built-in, verification outcomes, monitoring matches (monitor: true), then the signals that contributed to risk. See reason codes. |
signals |
A summary of the leading signals (IP, client, probe status, device). |
token_status |
valid · missing · expired · invalid · project_mismatch · reused · user_mismatch. |
user |
The user as used (stored attributes merged with this call's). |
test |
true for test projects — assert false in production. |
retry_after_seconds |
Only when a limit decided (check_rate_limited: 3600, challenge_reused: 5). |
Decisions and who decided
| decision | panel | what to do |
|---|---|---|
allow |
Allow | proceed |
review |
Your app decides | your choice: continue, add your own step-up, or hold — read decided_by first |
deny |
Block | reject with a generic message; log id + reasons, never show them to the client |
challenge |
Verify with ‹check› | answer 428; the SDK runs the checks and retries |
decided_by.type makes every review unambiguous:
| type | decided by | typical codes |
|---|---|---|
builtin |
a built-in before your rules: block/allow lists, a reused verification | blocklist_user, blocklist_ip, blocklist_device, allowlist_*, challenge_reused |
token |
the token: an unusable token (deny, always), or a missing one on a token-required action — token_missing = deny denies; review (default) turns what your rules would allow into review |
token_expired, token_invalid, token_missing, … |
rule |
one of your rules (id, name) |
the rule's key or rule_<id> |
check |
a verification's outcome: on_fail, on_incomplete, on_error, or a verification still pending |
challenge_failed, challenge_incomplete, challenge_error, check_unavailable, challenge_required |
limit |
a limit: the issue cap, or the verifications one attempt may have (decided_by.name: the rule that did not fit) |
check_rate_limited, challenge_limit |
default |
no enforcing rule matched (monitoring rules only report, see would_decision) |
no_rule_matched |
test |
a test project's forced decision or simulate |
test_forced |
Before your rules (always, whatever their states): unusable tokens (expired, invalid,
project_mismatch, reused, user_mismatch) and block lists → deny. A missing token
on a token: required action → the action's token_missing: deny ends here; review
(the default) lets your rules run (a withheld token never skips a check) and turns their
allow into review. Your rules then run top to bottom: any
enforcing deny wins; a check failed in this flow → its on_fail (several: the strictest);
otherwise the first enforcing allow, review or challenge(check group) decides (a
group whose checks already passed in the flow, or are remembered, is skipped — for "one of
these", one passed check is enough); nothing matched → allow. Allow lists are the top allow
rules: they skip your challenge rules but never beat a deny.
The floor for a client that withholds anything (the token, a solution, a challenge id)
is the configured missing / incomplete outcome — never allow.
Handling review: onReview
review means your app decides. The Node SDK's guard() and protect() require
onReview, so a review is never allowed silently, and they send your choice as
review_handling (shown on the action in the panel; it never changes the decision):
onReview |
review decided by a rule or the token requirement (decided_by.type rule, token) |
review decided by a verification, a limit or an outage (check, limit, outage, …) |
|---|---|---|
"continue" |
continue (guard() resolves null, protect() calls next()) |
403 {"error": "verification_incomplete", "retry_after_seconds"?} |
"block" |
403 {"error": "forbidden"} |
403 {"error": "verification_incomplete", …} |
| a function | guard(): (request, verdict) => Response | null; protect(): (req, res, verdict, next) — your answer, or null / next() to continue |
the same function (read decided_by first) |
So with "continue" someone who never enters the SMS code is not let through.
retry_after_seconds is set when a limit decided (e.g. check_rate_limited: "try again in
an hour"). Without the SDK, apply the same rule yourself: continue a review only when
decided_by.type is rule or token.
The 428 contract
What protect() / guard() answer, what Abusend.fetch understands, and what native
apps implement:
HTTP/1.1 428 Precondition Required
Abusend-Challenge: 1
Content-Type: application/json
{"error": "challenge_required", "challenges": <verdict.challenges>}
challenges lists 1–3 verifications; the client runs all of them (at the same time is
fine: widget: from script_url, verified by POST /v1/challenges/{id}/verify; app: your
UI, reported by your server) and, when every one completed, retries the original request
with a fresh token and X-Abusend-Challenge: <id>,<id> — every id of the 428,
comma-separated. The retry may get another 428 for the next step (at most the project's
challenge budget per flow, 3 by default). A verification still pending comes back with
reissued: true until it has a result. Abusend.fetch gives up after 5 rounds (SDKs before
3.2: 3), when a handler rejects, when no handler
is registered for an app check, or when a widget cannot load: it aborts the other app
handlers of the round, reports each unfinished verification as not completed and returns
the 428 to your code — show "We could not verify this request, please try again".
Rule conditions, fields and helpers
Conditions are expr-lang expressions (≤ 2000 characters, ≤ 500 nodes, ≤ 200 rules per project). The panel's sentence builder writes them for you; the expression tab shows the typed fields. Groups, in builder order:
| group | fields |
|---|---|
| Context | context.<key> (your declared context attributes) |
| User | user.present, user.id, user.age_days (since abusend first saw the user), user.new_device, user.devices_30d, user.<key> (your declared attributes), passed("sms") |
| Events | event_count("topup", "1h"), event_sum("topup", "24h"), event_last("topup") — the events your server reports (events); attempts("topup", "5m") — the user's attempts at an action; counter("payments_by_country", "1h") — a total across users (shared counters) |
| Velocity | user.verdicts_1h, user.challenges_issued_1h, ip.assessments_1h, device.assessments_1h, device.users_24h, device.challenges_not_passed_1h, device.challenges_issued_1h, network.challenges_not_passed_1h, network.challenges_issued_1h, fingerprint.devices_1h |
| Network (hint, except country) | ip.country, ip.asn, ip.org, ip.datacenter, ip.vpn, ip.tor, ip.relay, ip.address, ip.score |
| Device | device.present, device.id, device.age_seconds, device.users_total, device.denies_24h, device.blocked_user_seen, device.challenge_passed_recently, device.recognized, device.recognition (high · medium · ""), device.recognized_blocked, device.recognized_users (recognition) |
| Browser / TLS (hint) | probe.status (ok · absent · failed · none), client.kind, client.label, client.ua_family, client.ua_mismatch, client.h2_mismatch, client.ua_mobile, client.ja4, client.origin, header(name), js.webdriver, js.headless, js.headless_gpu, js.fingerprint_randomized, js.cdp_detected, js.interactions, js.timezone |
| Risk (hint) | risk.level, risk.score |
| Request | action, token.status (valid · missing), checks.ip (match · mismatch · unknown · not_checked), checks.action |
network.* is the client's IPv4 /24 or IPv6 /48 — careful with shared networks (CGNAT,
offices). Counters from verifications are computed only when a rule uses them.
Helpers: days_since(ts), hours_since(ts), has(x), in_list("list_key", value),
cidr(ip.address, "203.0.113.0/24"), header("sec-fetch-site"), now() (Unix seconds),
passed("check_key") (passed in this flow, or remembered),
email_domain(user.email) (the lower-cased domain, "" when it is not an address).
ip.country == "SG" && user.api_calls_total == 0 && context.amount >= 100 → Verify with SMS
days_since(user.registered_at) < 7 && context.amount >= 500 → Verify with SMS
ip.country != "" && user.country != ip.country → Verify with SMS
device.present && device.users_24h > 3 → Your app decides
event_count("topup", "1h") >= 5 → Verify with SMS
ip.datacenter && !ip.relay → Verify with captcha (sign-up)
risk.level in ["high", "critical"] → Verify with captcha
Missing values
- Any comparison with a missing value is false —
!=too:context.amount >= 100andcontext.amount != 0are both false withoutamount. has(context.amount)tests presence.!(context.amount >= 100)is true when missing; the editor warns about!around an attribute comparison. Preferhas(context.amount) && context.amount < 100.- A value of the wrong type counts as missing (and is counted on the attribute).
days_since(user.registered_at)of a missing or unreadable timestamp is missing.
Checks
Connect checks under Checks (several per project). Widget checks need the provider's site key and secret (stored encrypted, never shown again; Test checks it against the provider), except abusend's own checks, which need neither (a secret is refused). App checks have no secret on our side.
| setting | meaning |
|---|---|
key |
slug rules and SDK handlers use (captcha, sms); unique per project (check_key_taken) |
provider |
widget: turnstile · recaptcha (v3, v2_invisible or v2_checkbox) · recaptcha_enterprise (+ Google Cloud project id) · hcaptcha · friendly_captcha (site key + API key; region global or eu) · geetest (v4: site key = the 32-character captcha_id, secret = the captcha_key) · abusend_pow · abusend_hold · abusend_trace (abusend's own: no keys); app: sms · email · kyc · custom |
appearance |
widget: invisible (default: the widget runs in the background and only shows up when the provider wants an interaction) or visible (the browser SDK shows the widget — checkbox or puzzle — in its verification dialog). Turnstile, hCaptcha, Friendly Captcha and GeeTest support both; reCAPTCHA v2_checkbox is always visible (it needs a checkbox site key); reCAPTCHA v3 / v2_invisible and Enterprise are invisible only. abusend_pow is invisible only, abusend_hold and abusend_trace visible only. A visible verification waits for the person up to the check's timeout_seconds. |
threshold |
reCAPTCHA v3 / Enterprise: a score below it fails (default 0.5) |
pow |
abusend's own checks: the puzzle size {mode, fixed, low, medium, high, critical} — mode risk (by the verdict's risk level; default 15 / 18 / 20 / 22), fixed (default 18) or off (hold and trace only); sizes 10–26, each step doubles the work |
strictness |
abusend_hold, abusend_trace: lenient · normal (default) · strict — the movement score an attempt needs (0.3 · 0.5 · 0.7) |
remember_seconds |
0–604800 (7 days): a pass is remembered this long (default 24 h widget / 0 app); the panel sets it in seconds, minutes, hours or days. remember_hours (whole hours, rounded down) is still returned and accepted |
on_fail |
deny; review only for score-based widgets (reCAPTCHA v3 / Enterprise; not v2) |
on_incomplete |
not completed — abandoned, expired, could not run, issue cap reached: review (widget default: ad blockers hit real users) · deny (app default: a real user retries) |
on_error |
the provider could not verify (outage, open circuit breaker, unreadable secret): review (default) · deny |
timeout_seconds |
widget 30–300 (120) · app 60–1800 (600) |
max_issued_per_hour |
app only, 1–100 (5): the issue cap |
No on_* setting can be allow. A check a rule uses (alone or in a
group) cannot be deleted (check_in_use).
Widget checks are run by the browser SDK and verified by the edge
(POST /v1/challenges/{id}/verify); the browser can also report a pending verification of
either kind as not completed (script_blocked, timeout, widget_error, abandoned) —
never as passed. hCaptcha and GeeTest have no action binding of their own (GeeTest's
answer has no hostname either), so their verification relies on the challenge's device
cookie and your allowed origins; Friendly Captcha's answer carries the origin the widget ran
on, which must be one of your allowed origins. For GeeTest v4 the browser SDK sends the
widget's getValidate() object as provider_token (compact JSON with lot_number,
captcha_output, pass_token, gen_time); the edge signs it with your captcha_key.
The look of the verification dialog (visible checks) is a project setting,
settings.challenge_ui: theme (auto · light · dark, default auto), accent
(#rrggbb or empty) and branding (a small "Protected by abusend" line, default on).
App results are reported by your server with the secret key and the user id:
POST /v1/challenges/{id}/result Authorization: Bearer sk_…
{"status": "passed" | "failed" | "abandoned", "user_id": "3f9a1c…"} → {"status": "passed"}
First result wins; the same result again is idempotent; another one → 409
challenge_resolved. A wrong user → 409 challenge_user_mismatch (a challenge issued
without a user takes no user_id). A widget challenge → 409 challenge_kind_mismatch.
Unreported app verifications become incomplete at expiry. The Checks page shows app
checks as "Waiting for the first result report" until one arrives, and the share of
verifications that were issued but never reported.
The verification dialog
When a person has to do a check — a visible checkbox or puzzle (appearance: "visible",
the reCAPTCHA v2 checkbox), or a background check that turns interactive — the browser SDK
(3.1+) shows it in a small dialog: a card over the dimmed page (a bottom sheet on phones)
with a title, the provider's widget, Cancel (reported as abandoned, like Escape) and a
short "Verified" before it closes. It is accessible (a labelled modal dialog, focus moved in
and back, reduced motion respected), isolated in a Shadow DOM, and needs no CSP change (its
styles are a constructable stylesheet; older browsers get a <style> with the script's
nonce). The texts follow the page's <html lang>: English, Turkish, German, French,
Spanish, Italian, Portuguese and Dutch.
Its look is settings.challenge_ui (panel → Project settings → Verification dialog, with a
preview); a page can override it, or turn it off and draw its own UI:
Abusend.configure({ ui: { theme: "dark", accent: "#16a34a", lang: "tr", branding: false, text: { title: "One more step" } } });
Abusend.configure({ ui: false }); // widgets needing a person: small corner box
Abusend.configure({ challengeContainer: "#verify-here" }); // your own UI; challengeStatus() tells you when
On the script tag: data-ui-theme, data-ui-accent, data-ui-lang, data-ui="off".
SDKs before 3.1 ignore appearance and ui: a visible Turnstile or hCaptcha check then runs
in the background (it shows up only when the provider asks), and modes they don't know
(recaptcha_v2_checkbox, friendly_captcha, geetest_v4) are reported as widget_error
— the check's "cannot verify" outcome, never Allow; SDKs before 3.3 do the same with
abusend_pow, abusend_hold and abusend_trace. Update the SDK before you use them.
Check groups
A Verify with rule asks for a check group: 1–3 different checks and how to combine them. A rule with one check is simply a group of one.
| mode | panel | what happens |
|---|---|---|
any |
One of these (random, by weight) | One verification, of a check picked at random by weight when it is issued (for example Turnstile 70 / hCaptcha 30). Passing it satisfies the rule. A check that is unavailable (open circuit breaker, missing or unreadable secret) is skipped; weight 0 = fallback only, used when every weighted check is unavailable; none available → the first check's on_error. |
sequence |
All, one after another | One verification at a time, in order; the next after a pass (one 428 each). Satisfied when every check passed. |
parallel |
All at once | Every check in the same 428 (challenges lists them all). Satisfied when every check passed. |
- A random pick makes a solver tuned to one provider less useful and weights balance cost; it does not make the checks unbeatable.
- Failure: a failed, not completed or errored verification applies that check's
on_fail/on_incomplete/on_error. "One of these" never switches to another check after a failure, and retrying never draws again: a pending verification comes back (reissued: true) until it has a result. A new attempt (a new flow) draws again. "All at once": when some checks did not pass, the strictest of their outcomes decides (Block over Your app decides); passing one of two is never enough. - Limits: every verification counts toward the project's budget per flow (default 3,
up to 5 in Project settings → Verifications per attempt; a parallel group of three uses
three), shared by every rule that matches: when the checks the matching rules need
can't all fit, the flow ends with review
challenge_limitbefore anyone verifies (itsdecided_by.nameis the rule that did not fit), each check at most once per flow; the issue cap applies per app check — when an app check of a parallel group is capped, nothing is issued and that check'son_incompleteapplies. passed("sms")and remembered passes work per check; a remembered pass satisfies its item of the group.- Your hooks and handlers see only the checks that were issued:
checks.<key>(Node) runs once per newly issued verification, andAbusend.fetchruns every verification of a 428. - Panel API and MCP:
"checks": {"mode": "any", "items": [{"check": "turnstile", "weight": 70}, {"check": "hcaptcha", "weight": 30}]}, or"check": "sms"for one check. Weights (integers 0–100, default 1, at least one above 0) exist only forany.
Flows, retries and challenge ids
- A challenge is bound to its project, action and user, and to the device when it was
issued with a valid token. A presented id (
challenge_ids) that does not match — unknown, another action or user, another device, or taken up by another tab — gives no credit (info reasonchallenge_invalid); it is never an HTTP error. - The presented ids count for one flow (the one of the latest-issued presented verification); ids of another flow give no credit.
- Withholding an id never helps. Without a usable id, the latest unconsumed verification of the same action and user (or, without a user, device), issued within the longest check timeout + 15 minutes and not passed, attaches its flow. A passed verification is never attached this way: credit only comes from a presented, matching id.
- While any verification of the flow is pending, every pending one is returned again
with
reissued: true(not counted; hooks do not run again) — whether its id was sent or not. Once each has a result, the verdict uses the flow's results and consumes every verification of the flow (one you did not send included): one set of passes buys one decision. - A consumed verification presented again within 5 s by the same caller (user,
client_ip, device, action) replays the stored answer (idempotent retries); anything else is denychallenge_reused(retry_after_seconds: 5). - A pass has to be used within 5 minutes.
- At most the project's budget of verifications per flow (default 3, at most 5; those issued
together included), each check once; when the matching rules need more → review
challenge_limit, decided before anyone verifies.
Remembered passes
With remember_seconds > 0, a recent pass lets a challenge rule be skipped:
- Widget: the same device id, fingerprint hash and IPv4 /24 · IPv6 /48 as the pass, on a device with at most 2 users in 24 hours.
- App: the same user (and the same device when both have one).
- At most 20 skips per pass, within
remember_seconds. The rule list shows "skipped when SMS passed in the last 24 h" (for a group: "one of these" — the longest of its checks' windows; the other modes — the shortest). - Always the browser's own device id: a device recognition linked the browser to never lends its passes.
Issue cap (SMS pumping)
An app challenge is not issued when the check's max_issued_per_hour (default 5) is
reached for the user, else the device, else the network (IPv4 /24 · IPv6 /48). The verdict
then answers the check's on_incomplete with decided_by.type = "limit", code
check_rate_limited and retry_after_seconds: 3600 — tell the user to try later.
Re-returned challenges never re-run your hook. The template SMS pumping
(network.challenges_issued_1h >= 20 → Block) adds a network-wide brake.
Monitor, then enforce
A rule's state is Off, Monitoring or Enforcing, and it is the only monitor/enforce switch: an Enforcing rule decides on every action in its scope — including an action abusend sees for the first time — and a Monitoring rule only reports what it would do. Actions have no mode.
- Deploy the SDK and the verdict call. Actions appear as they are used. The two
recommended rules every project starts with (Repeated failed or unfinished
verifications → Block, High risk → Your app decides) are Monitoring; with no
enforcing rule, verdicts are
allowwith what the monitoring rules would do inwould_decision/would_challenge. Built-in denies (unusable tokens, block lists) and the token requirement always apply. - Save rules in Monitoring (the default). Watch Traffic ("would have: Verify with
SMS") and Overview (the friction funnel: users who saw no check, verified, passed,
abandoned, failed, blocked). A rule that lists
unseen_attributesreads an attribute your server never sent: fix the integration first. - Handle "Your app decides" on purpose (
onReview); the Overview flags actions where many verdicts end in review. - Enforce a rule when its would-hits look right (the confirmation shows its monitoring hits since creation; its optional "What would it have done?" section runs a replay when opened). The to-dos suggest this for the recommended rules.
- Revisit: feedback on verdicts (
fraud/legit), replay before every change.
Replay and simulation
Verdicts store their inputs (the user's attributes as used, the context, the counters and
the flow state), so a stored verdict re-evaluated with the rules that were live then
reproduces its decision. Replay (rule editor, MCP simulate_rule) runs the first
verdicts of the flows of the last 7 days (newest 20 000) with the rule enforcing,
before and after your change, against current lists and checks, and
reports matched flows, changed decisions and users per day per check (a check that is
not connected yet shows as "Verify with ‹SMS — not connected›"). It warns when a field the
rule uses was missing in many verdicts. There is no minimum sample and test-project traffic
counts: with no traffic yet the editor offers Try it (your rules and the new one on a
made-up request with the attributes and context you type), and below 200 verdicts it shows
counts ("would match 3 of 37") and lists every matching flow. Challenge rules are replayed
up to their first step. A stored verdict reports the check it actually issued; a new "one
of these" rule is reported as "one of" its checks, and its verifications are shared out by
weight (expected counts).
Testing
Test projects (pk_test_… / sk_test_…):
http://localhost:*,127.0.0.1and[::1]are always allowed origins; an empty list allows any origin. Verdicts carry"test": true.- Forced decision (Setup → Test) or
simulate: "deny"per request makes the verdict that decision (decided_by.type = "test", reasontest_forced) — except while a verification is being issued. Live projects ignore both. - Provider test keys (test projects only, never live):
Turnstile site key
1x00000000000000000000AA(passes) /2x00000000000000000000AB(fails in the browser), secret1x0000000000000000000000000000000AA(passes) /2x0000000000000000000000000000000AA(fails); reCAPTCHA v2 site key6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhIwith secret6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe(passes); hCaptcha site key10000000-ffff-ffff-ffff-000000000001with secret0x0000000000000000000000000000000000000000(passes). Friendly Captcha and GeeTest publish no test keys: usesimulateor a forced decision, or a real key onlocalhost. abusend's own checks need no keys at all: they run onlocalhostas they do live. - App checks: report
passed/failed/abandonedfrom your test OTP endpoint. - Headless test browsers raise risk on purpose; in CI use
simulate, a forced decision, or your CI egress on the allow IPs list.
Device id
- Unless the project turns it off (
privacy.device_id = off),/v1/assessissues a signed, per-project id (d1.…), kept in a partitioned, HttpOnly cookie__Host-ab_didon the edge host and in your origin'slocalStorage["abusend.did"]. A forged or foreign id is ignored and replaced. - It feeds the
device.*fields, remembered widget passes, the challenge binding (a verification issued with a valid token only counts from the same device) and the Devices page. Blocking a device denies every verdict carrying it (blocklist_device). Allowing a device is the top allow ruleallowlist_device, and only counts from the browser it was allowed from (cookie id with the same fingerprint hash). - It is an online identifier — personal data under KVKK/GDPR. Mention the cookie in your notice (PRIVACY.md); turned off, nothing is issued, stored or set.
Recognition across private windows (opt-in). With privacy.device_id = recognition
(Setup → Privacy → Recognise devices in private windows) a desktop browser that sends no
device id — a private window, cleared storage — can be linked to a known device. It
links, it never merges: the browser always gets its own new device id and cookie.
- Desktop browsers only (Windows, macOS, Linux, ChromeOS). iPhone, iPad (any browser), Android and other phones and tablets are never recognised: identical models look identical.
- Same network only. The edge compares a print of the browser's stable fingerprint
(canvas, WebGL, audio and font hashes, screen, time zone, languages, OS, CPU cores, TLS
fingerprint, browser and major version) with the project's devices last seen on the
same IPv4 /24 · IPv6 /48 in the last 30 days.
high: exactly one device with the same print.medium: one device with the same browser family, most components equal, and no equally good other device. Anything weaker is never linked. A presented id always wins. - Busy networks are skipped: a /24 · /48 with more than 20 devices with a print in the last 7 days (mobile carrier NAT, offices, campuses, hotels) is never used.
- Shared prints are dropped: when two devices with the same print keep coming back with their own ids (identical machines, two browser profiles on one computer), the print is marked shared and never linked again. Identical machines on one network may be linked once, until the second one's id is a day old.
- Rules see
device.recognized,device.recognition,device.recognized_blocked(the linked device is onblock_devicesor was used by a user onblock_users) anddevice.recognized_users(distinct users of the linked device in 30 days). A link is a hint for your rules — never an automatic block, never carried trust: block and allow lists, remembered passes,device.age_seconds,device.users_totalanduser.new_deviceare the new id's. Decide what a link means yourself, e.g.device.recognized && device.recognized_blocked→ Verify with ‹check›. - It is a hint, not proof. Safari private mode, Firefox and Brave randomise fingerprints:
they are not recognised and raise the signal
fingerprint_randomized. Prefer Verify over Block, especially ondevice.recognition == "medium". - Only keyed hashes are stored; they are deleted after 30 days, with an erased user's devices, and all at once when you turn recognition off. It is device fingerprinting: update your privacy notice first (PRIVACY.md).
Client IP behind proxies
Send the real client IP as client_ip; abusend compares it with the IP that requested the
token (checks.ip, signal ip_mismatch) and uses it for IP data on token-less verdicts,
network counters and fairness.
| setup | client IP |
|---|---|
| no proxy | the socket peer (req.socket.remoteAddress, $_SERVER['REMOTE_ADDR'], r.RemoteAddr) |
| Cloudflare | CF-Connecting-IP |
| load balancer / reverse proxy | the right-most X-Forwarded-For entry that is not one of your proxies |
| Express | app.set("trust proxy", <hops or CIDRs>), then req.ip (what protect() sends) |
Fetch API (guard()) |
by default the right-most X-Forwarded-For entry, else X-Real-IP; pass clientIp: (request) => … when clients can reach the app without your proxy |
Only trust these headers from your own proxies. When unsure, omit client_ip rather than
send a wrong one. Same IPv4 /24 or IPv6 /64 counts as a match; IPv4 vs IPv6 is unknown.
Tokens, retries and replay
- A token (
abt_…) is opaque and encrypted, valid 10 minutes by default (60–3600 s per project), and single use. The SDK requests it at submit time; don't pre-fetch. - A repeated verdict for the same token is an idempotent retry only within 5 s of the
first redemption, at most 3 redemptions, with the same
action,client_ipand user. Anything else istoken_reused→ deny. Retry only on network errors and 5xx, with the identical body; get a new token for every user action. - Expired, invalid and other-project tokens are always denied — a token from the
test project checked with a live secret key shows up as
token_project_mismatch. A token the service no longer holds a record of (a service restart in between, at most the token's lifetime) istoken_invalid, never valid: get a new one.
Failure policy and latency
- The verdict call should time out after 1.5 s. On a timeout, network error or 5xx, apply your failure policy — the SDKs default to review.
- A 503
busyis an outage. Under a burst the service can answerPOST /v1/verdictwith503busyandRetry-After: 1when it cannot take more verdicts for a moment. Apply your failure policy (review or deny, never allow); a retry after the header with the identical body is fine.@abusend/nodealready does this (it retries within its window, then returns the failure decision withdecided_by.type = "outage"). A 503state_unavailableis the same kind of outage: the service's state store could not be reached, so a token could not be redeemed (or, at/v1/assess, no token was issued); nothing was consumed, retrying with the identical body is fine. - 429 is not an outage. Map it to review (or deny), never to allow: anyone who can flood your endpoint can push your key over its limit.
- Other 4xx errors are integration bugs (wrong key, invalid input): alert on them.
- Target server time: verdict p99 < 50 ms. A challenged request costs one extra round trip (the 428); unchallenged requests are unaffected.
Users, feedback and erasure
All with Authorization: Bearer sk_…:
| call | |
|---|---|
GET /v1/users/{id} |
{id, attributes, first_seen_at, last_seen_at, recent: [{verdict_id, flow_id, created_at, action, decision, decided_by}]} (404 user_not_found); recent is read from the verdict log, about a second behind the verdict call |
PATCH /v1/users/{id} {"attributes": {…}} |
merge attributes (creates the user); null deletes a key |
DELETE /v1/users/{id} |
erasure (idempotent): {"ok": true, "erased": true | false}; the user's events go too. 200 means the erasure is recorded and its part in the main database is done; the parts in the analytics store and in Valkey are attempted in the same call and, if one fails or is still running, finish in the background (guaranteed, normally within a minute: a pending erasure abusend retries until it succeeds) — wait a minute before you tell the person it is complete. See PRIVACY.md |
POST /v1/feedback {"verdict_id", "label": "fraud" | "legit", "note"} |
labels the verdict's whole flow (404 verdict_not_found) |
POST /v1/events {"events": [{user_id, type, value?, properties?, occurred_at?, id?}]} |
reports events (1–100, all or nothing): {"accepted", "duplicates"} — see events |
URL-encode ids in paths. Node: abusend.users.*, abusend.feedback({ verdictId, label, note }),
abusend.events.track(…) / trackMany([…]).
Reason codes
reasons[] entries: code, label, kind (rule · builtin · signal · challenge ·
info), effect (the outcome a rule or built-in asked for), weight and group
(signals), rule_id (your rules), monitor: true (a monitoring rule that matched) and
category (automation, network, browser, device, account, token, challenge,
lists, custom). Your rules use their key, or rule_<8 hex>. Keys may not reuse a code
below or start with sdk_, test_, monitor_, rule_, token_, system_, challenge_,
check_ or builtin_. GET /api/panel/v1/meta/reason-codes lists them all.
Built-ins and flow codes:
| code | kind | when |
|---|---|---|
blocklist_user, blocklist_ip, blocklist_device |
builtin (deny) | the user / IP / device is on a block list |
allowlist_user, allowlist_ip, allowlist_device |
builtin (allow) | on an allow list (top allow rules; never over a deny) |
token_expired, token_invalid, token_project_mismatch, token_reused, token_user_mismatch |
builtin (deny) | unusable token (always) |
token_missing |
builtin | no token on a token-required action: deny (token_missing = deny), or review where your rules would allow (review) |
challenge_reused |
builtin (deny) | a consumed verification presented again (not the 5 s replay) |
challenge_required |
challenge | a verification is issued or still pending |
challenge_passed, challenge_remembered |
challenge | passed in this flow / remembered pass |
challenge_failed |
challenge | failed → the check's on_fail |
challenge_incomplete |
challenge | not completed → on_incomplete |
challenge_error |
challenge | the provider could not verify → on_error |
check_unavailable |
challenge | the check cannot run now (not configured, secret unreadable, breaker open) → on_error |
check_rate_limited |
challenge | issue cap reached → on_incomplete |
challenge_limit |
challenge | the matching rules need more verifications than the project allows per attempt → review |
challenge_invalid, challenge_attached |
info | a presented id gave no credit / an earlier verification was attached by the lookup |
test_forced |
info | test project override |
no_rule_matched |
info | nothing matched → allow |
Signals (hints feeding risk; within a group only the largest weight counts; weights
and on/off are editable per project under Signals):
| code | group | weight | when |
|---|---|---|---|
client_bot_library |
tls_consistency | 50 | the TLS handshake matches a known HTTP library or tool |
ua_mismatch |
tls_consistency | 45 | the User-Agent claims a browser the TLS fingerprint contradicts (a handshake several browsers share, client.label chrome/firefox, contradicts neither) |
h2_mismatch |
tls_consistency | 40 | HTTP/2 settings of a tool or of another browser |
no_grease_chromium |
tls_consistency | 20 | Chrome/Edge without GREASE |
client_unknown |
tls_consistency | 15 | TLS fingerprint not in the catalog (inspecting proxies, rare browsers) |
sec_fetch_missing |
automation | 25 | Chrome/Edge/Firefox User-Agent without Sec-Fetch-* |
js_webdriver |
automation | 60 | navigator.webdriver is true |
js_headless |
automation | 40 | headless traits |
js_ua_mismatch |
automation | 25 | navigator.userAgent differs from the header |
js_inconsistent |
automation | 15 | window, screen, touch or language properties contradict each other |
no_interaction |
automation | 10 | no interaction before login/register/signup/checkout |
header_anomaly |
automation | 10 | Accept or Accept-Language missing |
js_headless_gpu |
automation | 50 | software WebGL renderer |
js_clean_dirty_mismatch |
automation | 40 | a property differs between a clean iframe and the page |
js_native_tostring_tampered |
automation | 35 | a native function's toString is not native |
js_navigator_overridden |
automation | 35 | a navigator getter was redefined |
js_cdp_detected |
automation | 55 | DevTools-protocol artifacts |
js_webdriver_advanced |
automation | 55 | automation markers beyond navigator.webdriver |
js_probe_tampered |
automation | 60 | the probe's integrity checks failed |
js_ua_platform_mismatch |
automation | 25 | navigator.platform contradicts the User-Agent |
js_screen_anomaly |
automation | 15 | impossible screen geometry |
fingerprint_randomized |
automation | 10 | canvas / audio fingerprints change between identical renders (privacy browsers, private modes, anti-detect tools); never recognised |
probe_failed |
probe | 80 | a probe was sent but did not open (replayed, altered, expired) |
probe_absent |
probe | 80 | the token was requested without the SDK's probe |
ip_tor |
network | 40 | Tor exit |
ip_high_risk |
network | 25 | third-party IP score ≥ 75 (off by default) |
ip_datacenter |
network | 25 | hosting / cloud address (not iCloud Private Relay) |
ip_vpn |
network | 15 | commercial VPN |
ip_velocity |
network | 20 | > 30 token requests from this IP in the hour before |
ip_mismatch |
consistency | 20 | client_ip in another /24 · /64 than the token request |
action_mismatch |
consistency | 20 | the token was requested for another action |
risk.score is the sum (0–100); risk.level low < 25 ≤ medium < 50 ≤ high < 75 ≤ critical
with the default thresholds. Each project can move them (1 ≤ medium < high < critical ≤ 100)
after previewing the effect on its recent traffic; the levels only name score ranges for
your rules, and verdicts already made keep the level they got.
A failed probe alone makes the risk critical, but it is still a hint: the baseline rule
(once you enforce it) asks for a widget check on high risk, and a passed check can outweigh
it. No default rule
blocks on risk alone (Critical risk → Block is a strict template).
Errors
Every error has the same shape and an X-Request-Id header (quote it to support):
{"error": {"code": "challenge_user_mismatch", "message": "The challenge was issued for another user.", "docs": "https://rep.example.com/docs#errors"}}
Edge API:
| code | HTTP | endpoint | meaning / fix |
|---|---|---|---|
invalid_json |
400 | POST/PATCH | empty body, invalid JSON or a wrong type — send a JSON object (PHP: cast empty arrays to (object)) |
invalid_body |
400 | POST/PATCH | the body could not be read |
body_too_large |
413 | POST/PATCH | > 16 KiB (assess, feedback), > 192 KiB (verify), > 4 KiB (result), > 64 KiB (verdict, users), > 640 KiB (events) |
invalid_site_key |
401 | assess, verify | site key missing or unknown — copy pk_… from the panel; test vs live |
origin_not_configured |
403 | assess, verify | live project without allowed origins |
origin_not_allowed |
403 | assess, verify | the page origin is not allowed |
invalid_action |
400 | assess, verdict | action not ^[a-z0-9_.-]{1,32}$ (required on verdict) |
invalid_request |
400 | verify, result, verdict, feedback | malformed ch_… id (also in challenge_ids); more than 3 challenge_ids; not exactly one of provider_token, solution, error; a solution for a check that is not one of abusend's own, a provider_token for one of them, or more than 64 puzzle answers; bad review_handling; feedback without verdict_id |
invalid_status |
400 | result | status not passed · failed · abandoned |
invalid_user |
400 | verdict, users, result | user.id empty or > 256 characters; user_id > 256 |
invalid_attributes |
400 | verdict, users | > 32 keys, bad key, value > 512 characters, nested value, context > 4 KB |
invalid_client_ip |
400 | verdict | not a bare IPv4/IPv6 address |
invalid_simulate |
400 | verdict | not allow · review · deny |
invalid_label |
400 | feedback | not fraud · legit |
invalid_events |
400 | events | events missing, empty or more than 100 |
invalid_event |
400 | events | an event is invalid (user_id, type, value, properties, occurred_at format, id); details: {index, field} names it; nothing of the batch is stored |
invalid_occurred_at |
400 | events | occurred_at more than 5 minutes in the future or older than 30 days; details: {index, field} |
event_value_out_of_range |
400 | events | an event's value is beyond ±1e15 (values are summed; keep amounts in a sane range, in minor units if needed); details: {index, field}; nothing of the batch is stored |
missing_api_key |
401 | server API | no Authorization: Bearer … |
invalid_api_key |
401 | server API | unknown key, or a pk_ site key was sent |
revoked_api_key |
401 | server API | the key was revoked — deploy a new one |
challenge_not_found |
404 | verify, result | no such challenge in this project |
challenge_device_mismatch |
403 | verify | the challenge was issued to another browser (device cookie) |
challenge_resolved |
409 | verify, result | already has another result (first result wins), expired, or out of attempts |
challenge_kind_mismatch |
409 | verify, result | a provider token for an app challenge, or a result for a widget challenge |
challenge_user_mismatch |
409 | result | user_id is not the user the challenge was issued for |
user_not_found |
404 | users | never sent, or erased |
verdict_not_found |
404 | feedback | unknown verdict_id (or removed by retention) |
rate_limited |
429 | all | see rate limits; Retry-After in seconds. On a verdict: review, never allow |
key_lookups_paused |
429 | all keyed | this client sent 30+ different unknown keys in a minute |
not_found |
404 | any | unknown endpoint (or probe endpoints not enabled) |
method_not_allowed |
405 | known paths | wrong method; see the Allow header |
sdk_tarball_unavailable |
503 | /sdk/v1/*.tgz |
the server was built without SDK tarballs |
busy |
503 | verdict, events | the service is briefly overloaded (its verdict log queue stayed full, or ClickHouse's writer queue could not take a batch of events: nothing of it was recorded, retry it); Retry-After: 1. An outage for your failure policy: review (or deny), never allow; the SDKs retry twice within their time budget and then return the failure decision |
state_unavailable |
503 | verdict, assess | the service's state store (token redemption, velocity windows) could not be reached; Retry-After: 1. An outage for your failure policy, like busy: review (or deny), never allow; nothing was consumed and no token was issued |
internal_error |
500 | any | retry later; contact support with X-Request-Id |
Not errors: unusable tokens and unusable (well-formed) challenge_ids are decisions (HTTP 200),
never 4xx.
SDK: verification_incomplete (403 from protect() / guard(), body
{"error": "verification_incomplete", "retry_after_seconds"?}) — a review decided by a
verification or a limit under onReview: "continue". SDK error and fallback codes are in
the Node SDK and browser SDK READMEs.
Panel API (/api/panel/v1, the MCP server and the assistant surface them):
| code | HTTP | meaning |
|---|---|---|
attribute_undeclared |
400 | the condition uses an undeclared attribute (details: {scope, key}) |
attribute_in_use |
409 | rules use the attribute you want to retype or delete |
invalid_counter |
400 | panel: a counter's definition does not validate (the message names the field) |
too_many_counters |
400 | panel: a project has at most 50 counters |
counter_in_use |
409 | panel: rules read the counter you want to delete |
mail_not_configured |
409 | panel: e-mail is not set up: save and enable the SMTP settings in System → E-mail |
mail_send_failed |
502 | panel: the test e-mail could not be sent (the message carries the server's answer, never a secret) |
invalid_smtp_host, invalid_smtp_port, invalid_smtp_security, invalid_smtp_username, invalid_from_address, invalid_from_name, invalid_reply_to |
400 | panel: invalid SMTP settings (details.field names the input) |
smtp_security_refused |
400 | panel: unencrypted SMTP (none) is only allowed to localhost or in development |
smtp_password_required |
400 | panel: enter the SMTP password again after changing the server, the port or the user name |
invalid_webhook, invalid_webhook_url, invalid_webhook_format, invalid_event |
400 | panel: invalid webhook destination (https URL of a public address, format json or slack, at least one known event) |
too_many_webhooks |
409 | panel: a project has at most 5 webhook destinations |
notifications_unavailable |
503 | panel: this instance has no notification service |
check_in_use |
409 | rules still ask for the check you want to delete |
check_key_taken |
409 | another check of the project uses that key |
invalid_check |
400 | invalid check settings (provider, key, on_*, timeouts, config; a secret for abusend's own checks) |
invalid_rule, invalid_condition |
400 | invalid rule (outcome, check group — 1–3 distinct checks, mode, weights —, scope) / condition that does not compile |
invalid_rule_key, rule_key_taken |
400 / 409 | reserved or duplicate rule key |
too_many_rules |
400 | more than 200 rules |
invalid_template, invalid_params |
400 | unknown template / bad template parameter |
invalid_action, invalid_attribute, too_many_attributes, invalid_signal, invalid_value |
400 | invalid action, attribute (type, unit, label; at most 200), signal override or value |
invalid_settings, invalid_origins, invalid_environment, invalid_name |
400 | invalid project settings, origins, environment or name |
invalid_fix |
400 | the to-do has no server-side fix |
invalid_event_type, too_many_event_types |
400 | event type not ^[a-z0-9_]{1,32}$ / more than 200 event types listed |
invalid_kind, invalid_key, invalid_expiry, system_list |
400 | lists: bad kind, key or expiry; system lists cannot be deleted |
config_invalid |
400 | configuration import: the document has errors (details.plan.errors) |
plan_stale |
409 | configuration import: the project changed since the plan (details.plan is a fresh one: review it) |
enforce_confirmation_required |
409 | configuration import: rules would start enforcing; apply with confirm_enforce: true |
invalid_device, invalid_user, invalid_fingerprint, invalid_label, invalid_cursor |
400 | invalid device id, user id, fingerprint pattern, feedback label, page cursor |
invalid_ip, invalid_since, invalid_until, invalid_sort, invalid_filter, invalid_by |
400 | users, devices, networks and traffic lists: invalid IP address, time window (1h · 24h · 7d · 30d), until time (RFC 3339), sort, filter or grouping |
forbidden, forbidden_role |
403 | not allowed for your role (only owners manage owners) |
last_owner |
409 | an organization needs one owner |
unauthorized, csrf |
401 / 403 | not signed in / missing X-Abusend-CSRF: 1 |
invalid_credentials, invalid_code, mfa_required, mfa_setup_required, mfa_enabled, mfa_not_pending, password_change_required, weak_password |
400–409 | sign-in and 2FA |
invalid_email, invalid_role, email_taken, account_exists, invite_invalid, signup_disabled |
400–409 | team and invitations |
confirmation_required |
400 | deleting an organization needs its name |
invalid_token, token_not_allowed, too_many_tokens |
401 / 403 / 409 | personal access tokens |
assistant_disabled, assistant_not_configured, assistant_key_unreadable, invalid_base_url, invalid_model, invalid_provider, invalid_max_steps, api_key_required, message_too_long, conversation_busy, no_pending_confirmation |
400–409 | AI assistant |
invalid_recipient |
400 | panel: Watch e-mail recipients must be members of the organization (user ids) |
watch_disabled |
409 | panel: Watch is switched off for the project (nothing to run) |
watch_owner_missing |
409 | panel: the admin Watch runs as is no longer an admin or owner of the organization; save the Watch settings to take over |
watch_busy |
409 | panel: a Watch investigation of the project is already running |
watch_run_limit |
429 | panel: the project used its Watch investigations for the hour or the day (Retry-After); raise the limits in the Watch settings or wait |
watch_unavailable |
503 | panel: this instance has no Watch service |
panel_busy |
503 | panel: too many panel requests at once on this pod (Retry-After: 1); retry shortly. The edge API is not affected |
conflict |
409 | a concurrent change; reload and retry |
rate_limited |
429 | rule test / simulate: 10 per minute per project; risk preview: 30 per minute per project; check preview (hold / trace): 60 per minute per project |
unavailable |
503 | temporarily unavailable |
Rate limits and fairness
| limit | default | on excess |
|---|---|---|
POST /v1/assess per project + client IP (IPv6 /64) |
120/min | 429 for that client; the SDK continues without a token |
POST /v1/assess per project + network (IPv4 /24, IPv6 /48) |
8× per-IP per minute; max(daily / 50, 1000) per day | 429 for that network only |
POST /v1/assess per project |
6000/min | only the heavy networks that spent it get 429; others continue (up to 2×) |
POST /v1/assess per project per UTC day |
200 000 | networks with > 100 today get 429; others continue (up to 2×) |
| verdicts per secret key | 200/s (burst 400) | end users whose client_ip network made > 20 verdicts this minute get 429 (→ review); others continue (up to 2×). Send client_ip. |
| users, feedback, challenge results per secret key | 200/s, separate from verdicts | 429 + Retry-After |
POST /v1/events per secret key |
200 calls/s and 200 events/s (burst 400), separate from verdicts | 429 + Retry-After, nothing of the batch stored (the Node SDK returns ok: false) |
POST /v1/challenges/{id}/verify per client IP / network |
60/min / 480/min | 429 + Retry-After |
POST /v1/challenges/{id}/verify per project |
max(assess_per_minute, 300)/min |
only heavy networks get 429 |
| distinct unknown keys per client | 30/min | 429 key_lookups_paused (keys already in use keep working) |
One client that exhausts a limit only loses its own access — its IP, then its network block. Shared project budgets never lock out everybody. The four project limits are set per project (Setup → Limits). Limits are enforced per server instance.
Privacy notes
- Send opaque user ids and facts, not identities (choosing a user id).
- abusend stores signals per token request, and per verdict its inputs (the user's
attributes as used, the context, counters) and the verifications of the flow. Retention
is per project (default 30 days);
DELETE /v1/users/{id}erases a user, scrubs their verdict inputs and unlinks their verifications. - Events you report are stored with their type, value, time, id and properties for the project's event retention (default 90 days), and deleted with the user. Keep personal data out of event ids and properties.
- Widget checks: the browser loads the provider's script (Cloudflare, Google, hCaptcha/Intuition Machines, Friendly Captcha (Germany) or GeeTest (China)) and the edge verifies with it — the provider receives the visitor's IP address and browser data. GeeTest processes and stores data in China: check the cross-border transfer rules (GDPR, KVKK) before you connect it. abusend's own checks send nothing to anyone else: the pointer movements of hold / trace are scored on your edge and not stored. App checks (SMS, e-mail, KYC) are run by you with your own processors; abusend only records the outcome.
- Watch counts attempts per action in 5-minute buckets (in total and per country and ASN: aggregates, no personal data; kept 8 days). Its investigations are run by your assistant: the tool results the model reads — excerpts of your traffic — go to the LLM provider you configured. Its alerts and reports carry numbers; a note about a counter's rule names up to five of the values the counter is grouped by (IP addresses, user ids or device ids), never an e-mail address.
- Details, sub-processors and a KVKK annex: PRIVACY.md.
Security checklist
- The secret key lives only on your server — never in the browser, the mobile app or the repository.
- Decisions are made server-side from
/v1/verdict(orprotect()/guard()), never from a client flag. - Every protected request redeems its own token;
actionand the realclient_ipare sent. -
user.idis opaque (HMAC), never an e-mail or phone number; no PII in attributes or context. -
onReviewis a deliberate choice per route; reviews decided by a check or a limit are not continued. - App results:
failedonly after your own retry limit;userIdalways sent; the challenge id kept in the session for web flows. - Outages and 429 map to review (or deny), never allow.
- Live projects: allowed origins set exactly; production uses
pk_live_/sk_live_and checkstest === false. - CSP allows the edge and the providers of your widget checks.
- Rules go through Monitoring and replay before they enforce; no rule lists
unseen_attributes. - Panel accounts use two-factor authentication; erasure requests call
DELETE /v1/users/{id}.
FAQ
Does abusend stop bots? Not on its own, and no product can promise that. Signals make automation more expensive to hide and checks make it more expensive to pass; your rules decide where that cost is worth a real user's friction. Every client-side check can be beaten — combine hints with facts only your server knows (account age, amount, usage).
Ad blockers? If the SDK or a widget is blocked, the request goes out without a token or
the verification ends as not completed. On token-required actions your rules still run (a
missing token never skips a check) and what they would allow becomes review
(token_missing = review, the default; deny blocks instead); widget checks default to
review on on_incomplete. Serving the edge
from your own domain (e.g. rep.yourshop.com) reduces blocking; abusend's own checks load
nothing from another host.
VPN, iCloud Private Relay, corporate proxies? They are hints with small weights; Relay is not a VPN. A TLS-inspecting proxy looks like an unknown or non-browser client — add its egress to allow IPs if needed.
Does the browser learn anything? /v1/assess returns only a token. Decisions, risk and
reasons are only available to your secret key.
What if the edge is down? Your users get your failure policy — review with the SDK defaults.