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

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

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

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

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)