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

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:

  1. POST /topup → abusend.verdict({ action: "topup", user, context, clientIp }).
  2. decision: "challenge" (challenges: [{check: "sms", …}]) → store the ids of challenges in the session, send the OTP, redirect to /verify.
  3. /verify → the user enters the code → your server checks it → abusend.challenges.result(id, "passed" | "failed", { userId }).
  4. 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.

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

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

  2. Rule. Any rule with the outcome Verify with SMS, e.g. the dormant-account template of the quickstart.

  3. Server: send the code in the checks.sms hook of protect() / guard(). It runs only when a challenge is first issued (challenge.reissued is false), so retries never resend. A "resend code" button is your own endpoint.

  4. Browser: show your UI.

    Abusend.onChallenge("sms", async (challenge, { signal }) => {
      await openOtpModal({ challengeId: challenge.id, signal }); // resolve = retry now; reject / abort = give up
    });
    
  5. Server: verify and report. Report failed only 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 });
    });
    
  6. Retry. The modal resolves, Abusend.fetch retries with a fresh token and X-Abusend-Challenge, and the verdict runs the rules again: SMS passed → the next rule or allow; failed → the check's on_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
  1. 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.
  2. 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).
  3. 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 as token_reused.
  4. 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: review so a task that could not be done reaches your app, or use abusend_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

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.

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

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

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.

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");

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

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.

Flows, retries and challenge ids

Remembered passes

With remember_seconds > 0, a recent pass lets a challenge rule be skipped:

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.

  1. 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 allow with what the monitoring rules would do in would_decision / would_challenge. Built-in denies (unusable tokens, block lists) and the token requirement always apply.
  2. 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_attributes reads an attribute your server never sent: fix the integration first.
  3. Handle "Your app decides" on purpose (onReview); the Overview flags actions where many verdicts end in review.
  4. 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.
  5. 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_…):

Device id

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.

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

Failure policy and latency

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

Security checklist

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.