abusend Express demo: Orbit
Orbit is a small, realistic web app: prepaid credits for an API. You register, top up your balance with a (demo) card and spend 10 cents per API call. Every top-up goes through abusend, and a handful of readable rules decide who gets which verification:
| rule (in the panel) | outcome |
|---|---|
New account, big top-up: hours_since(user.registered_at) < 24 && context.amount >= 500 |
Verify with Captcha or hCaptcha (70/30): a check group, one of the two picked at random |
Never used the API, top-up of 100 or more: user.api_calls_total == 0 && context.amount >= 100 |
Verify with SMS: Orbit's own OTP step |
5 or more top-ups in an hour: event_count("topup", "1h") >= 5 |
Verify with SMS |
More than 2,000 in 24 hours: event_sum("topup", "24h") + context.amount > 2000 |
Your app decides: Orbit holds the top-up for a manual review |
The last two read events: after every credited top-up Orbit calls
abusend.events.track({ userId, type: "topup", value: amount, id }), so rules can count and
sum what abusend itself cannot see. (event_sum covers the top-ups already made; the
+ context.amount counts the one being attempted. Leave it out for "already over 2,000".)
What it shows:
- Browser: the loader snippet
in
<head>andAbusend.fetch("/wallet/topup", { …, action: "topup" }). On a 428 the SDK runs the verification: Turnstile and hCaptcha by itself (provider test keys, invisible), the SMS step throughAbusend.onChallenge("sms", …), which opens Orbit's OTP dialog. - Server:
abusend.protect()from@abusend/nodewithuser.attributes(api_calls_total,registered_at),context.amount, achecks.smshook that sends the code,onReview(Orbit holds top-ups a rule sends to "Your app decides", and never lets an unfinished verification through) andevents.trackafter the top-up. - The SMS inbox at
/check_sms: a fake phone that shows the codes Orbit "sent", linked from every page. - The "Why?" panel on the wallet: the last abusend decision in plain words (which rule, which verification, what Orbit did), for demos, and how long abusend took to answer: each verdict ("Verdict 1 · Verify with SMS · 23 ms"), the result Orbit reported for the SMS code and the event it tracked, with a total.
5-minute setup
You need an abusend test project (pk_test_… / sk_test_…): on a test project
http://localhost:* is an allowed origin and the captcha providers' test keys work. Either
use a hosted abusend and create a test project in the panel, or run abusend locally
(AGENTS.md §3 in the repository: make probe-variants, then go run ./cmd/abusend serve;
the bootstrap admin gets a test project).
cd examples/express-demo
# 1. The Node SDK: this demo links the local one (file:../../sdk/node). Your own app installs
# it from your edge instead: npm install https://<edge>/sdk/v1/abusend-node.tgz
(cd ../../sdk/node && npm ci && npm run build)
npm install
# 2. Configure the test project: the topup action, the attributes, the checks (Turnstile and
# hCaptcha with the providers' test keys, the SMS app check) and the four rules above,
# enforcing. Idempotent; refuses live projects.
ABUSEND_PANEL_URL=https://<your panel> ABUSEND_PANEL_TOKEN=abp_… ABUSEND_SITE_KEY=pk_test_… \
npm run setup
# 3. Build the assets and start
npm run build
ABUSEND_URL=https://<your edge> ABUSEND_SITE_KEY=pk_test_… ABUSEND_SECRET_KEY=sk_test_… npm start
# → http://localhost:3000
ABUSEND_PANEL_TOKEN is a personal access token with the admin role (panel → Account →
API & MCP). You can also create the same setup by hand in the panel (Checks, Rules; the
script's header lists every value). The rules are Enforcing and scoped to the topup
action, whose token is optional: a request without a browser token (the SDK blocked, or
a script calling the endpoint directly) meets the same rules.
While changing the demo, npm run dev rebuilds the assets on change (vite build --watch)
and restarts the server (node --watch).
Local edge with a self-signed certificate
- Node:
NODE_EXTRA_CA_CERTS=/path/to/tls.crt npm starttrusts the development certificate without turning off TLS verification. - Browser: open
https://localhost:8443/healthzonce and accept the certificate, otherwise the SDK script cannot load.
Configuration
| env | default | |
|---|---|---|
ABUSEND_URL |
https://localhost:8443 |
the edge: serves /sdk/v1/abusend.js and /v1/* |
ABUSEND_SITE_KEY |
(required) | public site key (pk_test_…) |
ABUSEND_SECRET_KEY |
(required) | secret key (sk_test_…, panel → API keys) |
ABUSEND_FAILURE |
review |
the Node SDK's failure: the decision when the edge is unreachable, times out or answers 429 (review or deny, never allow) |
ABUSEND_USER_ID_KEY |
a demo value | HMAC key for the opaque user ids; in production a random secret, stored like the secret key |
PORT |
3000 |
|
DATA_DIR |
.data/ |
where users, sessions and the SMS outbox are kept (JSON files) |
NODE_ENV |
production caches the compiled views |
Try it
Open the SMS inbox in a second tab (every page links to it), register, then on the wallet:
- Add $20. No rule matches: the balance goes up at once. Why? says "No rule matched this top-up".
- Add $150. The account never made an API call, so the rule asks for SMS: the OTP
dialog opens and the code appears in the SMS inbox. A wrong code shows "2 tries left" (a
typo is not a failed check); the right one → Orbit reports
passed→ the SDK sends the top-up again → allowed. - Add $20 five more times. The sixth top-up within an hour needs a code again: the
event_count("topup", "1h") >= 5rule, fed byevents.track. The wallet's "What abusend sees" box shows the counts. - New account, add $600. The check group runs one of Captcha / hCaptcha (70/30; the test
keys pass invisibly), then the SMS step. Make an API call (
api_calls_totalbecomes 1), then add $1,500: the 24-hour total would pass 2,000, the rule says Your app decides and Orbit shows the top-up as Held for review (it does not report held top-ups as events). - Cancel the SMS dialog: the SDK reports the verification as not completed and the page
shows a neutral "We could not verify this top-up". Trying again right away is declined:
an unfinished SMS check is never let through (
on_incompleteof app checks is Block). Three wrong codes end the same way (on_fail).
The Why? panel also mentions what rules in Monitoring would have done; headless or
automated browsers often trip the seeded High risk baseline rule, which only reports
until you enforce it. Signals and risk are hints for your rules, not verdicts.
How the SMS inbox works
src/lib/sms.js stands in for an SMS provider: send(to, text) appends the message to
.data/sms.json and logs it. /check_sms renders the messages sent to the logged-in user's
phone number, newest first, and assets/sms.js polls /check_sms/messages every 2 seconds
to add new ones (with a Copy code button). In your app, send() is where you call your
SMS provider.
The flow of one SMS step:
protect()gets a verdict that asks for thesmscheck and calls thechecks.smshook (src/lib/otp.jssendCode): Orbit creates a 6-digit code, keeps it with the challenge id in the session, and "sends" it. The hook does not run again when the same pending challenge comes back (challenge.reissued).- The browser SDK receives the 428 and calls the page's
onChallenge("sms")handler (assets/wallet.js), which opens the OTP dialog. - The dialog posts the code to
POST /otp/verify(src/routes/otp.js): three tries, thenabusend.challenges.result(id, "passed" | "failed", { userId }). The challenge id comes from the session, never from the browser. - The handler resolves, the SDK sends the top-up again with
X-Abusend-Challenge, and the next verdict decides with the result.
abusend never sees the phone number or the code, only the outcome.
How the "Why?" timings are measured
The Node SDK does not expose how long a call took, so the demo measures it (src/lib/why.js):
startClock, a small middleware right beforeprotect()on/wallet/topup, stampsperformance.now().protect()asks abusend for the verdict straight away and callsonVerdictwhen the answer is in, so the difference is the round trip to abusend plus the SDK's own work. Each verdict of an attempt (the first, and one per retry after a verification) gets its own time.why.timed(req, what, fn)wraps the other calls to abusend on the path (challenges.resultinsrc/lib/otp.js,events.trackinsrc/routes/wallet.js) and records their time too, also when they fail.
Times include your server's network path to the edge, so they are larger than abusend's own processing time. They are a demo aid, not a benchmark.
How events feed rules
After a credited top-up, src/routes/wallet.js reports it:
await abusend.events.track({ userId: req.abusendUserId, type: "topup", value: amount, id: topup.id });
userIdis the same opaque id the verdicts use;id(the top-up id) makes a retry count once.event_count("topup", "1h")counts these events per user in the last hour,event_sum("topup", "24h")sums theirvalue;event_last("topup")withhours_sincegives the time since the last one. See Events.track()never throws into your request path. Orbit awaits it only so that a quick demo's next top-up already sees the previous one; don't await it where latency matters.- The type
topupshows up under "Your data" in the panel once the first event arrives.
Code tour
src/
server.js config check, listen
app.js Express: Eta views, hashed assets, sessions, routes, error pages
config.js environment
routes/
pages.js landing page
auth.js register, log in, log out (server-rendered forms, redirect-after-POST)
wallet.js wallet page, POST /wallet/topup (protect() → credit or hold → events.track),
the pretend API call, GET /wallet/why (the Why? card alone)
otp.js POST /otp/verify: check the code, report the result
sms.js /check_sms and its JSON feed
lib/
abusend.js the client, the opaque user id, user attributes, the loader snippet
otp.js the checks.sms hook (send) and the code check (verify, report)
why.js records each verdict of an attempt (onVerdict) with its timing and explains it
users.js users, balances, top-ups in .data/users.json (scrypt passwords)
sessions.js server-side sessions in .data/sessions.json
sms.js the fake SMS provider (outbox)
assets.js reads Vite's manifest for the views
views/ Eta: layout.eta + partials (nav, flash, footer, why, OTP dialog, …) + pages
assets/ page scripts and styles, built by Vite into dist/ (hashed, with a manifest)
scripts/
setup-project.mjs configures the test project (npm run setup)
dev.mjs npm run dev
e2e.mjs Playwright end-to-end check
- The user id sent to abusend is an HMAC of the username with
ABUSEND_USER_ID_KEY(src/lib/abusend.js): stable, not reversible, never the username, e-mail or phone. new Abusend({ failure, onError }): outages, timeouts and 429 becomereview, whichonReviewanswers with 403 unless a rule decided it: nothing fails open.app.set("trust proxy", "loopback"):req.ip(sent as the client IP) is right behind a local reverse proxy; list your own proxies in production.- The loader snippet is read from
sdk/browser/loader.htmlat start; copying the demo out of the repository, paste it from the browser SDK README instead.
End-to-end check (Playwright)
npm run build
PLAYWRIGHT_MODULE=/path/to/playwright/index.mjs \
ABUSEND_URL=… ABUSEND_SITE_KEY=pk_test_… ABUSEND_SECRET_KEY=sk_test_… NODE_EXTRA_CA_CERTS=… npm run e2e
e2e.mjs starts the server on port 3999 (E2E_PORT) with a fresh data directory and runs,
in headless Chromium: a $20 top-up; a first $150 top-up with the SMS step (a wrong code
first, the code read from the SMS inbox page); five quick top-ups and the events rule; a
cancelled SMS step and the retry after it; three wrong codes; the check group ($600 on a new
account) and a $1,500 top-up that is held for review; the Why? panel along the way. It
prints each step with the challenges seen on the wire (428:sms, 428:captcha,
verify:passed, …).