@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:
- widget checks — Cloudflare Turnstile, Google reCAPTCHA (v3, v2 invisible, v2 checkbox, Enterprise), hCaptcha, Friendly Captcha, GeeTest — are run here (in the background, or visibly in the SDK's verification dialog) and verified by the abusend edge;
- app checks — your own SMS / e-mail codes, KYC, anything custom — run through a
handler your page registers (
Abusend.onChallenge("sms", …)); your server verifies them and reports the result to abusend.
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
Abusend.fetch()gets a fresh token forinit.actionand sends your request withX-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).- Your server answers 428 with header
Abusend-Challenge: 1and body{"error": "challenge_required", "challenges": [{…}]}(what the Node SDK'sguard()andprotect()send): 1–3 challenges. Any other response is resolved as is. - 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 toPOST /v1/challenges/{id}/verifyon the edge (with the device cookie);kind: "app": calls the handler registered forchallenge.check.
- 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)
});
checkis the key of the check in your project (panel → Checks), e.g."sms".- Resolve when your server verified the code and reported the result
(
challenges.result(id, "passed", { userId })in the Node SDK): the request is sent again. Reject (or letsignalabort) to give up. signalaborts when the caller's request is aborted (init.signal) or the challenge expires (expires_in): close your dialog then.- A mistyped code is not a failed check: let the user retry in your dialog, and report
failedonly after your own retry limit. - Keep the challenge id on your server (in the session) when you can; the Node SDK's
guard()/protect()hooks see it when the challenge is issued. onChallenge(check, null)removes the handler; the returned function removes this one. With the loader snippet, handlers registered before the script loaded are kept.
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.
abusend_pow(in the background): a proof-of-work puzzle (params.pow) solved in Web Workers (ablob:worker, up to 4), or on the main thread in small slices when the page's CSP has noworker-src blob:. Its size follows the verdict's risk level as the check configures it (each step doubles the work).abusend_hold(visible): in the dialog, press and hold two dots one after the other until each ring fills.abusend_trace(visible): move the pointer (or a finger) around a box until the bar fills. Their puzzle, if the check has one, is solved meanwhile.- The pointer events on the task's box (times, positions, press / release, whether the page
dispatched them) are sent with the answer (
solution.telemetry) and scored by the edge's heuristics; the raw recording is not stored. When an attempt did not convince, the edge says so and the task starts over (a short hint says what was missing), up to the challenge's attempts. - Like every client-side check these can be beaten; they add cost and friction for automation. The visible tasks need a pointer or touch: pair them with another check (a rule's group "one of" with SMS, say) where keyboard-only visitors must pass.
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:
- a redirect (
res.redirected) →location.assign(res.url); - anything else replaces the document with the response (HTML as is; other content as text).
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",
});
- Calls made before the script loaded wait for it. If it fails to load (
onerror) they resolve at once, and if it has not loaded after the timeout they resolve then:getToken()/execute()with"",Abusend.fetch()by sending your request without a token (no checks can run then). - With the loader,
Abusend.getToken()andAbusend.execute()never reject.Abusend.status()is"loading","ready"or"unavailable", andAbusend.readyresolves (never rejects) when the SDK loaded or the loader gave up. onChallenge()handlers registered before the script loaded are kept and handed to the SDK.- A script that loads after the timeout is still used for later calls.
- Under a strict CSP, give the inline script your page's
nonce(or its hash), or put it in a file you serve yourself.
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:
-
Edge-served script (
/sdk/v1/abusend.js, and the ESM at/sdk/v1/abusend.mjs): nothing to configure. The edge bakes its current signing key into the script it serves you. -
Bundled / npm build (
@abusend/browser), including React: the tarball ships a dev/test placeholder key, not your edge's key. When you do not pin one explicitly, the SDK uses the edge's current key fromGET <endpoint>/v1/probe/pubkey— no configuration required. You may pin it yourself instead:import { configure } from "@abusend/browser"; configure({ siteKey: "pk_live_…", endpoint: "https://rep.example.com", probePin: "<base64url Ed25519 pubkey>" });or, on a classic script tag,
data-probe-pin="<base64url Ed25519 pubkey>", or in React<AbusendProvider probePin="…">. Get the value fromGET <endpoint>/v1/probe/pubkey(thepinfield) or from your panel.
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 }:
fetch(url, init?)isAbusend.fetch()with the hook'sactionas the default (init.actionwins).challengeischallengeStatus(action)— the last challenge for the hook's action (without an action: the page's last challenge); the component re-renders when a challenge starts and ends, so two forms on one page don't both show "Verifying…".getToken()resolves""(or thefallbackyou pass) on any failure; passthrowOnError: trueto receiveAbusendErrorrejections.statusis the SDK status ("challenge"while one runs; withaction, only while that action's challenge runs);readyturns true after the first effect.- Options:
siteKey,endpoint,timeout,field,challengeContainer,ui,probePin,action,throwOnError. They override the provider's props; the hook works without a provider if you passsiteKeyandendpoint. Without either and without a globalconfigure(),getToken()resolves""and warns once how to configure it.
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" });