@abusend/node
Server SDK for abusend, the rule and orchestration layer in front of the verification tools you already use. Integrate once: your server asks abusend for a verdict with the business context your rules need (the user, the amount, …); your rules decide who gets which check — Turnstile, reCAPTCHA or hCaptcha run by the browser SDK, or your own SMS / e-mail / KYC step — and this SDK runs the flow: hooks when a check is issued, the HTTP 428 the browser SDK understands, and the final allow / "your app decides" / block.
- One framework-neutral core on the Web
Request/ResponseAPI:guard(request)for Next.js route handlers, Hono, Workers, Deno, Bun;protect()for Express/Connect. - Never fails open: outages, timeouts, 5xx and 429 become
review(ordeny), neverallow, andonReview: "continue"never lets an unfinished verification through. - TypeScript, zero runtime dependencies, Node ≥ 18 (global
fetch), ESM and CommonJS.
Install
The package is not on the public npm registry: your abusend edge serves it.
npm install https://rep.example.com/sdk/v1/abusend-node.tgz # current version
npm install https://rep.example.com/sdk/v1/abusend-node-3.1.0.tgz # pinned (recommended for lockfiles)
Replace rep.example.com with your edge. The package name stays @abusend/node. Pin the
versioned URL for reproducible npm ci (the X-Abusend-SDK-Version response header shows
the current version). An edge built without the tarballs answers
503 sdk_tarball_unavailable; its operator runs sdk/build-tarballs.sh.
Quickstart
import { Abusend } from "@abusend/node";
const abusend = new Abusend({ secretKey: process.env.ABUSEND_SECRET_KEY!, endpoint: "https://rep.example.com" });
export async function POST(request: Request) { // Next.js route handler (any Fetch runtime)
const blocked = await abusend.guard(request, { action: "topup", user: { id: userId }, context: { amount: 100 }, onReview: "continue" });
if (blocked) return blocked; // 428 challenge · 403 block
return Response.json({ ok: true }); // allowed — do the work
}
app.post("/api/topup", express.json(), abusend.protect({ action: "topup", user, context, onReview: "continue" }), handler); // Express
The browser side is Abusend.fetch("/api/topup", { method: "POST", body, action: "topup" })
from @abusend/browser: it sends the token, runs the check of a 428 and retries. Use an
opaque user id (your database id, or an HMAC of it with a key only you hold) — never an
e-mail address or phone number.
guard(request, options)
const blocked = await abusend.guard(request, {
action: "topup", // or (request) => …
user: async (req) => ({ id: userIdFor(session), attributes: { api_calls_total: session.calls } }),
context: { amount: body.amount, currency: "USD" }, // this request's facts → context.* in rules
checks: { sms: async (challenge) => sms.sendOtp(session.phone, challenge.id) }, // first issue only
onReview: "continue", // required
});
if (blocked) return blocked;
const verdict = abusend.verdictOf(request); // log verdict.id
- Reads
X-Abusend-TokenandX-Abusend-Challenge(comma-separatedch_…ids; the well-formed ones, at most 3, are forwarded aschallenge_ids) — headers only, the body is never consumed, so your handler can stillawait request.json(). Read a native form's token yourself withtoken: (req) => …. - Client IP: the right-most
X-Forwarded-Forentry, elseX-Real-IP— right behind a proxy that sets them. When clients can reach the app directly, passclientIp: (req) => …. action,user,contextandtokentake a value or(request) => value | Promise<value>.- Resolves
nullto continue, or theResponseto return:
| verdict | answer |
|---|---|
allow |
null |
challenge |
for each of verdict.challenges (1–3) that is new (reissued: false), runs checks[challenge.check](challenge, request); then 428 {"error": "challenge_required", "challenges": [{…}]} with header Abusend-Challenge: 1 (or your onChallenge(request, verdict)) |
deny |
403 {"error": "forbidden"} (or your onDeny(request, verdict)) |
review |
see onReview |
- A
checkshook that throws: the verdict's app challenges are reportedabandoned,onErroris called and the answer is 503{"error": "verification_unavailable"}. - A rule may ask for several checks: one of them (picked at random by weight — your hooks see only the one issued), one after another (one per 428) or all at once (several challenges in one 428: each new one runs its own hook).
onVerdict(verdict, request)sees every verdict;abusend.verdictOf(request)returns it afterwards.
onReview
review means your app decides. The option is required, so review is never silently
allowed:
onReview |
review decided by a rule / the token requirement (decided_by.type rule, token) |
review decided by a verification, a limit or an outage (check, limit, outage, others) |
|---|---|---|
"continue" |
continue (null / next()) |
403 {"error": "verification_incomplete", "retry_after_seconds"?} |
"block" |
403 {"error": "forbidden"} |
403 {"error": "verification_incomplete", …} |
(request, verdict) => Response | null |
your answer (null continues) |
your answer (null continues) |
So with the quickstart config an unfinished check (someone who never enters the SMS code)
is never let through. retry_after_seconds is set when a limit decided (e.g.
check_rate_limited: "try again in an hour"). The SDK sends your choice as
review_handling (shown in the panel; it never changes the decision).
Express: protect(options)
app.post("/api/topup", express.json(), abusend.protect({
action: "topup",
user: (req) => ({ id: userIdFor(req.session.userId) }),
context: (req) => ({ amount: Number(req.body.amount) }),
checks: { sms: (challenge, req) => sms.sendOtp(req.session.phone) },
onReview: "continue",
}), (req, res) => res.json({ ok: true, verdict: req.abusend!.id }));
The same core as guard(): req.abusend is the verdict; next() continues; 428 / 403 /
503 as above. Differences: the client IP is req.ip (set Express trust proxy behind a
load balancer, or pass clientIp); the token is X-Abusend-Token, else the
abusend_token body field of a native form (tokenField renames it); onReview,
onDeny and onChallenge handlers are Express-style (req, res, verdict, next). Errors
thrown by your handlers go to next(err); abusend's own failures never become a 500.
Your OTP endpoint (app checks)
The check hook sends the code; your endpoint verifies it and reports the result:
app.post("/otp/verify", express.json(), async (req, res) => {
const id = req.session.challengeId; // stored by the checks.sms hook (recommended),
const ok = await sms.checkOtp(req.session.phone, req.body.code); // or req.body.challengeId
const tries = (req.session.otpTries = (req.session.otpTries ?? 0) + 1);
if (!ok && tries < 3) return res.status(400).json({ retry: true }); // a typo is not a failed check
await abusend.challenges.result(id, ok ? "passed" : "failed", { userId: userIdFor(req.session.userId) });
res.json({ ok }); // the browser handler resolves → the request is retried
});
- Report
failedonly after your own retry limit;abandonedwhen the user gives up. userIdmust be the user the challenge was issued for (theuser.idof the verdict); a challenge id from the client alone is not enough to pass it for someone else.- The first result wins: a later different result gets 409
challenge_resolved, e.g. when the browser already reported the challenge as abandoned. reissued: true(the same pending challenge came back) never runs the hook again: a "resend code" button is yours.
Challenges (HTTP 428)
The 428 contract, for any client (the browser SDK, native and mobile apps): status 428,
header Abusend-Challenge: 1, body {"error": "challenge_required", "challenges": [{id, check, name, kind, provider, expires_in, reissued, …}]} (1–3 challenges, to run all at
once). The client runs every one and retries the same request with a fresh token and
X-Abusend-Challenge: <id>[,<id>…] (every id of the 428); the next verdict sees the results
(another check — up to 3 per flow —, allow, review or block). A challenge still pending
comes back (reissued: true) until it has a result.
Abusend.challengeResponse(verdict) builds that Response, Abusend.challengeBody(verdict)
its { status, headers, body }.
Events
Report what abusend cannot see — a completed top-up, payment, withdrawal — after it
happened, and write rules on it: event_count("topup", "1h") >= 5,
event_sum("topup", "24h") > 2000, days_since(event_last("topup")) < 1.
// after the payment went through
await abusend.events.track({
userId: userIdFor(session.userId), // the same opaque id as in your verdicts
type: "topup", // ^[a-z0-9_]{1,32}$
value: 250, // summed by event_sum
id: payment.id, // idempotency: stored once per project
});
track/trackManynever throw into your request path: they resolve{ ok: true, accepted, duplicates }or{ ok: false, accepted, duplicates, error }, and every failure goes toonError. Pass{ throwOnError: true }to get theAbusendErrorthrown instead. Don't await it where latency matters.idmakes an event idempotent (a retry is counted once,duplicates); use your own (payment id, order id). Without one the SDK assigns a random id, so its own retries are safe, yours are not.occurredAt(aDate, an RFC 3339 string or epoch ms) defaults to now; it must be within the last 30 days and not in the future (5 minutes of clock skew are allowed).trackManysends 100 events per request; each request is all or nothing.properties(up to 32 scalar values, 4 KB) are stored with the event; rules don't read them yet. Never put personal data (e-mails, phone numbers) in ids or properties.
Recipes
Next.js route handler
// app/api/topup/route.ts
import { abusend, userIdFor } from "@/lib/abusend";
export async function POST(request: Request) {
const session = await getSession();
const blocked = await abusend.guard(request, {
action: "topup",
user: { id: userIdFor(session.userId), attributes: { api_calls_total: session.apiCalls } },
context: async (req) => ({ amount: Number((await req.clone().json()).amount) }),
checks: { sms: async (ch) => { await saveChallenge(session, ch.id); await sms.send(session.phone); } },
onReview: "continue",
});
if (blocked) return blocked;
return Response.json({ ok: true });
}
(guard() never reads the body; read it from a request.clone() in your callback, or
parse it first and pass values.)
Server-rendered forms. Load abusend.js and mark the form
data-abusend-action="login": the browser SDK submits it with the token, runs widget
checks and app-check handlers, and follows your redirect (answer form posts with
redirect-after-POST). Without any JavaScript, handle the challenge with pages:
app.post("/topup", express.urlencoded({ extended: false }), abusend.protect({
action: "topup", user, context: (req) => ({ amount: Number(req.body.amount) }),
checks: { sms: (ch, req) => sms.send(req.session.phone) },
challengeIds: (req) => req.session.challengeIds, // presented after the OTP page
onChallenge: (req, res, v) => { req.session.challengeIds = v.challenges.map((c) => c.id); req.session.pending = req.body; res.redirect(303, "/otp"); },
onReview: "continue",
}), handler);
// /otp: verify the code → challenges.result(id, "passed", { userId }) → re-post the pending form (or redirect to a confirm page that does)
The challenge ids must be presented: a passed challenge is only credited when the retried
verdict names it (challengeIds); clear them from the session once the flow ends.
Mobile / API-only. Set the action's token requirement to optional in the panel
(there is no browser token) and use app checks only. Your API answers the 428 JSON; the
app shows its OTP screen, your OTP endpoint reports the result, and the app retries with
X-Abusend-Challenge. Without a token, IP signals come from client_ip.
Workers and custom flows. await abusend.verdict({ token, action, user, context, clientIp, challengeIds, reviewHandling }) returns the verdict; answer it yourself.
API reference
new Abusend(options)
| option | default | |
|---|---|---|
secretKey |
— | sk_live_… / sk_test_… (panel → API keys). A pk_ key throws. |
endpoint |
ABUSEND_ENDPOINT env |
your edge, e.g. https://rep.example.com |
timeoutMs |
2000 |
budget per call, retries included |
failure |
"review" |
decision of synthetic verdicts (outage, timeout, 5xx, 429, configuration error): "review" or "deny", never allow |
retries |
2 |
extra attempts on network errors and 5xx (0–2), only within the server's 5 s retry window, never after a 429 |
fetch |
global fetch |
custom fetch (tests, proxies) |
onError |
— | (error: AbusendError) => void for every synthetic verdict and failing hook |
abusend.verdict(params): Promise<Verdict>
params: token?, action, user?: { id, attributes? }, context?, clientIp?,
challengeIds? (≤ 3), reviewHandling? (continue · block · custom), simulate? (test
projects: allow · review · deny). Returns the server's JSON as is:
{ "id": "…", "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_…"},
"challenges": [{"id": "ch_…", "check": "sms", "name": "SMS", "kind": "app", "provider": "sms", "expires_in": 600, "reissued": false}],
"risk": {"score": 12, "level": "low"}, "reasons": [ … ], "signals": { … }, "token_status": "valid",
"user": {"id": "u_…", "attributes": {}}, "action": "topup", "test": false, "created_at": "…" }
decided_by.type says what decided: rule, token, check, limit, builtin,
default, test, or outage (this SDK). Monitoring rules never decide: they show up as
would_decision / would_challenge ({mode, checks}; and reasons with monitor: true); a rule's state
(Monitoring, Enforcing) is the only switch — actions have no mode. signals and risk are hints for
your rules. Throws AbusendError only for integration bugs (401 invalid/revoked key, 400
invalid input); outages return a synthetic verdict:
{ "id": null, "flow_id": null, "decision": "review", "decided_by": {"type": "outage", "code": "unavailable"},
"challenges": [], "risk": {"score": 0, "level": "unknown"}, "signals": null, "degraded": true,
"error": {"code": "unavailable", "message": "timeout: …"} }
(code rate_limited with retry_after_seconds for a 429. A 503 with the API code busy — the service is briefly overloaded, Retry-After: 1 — is an outage like any 5xx: retried within the window, then code unavailable with busy: … in error.message; never allow.) guard()/protect() also
turn configuration errors and failing callbacks (action, user, context) into such a
verdict — with the error's code — and log the first occurrence of each code.
abusend.guard(request, options) / abusend.protect(options) / abusend.verdictOf(request)
See above. Shared options: action (required), onReview (required), user, context,
checks, challengeIds, simulate, onError(error, request), onVerdict(verdict, request),
onDeny, onChallenge, clientIp, token; protect() adds tokenField.
abusend.challenges.result(id, status, { userId })
status: passed · failed · abandoned → POST /v1/challenges/{id}/result →
{ status }. Throws AbusendError 404 challenge_not_found, 409 challenge_resolved,
challenge_user_mismatch, challenge_kind_mismatch (a widget challenge).
abusend.feedback({ verdictId, label, note? })
Labels the verdict's flow fraud or legit.
abusend.users
get(id), update(id, attributes) (merged; null deletes one), delete(id) (erasure,
idempotent: { ok: true, erased }; the user's events go too).
abusend.events.track(event, options?) / abusend.events.trackMany(events, options?)
event: { userId, type, value?, properties?, occurredAt?, id? } → POST /v1/events →
{ ok: true, accepted, duplicates }, or { ok: false, accepted, duplicates, error } with
error.code invalid_event (checked before sending, or by the server),
invalid_occurred_at, event_value_out_of_range (|value| > 1e15), invalid_events, rate_limited, timeout, network, …
options.throwOnError throws the error instead. See Events.
Helpers
Abusend.challengeResponse(verdict) / challengeResponse(verdict) → the 428 Response;
Abusend.challengeBody(verdict) / challengeBody(verdict) → { status, headers, body };
continuable(verdict) → whether onReview: "continue" continues it; TOKEN_HEADER,
CHALLENGE_HEADER; normalizeIp(ip).
AbusendError
code (the server's error code, or timeout, network, invalid_response,
invalid_config, invalid_request, invalid_event, unavailable, rate_limited, and in hooks
check_hook_error, action_callback_error, user_callback_error,
context_callback_error, client_ip_callback_error, token_callback_error,
challenge_ids_callback_error), status
(0 for SDK-side errors), requestId (X-Request-Id, quote it to support), retryAfter,
transient (outage vs integration bug), rateLimited.
TypeScript
Types mirror the API's JSON (snake_case fields); only the SDK's own options are camelCase.
Verdict, Challenge, DecidedBy, GuardOptions, ProtectOptions, CheckHook, … are
exported. 3.1 adds the widget fields of a challenge (appearance, region, ui) and the
ChallengeProvider (friendly_captcha, geetest), WidgetMode (recaptcha_v2_checkbox,
friendly_captcha, geetest_v4), ChallengeAppearance and ChallengeUI types. req.abusend is typed for Express.
Development
npm ci
npm test # build (ESM + CJS) + unit tests with a mocked fetch
ABUSEND_URL=https://localhost:8443 ABUSEND_SITE_KEY=pk_test_… ABUSEND_SECRET_KEY=sk_test_… \
NODE_EXTRA_CA_CERTS=./tls.crt npm run test:integration # against a running edge (test project)