@abusend/browser

The abusend browser SDK (abusend.js): integrate once, and the checks your rules ask for run by themselves. Your server asks abusend for a verdict (Node SDK guard() / protect()); when a rule says Verify with ‹check›, it answers HTTP 428 and this SDK runs the check and sends the request again:

Up to three checks can follow each other in one call (e.g. a captcha, then an SMS code). The SDK also sends a single-use token with each protected request; the signals abusend collects with it are hints for your rules, not verdicts. The rules and their contract are in docs/RULES.md of the abusend repository.

The abusend edge serves the same code:

build URL use
classic script https://rep.example.com/sdk/v1/abusend.js <script> tag, sets window.Abusend
loader snippet loader.html (inline, loads the classic script) SPAs without a bundler: queues calls, never throws when the script is blocked
ES module https://rep.example.com/sdk/v1/abusend.mjs import … from in the browser
npm @abusend/browser from https://rep.example.com/sdk/v1/abusend-browser.tgz bundlers (Vite, webpack, Next.js), plus @abusend/browser/react

Install (bundlers, React)

The package is not on the public npm registry: your abusend edge serves it.

npm install https://rep.example.com/sdk/v1/abusend-browser.tgz          # current version
npm install https://rep.example.com/sdk/v1/abusend-browser-3.0.0.tgz    # pinned (recommended for lockfiles)

Replace rep.example.com with your edge (panel → Setup). The package name stays @abusend/browser, so import … from "@abusend/browser" / "@abusend/browser/react" work as usual. Pin the versioned URL for reproducible npm ci. An edge built without the tarballs answers 503 sdk_tarball_unavailable (its operator runs sdk/build-tarballs.sh). The <script> tag and the loader snippet need no install.

abusend.mjs/abusend.js are generated from core.js by npm run build (also run by npm test and npm pack). npm test runs the SDK tests in Node (no browser needed).

Quickstart

<script src="https://rep.example.com/sdk/v1/abusend.js" data-site-key="pk_live_…" async></script>
// App checks (SMS, e-mail, KYC, custom): your UI, your endpoint. Resolve = retry now.
Abusend.onChallenge("sms", async (challenge, { signal }) => {
  await openOtpDialog({ challengeId: challenge.id, signal }); // posts the code to your /otp/verify
});

// Every protected request: token header, checks run on a 428, request sent again.
const res = await Abusend.fetch("/api/topup", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ amount: 100 }),
  action: "topup",
});

That is the whole browser side. Widget checks need no code: connect them in the panel and point a rule at them. Server-rendered forms need not even this: see Forms.

How a challenge runs

  1. Abusend.fetch() gets a fresh token for init.action and sends your request with X-Abusend-Token. Without a token (edge blocked, not configured, rate limited) the request still goes out, without the header: your rules still run, and the action's token requirement applies (token_missing: review where the rules would allow, or deny).
  2. Your server answers 428 with header Abusend-Challenge: 1 and body {"error": "challenge_required", "challenges": [{…}]} (what the Node SDK's guard() and protect() send): 1–3 challenges. Any other response is resolved as is.
  3. The SDK runs every challenge of the 428 at the same time (a rule asking for several checks "all at once" sends them together; "one of these" and "one after another" send one at a time):
    • kind: "widget": loads the provider script from the provider's own host, runs the widget invisibly (a person is asked only when the provider wants it) and posts its token to POST /v1/challenges/{id}/verify on the edge (with the device cookie);
    • kind: "app": calls the handler registered for challenge.check.
  4. When every one completed, it sends your request again with a fresh token and X-Abusend-Challenge: <id>[,<id>…] (every id of the 428, comma-separated). Your server's verdict decides with the results: allow, another check (up to 5 rounds per call: the project's challenge budget, 3 by default), Your app decides (review) or block.

Give-up. When a challenge cannot be completed — the handler rejects or its signal aborts, no handler is registered for an app check, the widget script is blocked, the widget errors or times out, a person closes the hCaptcha challenge — the SDK reports it to the edge (/verify {error: abandoned | script_blocked | timeout | widget_error}) and resolves the 428 response (with several challenges, the other app handlers of the 428 are aborted and reported too: one missing verification is enough to stop); so it does when /verify cannot reach the edge (reporting is retried for at most 10 s). Show something neutral ("We could not verify this request. Please try again."), never the reason. Your project's check settings decide what the next attempt gets (on_incomplete: review or deny).

Reissued. challenge.reissued: true means the same pending challenge came back (e.g. the handler resolved but your server did not report a result yet, or the user retried the action). A widget simply runs again. An app handler is called again with it: your server did not send a new SMS, so show the code input again (your own "resend" button stays yours).

Never rejects. Abusend.fetch() resolves like fetch() and rejects only when fetch() itself does (your server unreachable). The request body is copied up front (string, URLSearchParams, FormData, Blob, ArrayBuffer, a Request) so every retry sends the same data; a ReadableStream body cannot be sent twice, so its 428 is resolved as is.

challengeStatus(action?) tells you what happened: state is "none", "running", "interactive" (a widget waits for a person), then "completed" (app handler resolved), the edge's answer to a widget ("passed", "failed", "error") or "incomplete" after a give-up; check, kind, provider, mode, step (1–3) and error (the give-up reason) describe it. With an action it is the last challenge of that action only.

App checks

const off = Abusend.onChallenge("sms", async (challenge, { signal, action, step }) => {
  // challenge: { id: "ch_…", check: "sms", name: "SMS", kind: "app", provider: "sms", expires_in: 600, reissued: false }
  const code = await askForCode({ signal, again: challenge.reissued }); // your dialog; reject on "Cancel"
  const res = await fetch("/otp/verify", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ challengeId: challenge.id, code }),
    signal,
  });
  if (!res.ok) throw new Error("not verified"); // give up (after your own retries)
});

Widget checks

Connect Turnstile, reCAPTCHA, hCaptcha, Friendly Captcha or GeeTest in the panel (panel → Checks) and point a rule at it. The SDK loads the provider script once, and only from the provider's own hosts (per mode): Turnstile https://challenges.cloudflare.com; reCAPTCHA https://www.google.com, https://www.recaptcha.net, https://www.gstatic.com; hCaptcha https://js.hcaptcha.com, https://hcaptcha.com, https://*.hcaptcha.com; Friendly Captcha https://cdn.jsdelivr.net/npm/@friendlycaptcha/sdk@<exact version>/; GeeTest https://static.geetest.com. A script_url anywhere else is never loaded and is reported as script_blocked.

mode in the background (default) visible (appearance: "visible")
turnstile explicit render, execution: "execute", appearance: "interaction-only"; the dialog opens only if Turnstile asks for a click appearance: "always" in the dialog
recaptcha_v3, recaptcha_enterprise grecaptcha(.enterprise).execute(site_key, { action }) —
recaptcha_v2_invisible explicit invisible render + execute(); Google draws its own puzzle —
recaptcha_v2_checkbox — the "I'm not a robot" checkbox in the dialog (a v2 Checkbox site key)
hcaptcha explicit invisible render + execute(); closing hCaptcha's puzzle is a give-up (abandoned) the checkbox in the dialog; closing a puzzle is not a give-up (Cancel is)
friendly_captcha runs unseen (startMode: "auto"); the dialog opens when it turns interactive or takes more than 5 s the widget and its progress in the dialog
geetest_v4 product: "bind": GeeTest decides whether to show its own puzzle; closing it is a give-up product: "float": the puzzle button in the dialog

action and cData (nonce) are bound where the provider supports them (Turnstile, reCAPTCHA). Friendly Captcha's region: "eu" selects its EU endpoint. For GeeTest the getValidate() object is sent as provider_token (compact JSON). The providers get the dialog's language and theme (GeeTest has no Turkish: English then).

A background widget that does not answer within timeout_ms is reported as timeout; once a person has to act, its timer restarts (at least 30 s). A visible widget waits up to the check's timeout (timeout_ms, at most 5 minutes).

abusend's own checks

abusend_pow, abusend_hold and abusend_trace load nothing and talk to no third party: the edge verifies them itself.

The verification dialog

When a person has to do a check — a visible checkbox or puzzle, or a background check that turns interactive — the SDK draws a small dialog: a card over a dimmed page (a bottom sheet on phones) with a title, the provider's widget, Cancel (reported as abandoned, like Escape) and a short "Verified" state before it closes. It is accessible (role="dialog", aria-modal, labelled, focus moved in and restored, reduced motion respected), lives in a Shadow DOM (your CSS can't break it and it can't break yours) and keeps the provider's widget in your page's DOM, where providers expect it. It sits just below the providers' own puzzle popups (z-index 1999999990).

Its look comes from the project (panel → Project settings → Verification dialog: theme, accent color, "Protected by abusend"), sent as ui with each widget challenge. A page can override it:

Abusend.configure({
  ui: {
    theme: "dark",            // "auto" (default: the visitor's system) · "light" · "dark"
    accent: "#16a34a",        // icon, focus ring, spinner
    lang: "tr",               // default: <html lang>, then the browser's languages
    branding: false,          // hide "Protected by abusend"
    text: { title: "One more step" }, // any of: title, subtitle, loading, verifying,
  },                          // verified, failed, blocked, cancel, protectedBy ({brand}), close
});

Texts ship in English, Turkish, German, French, Spanish, Italian, Portuguese and Dutch. On the script tag: data-ui-theme, data-ui-accent, data-ui-lang, and data-ui="off". ui: false (or data-ui="off") turns the dialog off: widgets that need a person then appear in a small box in the bottom-right corner. To draw your own UI, pass challengeContainer (an element or selector): widgets render there instead of the dialog, and challengeStatus() / useAbusend().challenge tell you when one runs (state: "interactive" when it needs the person).

Content Security Policy: add the provider to script-src and frame-src, e.g. https://challenges.cloudflare.com (Turnstile), https://www.google.com/recaptcha/, https://www.gstatic.com/recaptcha/ and https://www.recaptcha.net (reCAPTCHA), https://hcaptcha.com and https://*.hcaptcha.com (hCaptcha), https://cdn.jsdelivr.net/npm/@friendlycaptcha/ (script) and https://*.frcapi.com (frame) for Friendly Captcha, and the GeeTest hosts listed in the integration guide; and your abusend edge to connect-src. The provider script gets the nonce of the abusend.js tag (or configure({ nonce })). The edge-served abusend.js loads the verification dialog (and abusend's own checks) only when a verification arrives, from the same edge host (/sdk/v1/c/…, with Subresource Integrity and the same nonce): no CSP change beyond the edge in script-src. The dialog needs no CSP change: its styles are a constructable stylesheet; in browsers without one it falls back to a <style> with the same nonce.

Forms

Server-rendered apps get widget and app checks without JavaScript of their own:

<script src="https://rep.example.com/sdk/v1/abusend.js" data-site-key="pk_live_…" async></script>

<form action="/login" method="post" data-abusend-action="login">…</form>

The SDK submits data-abusend-action forms through Abusend.fetch(form.action, { method, body, action }) — application/x-www-form-urlencoded by default, FormData for enctype="multipart/form-data", the fields in the query string for GET, the clicked button's name/value included — so the token travels in X-Abusend-Token. Challenges run as above. Then it follows the final response:

Answer form posts with redirect-after-POST (303 to the next page): a replaced document keeps the form page's URL and history entry. Before the SDK follows the response it dispatches a cancelable abusend:response event on the form (event.detail.response); call preventDefault() to handle it yourself. If your server cannot be reached through fetch(), the form is submitted natively. If the script itself is blocked, the browser submits the form natively without a token.

data-abusend-native keeps the browser's own submit: the SDK puts the token into a hidden abusend_token input (data-abusend-field renames it) and submits. Such forms cannot run checks: a verdict that asks for one ends as not completed (your check's on_incomplete). Abusend.protect(form, action) is the same as setting data-abusend-action.

Script attributes: data-site-key, data-endpoint (defaults to the script's origin), data-timeout (ms), data-probe-pin (see Probe key pinning — not needed for the edge-served script).

SPA without a bundler: loader snippet

Code that calls Abusend.fetch() directly breaks when an ad blocker, a CSP rule or a flaky network stops abusend.js from loading (window.Abusend is undefined). Paste this loader into your page instead of the <script src> tag. It defines window.Abusend right away, loads the SDK, and queues calls until it is ready:

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

Replace the edge URL and the site key in the last line; the 3000 is the load timeout in ms. The same file ships as loader.html in the npm package. Then, in your app code:

Abusend.onChallenge("sms", async (challenge, { signal }) => { /* your OTP dialog */ });

const res = await Abusend.fetch("/api/register", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify(form),
  action: "register",
});

ES module / SPA with a bundler

import { configure, fetch as abusendFetch, onChallenge, execute } from "@abusend/browser";

configure({ siteKey: "pk_live_…", endpoint: "https://rep.example.com" }); // required for the module build
onChallenge("email", async (challenge, { signal }) => { /* … */ });

const res = await abusendFetch("/api/register", { method: "POST", body: JSON.stringify(form), action: "register" });

// Or only the token, for your own client:
const token = await execute({ action: "register" }); // never throws: "" on failure

The module build does not detect its own URL, so always call configure({ siteKey, endpoint }). If you forget, calls send nothing: execute() resolves "", getToken() rejects with not_configured, and the SDK logs one console.warn explaining how to configure it. Evaluating the module never throws, including during server-side rendering (no window).

With your own HTTP client, send the token in X-Abusend-Token, handle the 428 yourself (see How a challenge runs) and resend with a fresh token and X-Abusend-Challenge (the comma-separated ids of the 428's challenges). Native and mobile apps do the same with the same 428 JSON.

Importing the module from the edge URL with a static import fails the whole module when the URL is blocked. Load it dynamically and fall back:

const abusend = import("https://rep.example.com/sdk/v1/abusend.mjs")
  .then((m) => m.configure({ siteKey: "pk_live_…", endpoint: "https://rep.example.com" }))
  .catch(() => null);

async function protectedFetch(url, init) {
  const sdk = await Promise.race([abusend, new Promise((r) => setTimeout(() => r(null), 3000))]);
  return sdk ? sdk.fetch(url, init) : fetch(url, init);
}

Probe key pinning

Each token request runs an encrypted probe handshake with the edge. The SDK verifies the edge's ephemeral key against a pinned Ed25519 signing key (anti-MITM) before it trusts anything, so it needs to know that key:

The SDK always reads GET <endpoint>/v1/probe/pubkey once (public, cacheable): it names the probe build the edge serves, whose obfuscated, per-build variant is the only probe the SDK runs (the SDK itself contains no probe program; see docs/PROBE.md §2.1). Precedence for the key: an explicit probePin / data-probe-pin (or an edge-injected key) wins over the fetched one. If the build or the key cannot be resolved (the fetch is blocked), or the variant cannot be loaded, the probe simply runs degraded — the token then carries probe.status = "failed", a signal your rules can use (it weighs into risk), getToken()/execute() still never throw, and your page is unaffected. The SDK never trusts an unsigned handshake.

React

import { useState } from "react";
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" });
  const [otp, setOtp] = useState<null | { id: string; done: () => void; cancel: () => void }>(null);
  const [error, setError] = useState("");

  // The SMS step: show the dialog, resolve when your server accepted the code.
  useOnChallenge("sms", (ch, { signal }) =>
    new Promise<void>((resolve, reject) => {
      signal.addEventListener("abort", () => { setOtp(null); reject(signal.reason); });
      setOtp({ id: ch.id, done: () => { setOtp(null); resolve(); }, cancel: () => { setOtp(null); reject(new Error("cancelled")); } });
    }),
  );

  async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const body = JSON.stringify(Object.fromEntries(new FormData(e.currentTarget)));
    const res = await fetch("/api/topup", { method: "POST", headers: { "Content-Type": "application/json" }, body });
    if (res.status === 428 || res.status === 403) setError("We could not verify this request. Please try again.");
  }
  const verifying = challenge.state === "running" || challenge.state === "interactive";
  return (
    <form onSubmit={onSubmit}>
      …<button disabled={!ready || verifying}>{verifying ? "Verifying…" : "Top up"}</button>
      {otp && <OtpDialog challengeId={otp.id} onVerified={otp.done} onCancel={otp.cancel} />}
      {error && <p role="alert">{error}</p>}
    </form>
  );
}

useAbusend(options?) returns { fetch, getToken, ready, status, challenge }:

useOnChallenge(check, handler) registers an app-check handler while the component is mounted and always calls the latest handler. The SDK is a singleton: configuration and handlers are global (one handler per check).

Device id

The edge gives each browser a signed, per-project device id (d1.…). The SDK keeps it in localStorage["abusend.did"] and sends it with each token request, and the edge also sets a partitioned cookie (__Host-ab_did); token requests and /verify are sent with credentials for that (a widget challenge issued to a device is only verified with that device's cookie). If the project turns device ids off, the edge answers device_id: null and the SDK deletes its copy. Storage that is disabled or throws is ignored. The device id is an online identifier, i.e. personal data under KVKK/GDPR: mention it (and the cookie) in your privacy / cookie notice; see PRIVACY.md.

Since 2.1 the probe also reports whether the browser randomises its canvas / audio fingerprints (it draws the same scene twice and reads back a solid fill). Projects that opt in to device recognition (privacy.device_id = recognition) use that to never recognise a randomised browser; nothing changes in the SDK API.

API

configure({ siteKey?, endpoint?, timeout?, field?, challengeContainer?, ui?, nonce?, probePin? }) Set or change configuration. challengeContainer: element or selector where a widget that needs a person is shown instead of the verification dialog. ui: the dialog's look and texts, or false to turn it off. nonce: CSP nonce for the provider script (default: the nonce of the abusend.js tag). probePin: see Probe key pinning. Returns the SDK.
fetch(url, init?, options?) fetch() with a fresh token in X-Abusend-Token; runs the challenges of 428 answers (up to 5 rounds) and sends the request again. init.action names the action; options: { action?, timeout?, header? } or the action as a string. See How a challenge runs.
onChallenge(check, handler) Registers the handler of an app check: handler(challenge, { signal, action, step }) → Promise (resolve = retry now, reject = give up). null removes it. Returns a function that removes it.
challengeStatus(action?) { state, id, check, kind, provider, mode, action, step, error } of the last challenge (of action, if given).
getToken(action) / getToken({ action?, timeout?, fallback? }) Promise<string> with a fresh single-use token, for your own HTTP client. Rejects with AbusendError, or resolves to fallback when one is given. HTTP 429 resolves to "" (or the fallback).
execute(action) / execute({ action?, timeout?, fallback? }) Same as getToken but never rejects: "" (or fallback) on any failure.
protect(form, action?) Same as adding data-abusend-action to a form.
ready Promise<void> that never rejects (also a named ES module export).
status() "ready", "challenge" while a challenge runs, or "unavailable" after a token request could not reach the edge (blocked, offline, timeout). The loader also reports "loading".
version SDK version.
AbusendError Error class (err instanceof AbusendError).

The challenge object (Challenge in index.d.ts): id, check, name, kind ("widget" / "app"), provider, expires_in, reissued; widget checks add site_key, mode (turnstile, recaptcha_v3, recaptcha_v2_invisible, recaptcha_v2_checkbox, recaptcha_enterprise, hcaptcha, friendly_captcha, geetest_v4, abusend_pow, abusend_hold, abusend_trace), action, nonce, script_url, timeout_ms, appearance ("visible", or absent), ui (the project's dialog look), for Friendly Captcha in the EU region, and for abusend's own checks params.

Errors

getToken() without fallback rejects with a AbusendError (an Error subclass with a code): err instanceof Abusend.AbusendError works, and so does checking err.code:

code meaning fix
not_configured no site key or endpoint (also one console.warn with the fix) data-site-key or configure({ siteKey, endpoint }), in React <AbusendProvider siteKey endpoint>
timeout no answer in time network; raise timeout
network request blocked (ad blocker, CSP connect-src, offline, TLS) allow the edge in CSP
unsupported fetch is not available very old browser
invalid_site_key unknown key copy the site key from the panel
origin_not_allowed / origin_not_configured page origin not allowed panel → Settings → Allowed origins (live projects: https:// origins only). The panel lists refused origins.
invalid_action action not ^[a-z0-9_.-]{1,32}$ use lower-case names like login
http_<status> HTTP error without a JSON body check the edge / proxy

In all these cases Abusend.fetch() and forms still send your request, without a token; your rules still run on your server and the action's token requirement applies. Rate limits are not errors: when /v1/assess answers 429 (rate_limited), the SDK resolves "" (or the fallback) and logs one console.warn per page. The SDK also logs the server message of the other API errors with console.warn.

Types

index.d.ts covers the ES module (default export plus getToken, execute, configure, protect, ready, status, version, AbusendError, fetch, onChallenge, challengeStatus; types Challenge, ChallengeHandler, ChallengeContext, ChallengeStatus, ChallengeRequiredBody, AbusendRequestInit), the global window.Abusend of the classic script and of the loader (AbusendLoader), and the React bindings. Import useAbusend/useOnChallenge/AbusendProvider from @abusend/browser/react only. Both entry points share this one declaration file.

With the classic script and TypeScript, add the global types:

/// <reference types="@abusend/browser" />
const res = await window.Abusend.fetch("/api/login", { method: "POST", body, action: "login" });