Official Node.js SDK for Maskbreak — network and device fraud signals for browser-SDK-backed visits, plus limited public IP intelligence.
Using Claude Code, Cursor, Copilot, or any AI coding assistant? Paste this one prompt and it wires the whole integration — frontend script, backend check, env var, and a test:
Fetch https://maskbreak.com/integrate.md and follow it to add Maskbreak fraud protection to this app — protect signup, login, and checkout. Start in watch mode (MASKBREAK_MODE). Read the API key from the server-only MASKBREAK_API_KEY environment variable; I will configure the secret separately. Never put it in client-side code. Then show me how to test it.
Never paste the key itself into a prompt: set it in your hosting secrets.
integrate.md is the canonical
machine-readable integration guide, kept in sync with the live API.
npm install @sentinelsup/sdkZero dependencies. Requires Node.js 18+ with built-in fetch. Other runtimes and edge bundlers are not covered by this package's test matrix.
Three steps give you the first complete check, one that carries both the network token and the browser check. The Maskbreak dashboard's setup uses the same names.
1. Add the script to the page with your form, and class="monocle-enriched"
to the <form> element itself (never to an input):
<script async src="https://maskbreak.com/assets/sentinel.js"></script>
<form class="monocle-enriched" method="post" action="/signup">
<!-- your fields; the script adds monocle, sentinel_fp and sentinel_tz -->
</form>Your site sends a Content-Security-Policy header? helmet's default policy does, and it blocks the script until you add Maskbreak's hosts: the list. The hidden fields fill in a second or two after the page loads.
2. Send the check from your server. One field mapping, whichever way the browser part sends it:
| Form field | Sentinel.collect() returns |
Send to the API as |
|---|---|---|
monocle (network token) |
token |
token |
sentinel_fp (browser check) |
fingerprintEventId |
fingerprintEventId |
sentinel_tz (time zone) |
tz |
tz (optional; evaluate() sends it since 0.5.0) |
Start in watch mode: every submission is checked and logged, a submission with
a missing field included (the dashboard then says which half did not arrive),
and nobody is blocked. evaluate() will not send a request without the network
token, so the quick start reports those submissions over plain HTTP:
const Sentinel = require('@sentinelsup/sdk');
const sentinel = new Sentinel(); // reads MASKBREAK_API_KEY from the environment
// Start in watch mode: log Maskbreak's answer and let everyone through.
// When Events look right, set MASKBREAK_MODE=enforce and redeploy.
const MODE = process.env.MASKBREAK_MODE || 'watch';
// evaluate() refuses to send without the network token. In watch mode a
// submission without it is reported anyway, so the dashboard can say so.
async function reportWithoutToken(fields) {
const response = await fetch('https://maskbreak.com/v1/evaluate', {
method: 'POST',
signal: AbortSignal.timeout(5000),
headers: {
Authorization: 'Bearer ' + process.env.MASKBREAK_API_KEY,
'Content-Type': 'application/json'
},
body: JSON.stringify(fields)
});
return response.ok ? response.json() : null;
}
app.post('/signup', async (req, res, next) => {
const body = req.body || {};
// Form fields -> API names. Sentinel.collect() already uses the API names.
const token = body.monocle || body.token;
const fingerprintEventId = body.sentinel_fp || body.fingerprintEventId;
const tz = body.sentinel_tz || body.tz;
let result = null, problem = null, monthUsedUp = false;
try {
if (token) {
result = await sentinel.evaluate({ token, fingerprintEventId, tz });
} else if (MODE !== 'enforce') {
result = await reportWithoutToken({ fingerprintEventId, tz });
}
} catch (err) {
problem = err.message; // an uncaught rejection would end an Express 4 process
// The month's checks are used up: let the action through, logged, until the 1st.
monthUsedUp = err.status === 429 && err.body?.code === 'monthly_quota_exceeded';
}
console.log('[maskbreak]', MODE, result ? result.decision : 'no answer', result ? result.reasons : problem);
// A test or sample answer is never a real visitor's verdict: anyone can send a test token.
if (MODE === 'enforce' && !monthUsedUp && (!result || result.decision !== 'allow' || Sentinel.isFixtureAnswer(result))) {
return res.status(result && result.decision === 'block' ? 403 : 409).json({ error: 'Verification required' });
}
next(); // watch mode, or an allow: your existing handler runs
});3. Submit your form once. Run your app where you can open the page, on
your computer or on your site, and submit the form once. The first check then
appears in the dashboard (Integration tab and Events), and
sentinel.setupStatus() says whether it arrived complete
and what to fix.
A form your JavaScript renders and submits (fetch, React, Next.js, Vue) sends
await window.Sentinel.collect() with its data instead: see
Next.js App Router below. The full enforce policy
(review, missing evidence, test and degraded answers) is in
integrate.md.
Get a free API key (no credit card) at maskbreak.com/signup.
Since v0.3.3, new Sentinel() without a key reads MASKBREAK_API_KEY from the environment; the older SENTINEL_KEY and SENTINEL_API_KEY names are still read as fallbacks.
evaluate()sends the fields the API has read since 23 October 2026:tz,phone, and for login protectionevent,loginSucceeded,succeeded,sessionId,userAgent,ip,reason,verificationTypeandverifiedEvent. See Logins, signups and account events.- The account events
password_change,verification,logoutandaccount_changecan be sent without the browser's token. A login, a signup and a plain check still need it. - A value the API would ignore or misread (an
eventsuch as'signin', aloginSucceededthat is nottrueorfalse) throws aSentinelErrorbefore anything is sent, instead of a check that quietly runs without it. - A check sent without
fingerprintEventIdprints one warning per process: such checks have no browser part.new Sentinel({ warnMissingBrowserPart: false })turns it off. - New
sentinel.setupStatus()(GET /v1/setup) says whether your checks arrive complete and what to fix. - Types for the answer's
account_takeover,account_event,phone,timezone, the scoredemailfields,sandboxandsample.
Illustrative response. The VPN/proxy service name is returned only when known; otherwise it is null. Device fields require available device intelligence, not just a supplied event ID.
{
decision: 'review', // 'allow' | 'review' | 'block' — route on this
risk_score: 65, // 0–100
isSuspicious: true, // legacy flag; route on decision instead
ip: '198.51.100.18',
country: 'NL',
network: {
vpn: true, proxy: false, privacy_relay: false, datacenter: true, anonymous: true,
tor: false, residential: false, service: 'PROTON_VPN'
},
device: { // when fingerprintEventId resolves to device data
antidetect: false, // antidetect browser detected
automation: false, // bot / browser automation
emulator: false, virtual_machine: false, incognito: false,
ip_blocklisted: false, visitor_id: 'abc123', tampering_score: 0
},
reasons: ['vpn_detected', 'datacenter_asn'] // machine-readable codes
}Try the live sample (same shape, no key needed):
curl "https://maskbreak.com/v1/evaluate/sample?scenario=vpn"
With event, email, phone or tz in the request, the answer also carries
account_takeover or account_event, the scored email fields, phone and
timezone: see Logins, signups and account events.
Legacy details / deviceIntel fields are still returned for backwards
compatibility with 0.1.0 integrations.
Add the Maskbreak SDK to the page with your form. One script loads both layers — network (VPN/proxy/datacenter) and device (antidetect/bot/tampering):
<script async src="https://maskbreak.com/assets/sentinel.js"></script>
<!-- class="monocle-enriched" on the form itself, never on an input -->
<form class="monocle-enriched" id="checkout-form">
<!-- The SDK fills in:
<input type="hidden" name="monocle" value="eyJ..."> (network)
<input type="hidden" name="sentinel_fp" value="a1b2..."> (device)
<input type="hidden" name="sentinel_tz" value="Europe/Tallinn"> -->
</form>If your site sends a Content-Security-Policy header (helmet's default policy does), it must allow the SDK's hosts or the browser blocks the script: the list in the integration guide.
A form your JavaScript submits collects the same evidence and sends it to your
backend. collect() waits at most 5 seconds for the device check
(collect({ timeout: ms }) to change it); a value that is not there by then is
null. Check that the script is there, and never hold the form because of it:
const evidence = window.Sentinel ? await window.Sentinel.collect() : {};
fetch('/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
// evidence = { token, fingerprintEventId, tz }, already the API's names
body: JSON.stringify({ email: form.email.value, ...evidence })
});The examples below act on the decision, so they belong after the quick
start's watch mode, once Events look right. evaluate() throws when the
browser sent no token (outside the account events) or the
API cannot be reached, so every handler below catches it. In Express 4 an async handler's uncaught error is an unhandled
rejection, which ends the whole Node process, not just the request. The
examples use this helper, which reads both namings (form fields and
collect()'s), and treat null as "no answer": decide what your endpoint does
then (here: continue).
// Form fields (monocle, sentinel_fp, sentinel_tz) or collect()'s names (token, fingerprintEventId, tz).
const evidence = body => ({
token: body.token || body.monocle,
fingerprintEventId: body.fingerprintEventId || body.sentinel_fp,
tz: body.tz || body.sentinel_tz
});
async function check(input) {
try {
return await sentinel.evaluate(input);
} catch (err) {
console.log('[maskbreak] check unavailable:', err.message);
return null;
}
}Load the script once in app/layout.js
(<Script src="https://maskbreak.com/assets/sentinel.js" strategy="afterInteractive" />
from next/script), collect on submit in the client component that renders the
form, and check in a Route Handler. The key stays in a server-only environment
variable, never a NEXT_PUBLIC_ one.
// app/signup/signup-form.js
'use client';
export default function SignupForm() {
async function onSubmit(event) {
event.preventDefault();
const email = new FormData(event.currentTarget).get('email');
// No script (blocked or not loaded)? The form still goes through.
const evidence = window.Sentinel ? await window.Sentinel.collect() : {};
await fetch('/api/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, ...evidence })
});
}
return (
<form onSubmit={onSubmit}>
<input type="email" name="email" required />
<button type="submit">Sign up</button>
</form>
);
}// app/api/signup/route.js (server only)
import Sentinel from '@sentinelsup/sdk';
const MODE = process.env.MASKBREAK_MODE || 'watch';
// Created on the first request, not at import: `next build` imports route
// modules, and new Sentinel() throws when MASKBREAK_API_KEY is not set there.
let sentinel;
// evaluate() refuses to send without the network token; watch mode reports anyway.
async function reportWithoutToken(fields) {
const response = await fetch('https://maskbreak.com/v1/evaluate', {
method: 'POST',
signal: AbortSignal.timeout(5000),
headers: { Authorization: 'Bearer ' + process.env.MASKBREAK_API_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify(fields)
});
return response.ok ? response.json() : null;
}
export async function POST(request) {
const body = (await request.json().catch(() => null)) || {};
const { token, fingerprintEventId, tz, email } = body; // collect()'s names
let result = null, monthUsedUp = false;
try {
if (token) result = await (sentinel ??= new Sentinel()).evaluate({ token, fingerprintEventId, tz, email }); // reads MASKBREAK_API_KEY
else if (MODE !== 'enforce') result = await reportWithoutToken({ fingerprintEventId, tz, email });
} catch (err) {
console.log('[maskbreak] check unavailable:', err.message);
// The month's checks are used up: let the action through, logged, until the 1st.
monthUsedUp = err.status === 429 && err.body?.code === 'monthly_quota_exceeded';
}
console.log('[maskbreak]', MODE, result ? result.decision : 'no answer');
// A test or sample answer is never a real visitor's verdict: anyone can send a test token.
if (MODE === 'enforce' && !monthUsedUp && (!result || result.decision !== 'allow' || Sentinel.isFixtureAnswer(result))) {
return Response.json({ error: 'Verification required' }, { status: result && result.decision === 'block' ? 403 : 409 });
}
return createAccount(body); // your existing signup logic
}Check before stripe.paymentIntents.create() and before the password hash
runs. Scripted card testing and credential stuffing often send no browser
token at all, so this gate does not wave such a request through: in enforce
mode only a real allow goes on.
const stripe = require('stripe')(process.env.STRIPE_KEY);
const MODE = process.env.MASKBREAK_MODE || 'watch';
// Watch (the default) logs and always continues. Enforce holds everything but
// a real allow: no token, no answer, review, block or a test answer (anyone
// can send a test token). A used-up month continues, logged, until the 1st.
async function gate(body) {
const input = evidence(body);
let result = null, monthUsedUp = false;
try {
if (input.token) result = await sentinel.evaluate(input);
} catch (err) {
console.log('[maskbreak] check unavailable:', err.message);
monthUsedUp = err.status === 429 && err.body?.code === 'monthly_quota_exceeded';
}
console.log('[maskbreak]', MODE, result ? result.decision : 'no answer');
if (MODE !== 'enforce' || monthUsedUp) return null;
if (!result || result.decision !== 'allow' || Sentinel.isFixtureAnswer(result)) {
return result && result.decision === 'block' ? 403 : 409; // 409: your verification flow
}
return null;
}
app.post('/api/checkout', async (req, res) => {
const held = await gate(req.body);
if (held) return res.status(held).json({ error: 'Verification required' });
const intent = await stripe.paymentIntents.create({
amount: calculateTotal(req.body.items), // from your own prices, never a submitted amount
currency: 'usd',
automatic_payment_methods: { enabled: true }
});
res.json({ clientSecret: intent.client_secret });
});
app.post('/api/login', async (req, res, next) => {
const held = await gate(req.body); // before bcrypt or argon2 runs
if (held) return res.status(held).json({ error: 'Verification required' });
next(); // your existing credential check
}, existingLoginHandler);A visitor who passes still confirms the payment with the client secret and
can try another card on the same intent: keep Stripe Radar and your own
attempt limits. To link devices to accounts, pass your own user id as
accountId (from your database, never the email or another form field).
app.post('/auth/google', async (req, res) => {
const ticket = await googleClient.verifyIdToken({ idToken: req.body.credential });
const result = await check(evidence(req.body));
if (result && (result.decision === 'block' || Sentinel.isFixtureAnswer(result))) return res.status(403).json({ error: 'signup_blocked' });
await createUser(ticket.getPayload().email, result?.device?.visitor_id);
});// Keep the API's own blocks, and also block a VPN visitor whose device is
// already behind another of your accounts. The API alone answers review for
// that visit (a VPN is review; a shared device adds the multi_account_device
// reason without changing the decision). device.multi_account needs the
// accountId you pass and a resolved device event. shouldBlock() throws like
// evaluate(): call it inside try/catch.
const blocked = await sentinel.shouldBlock(
{ token, fingerprintEventId, accountId: user.id },
r => r.decision === 'block' || (r.network.vpn && r.device?.multi_account === true)
);// Pass the signup email and Sentinel checks it against a continuously
// refreshed disposable-domain feed. A hit adds the disposable_email
// reason, raises risk_score, and escalates allow → review. With email and
// phone intelligence on (the default for accounts created from
// 23 October 2026) the address is also scored by the email- and
// phone-intelligence provider (email.checked/score/risk/signals; 80 or
// more adds email_high_risk) and uses one of the plan's daily
// email-and-phone checks. Maskbreak never stores or logs the address.
const result = await check({ token, email: req.body.email });
if (result?.email?.disposable) {
// e.g. require a real address before granting the trial
}// Batch scoring, log enrichment, server-side screening. Same key;
// its own monthly allowance (10x your checks).
const info = await sentinel.lookup('185.220.101.34');
// info.verdict → 'allow' | 'review' | 'block'
// info.risk_score → 0–100
// info.signals → { vpn, proxied, tor, dch, anon } (null when known:false)
// info.network → { asn, org, country, city }Since 0.5.0 evaluate() sends the fields the API reads for login protection
and for email and phone intelligence, so the call your login, signup and
account handlers already make carries them. The examples use the evidence()
and check() helpers above.
Make the check after your own password check, so it can say whether the
password was right. accountId is the account being signed in to, as your
database knows it (never a form field). Each call is a check: this one takes
the place of the gate before the password hash
on your login route rather than adding to it.
app.post('/login', async (req, res) => {
const user = await findUserByEmail(req.body.email); // your database
const passwordOk = Boolean(user && await verifyPassword(user, req.body.password));
let result = null, monthUsedUp = false;
try {
result = await sentinel.evaluate({
...evidence(req.body), // the browser's token, fingerprintEventId and tz
event: 'login',
accountId: user ? String(user.id) : undefined, // your own id for the account
loginSucceeded: passwordOk, // send failed attempts too
sessionId: req.sessionID, // your session ID: only a keyed hash leaves Maskbreak
userAgent: req.get('user-agent'), // as your server received it
ip: req.ip // as your server saw it
});
} catch (err) {
console.log('[maskbreak] check unavailable:', err.message);
monthUsedUp = err.status === 429 && err.body?.code === 'monthly_quota_exceeded';
}
console.log('[maskbreak]', MODE, result ? result.decision : 'no answer', result?.account_takeover);
if (!passwordOk) return res.status(401).json({ error: 'Wrong email or password.' });
if (MODE === 'enforce' && !monthUsedUp) {
if (result && result.decision === 'block') return res.status(403).json({ error: 'Sign-in unavailable.' });
// Review, no answer or a test answer: a second factor, not a refusal.
if (!result || result.decision !== 'allow' || Sentinel.isFixtureAnswer(result)) return sendSecondFactor(res, user);
}
return signIn(res, user); // your existing session code
});ipis used only when the network token gave no address, and never changes the network verdict. Behind a proxy, set Express'strust proxyfor your setup soreq.ipis the visitor's address; never take the firstX-Forwarded-Forvalue on trust.- The answer gains
account_takeover:{ checked: true, score, risk, signals }with a score from 0 to 100 (higher is riskier;riskislowbelow 50,mediumfrom 50,highfrom 80). A score of 80 or more adds the reasonaccount_takeover_riskand raisesallowtoreview, never toblock: answer it with a second factor or an emailed code. checked: falsecomes withwhy:not_enabled(switched off in Settings),no_account_id,test,busy,unavailableornot_available. Treat it as no opinion; the rest of the answer is unaffected.- Login protection is on for every account until a workspace admin switches
it off in Settings, and it acts only when you send
eventwithaccountId. Its score learns from the logins of every Maskbreak customer who uses login protection; say so in your own privacy notice. A login counts as a normal check.
Send event: 'signup' once your database has the new account's id, with the
address and the number the person gave:
const result = await check({
...evidence(req.body),
event: 'signup', // the account was created
accountId: String(user.id), // the new account's id, from your database
email: req.body.email,
phone: req.body.phone // international format, such as +44 20 7946 0958
});
console.log('[maskbreak]', result?.decision, result?.email, result?.phone, result?.account_event);
// A review: keep the new account waiting until it confirms its email or phone.- With email and phone intelligence on,
emailgainschecked,score,risk,signalsand a few facts, and the answer gains aphoneobject of the same shape. A score of 80 or more addsemail_high_riskorphone_high_riskand raisesallowtoreview, never toblock. - Email and phone intelligence is on by default for accounts created from 23 October 2026; an older account switches it on in Settings, and any account can switch it off there. Each address or number scored uses one of the plan's daily email-and-phone checks: Free 3, Starter 20, Growth 50, Scale 150 a day.
checked: falsecomes withwhy:not_enabled,quota(the day's checks are used up),invalid,test,busy,unavailableornot_available;invalid,busyandunavailableuse no daily check. Treat it as no opinion. Maskbreak never stores or logs the address or the number.account_eventsays whether login protection took the signup:{ event: 'signup', recorded: true }.
A password change, a second factor, a logout and an account change go to login protection too: no score comes back, and they teach it what is normal for each account, so its login scores get sharper. These four may go without the browser's token (since 0.5.0), for handlers with no page in front of them:
// After your password-change handler has saved the new password:
await check({
event: 'password_change',
accountId: String(req.session.userId), // from your server-side session
succeeded: true,
reason: 'user', // 'forgot' after a reset link, 'forced' for a reset you required
sessionId: req.sessionID,
userAgent: req.get('user-agent'),
ip: req.ip
});
// A second factor, with how it was checked and what it was for:
await check({ event: 'verification', accountId: String(user.id), succeeded: codeOk,
verificationType: 'sms', verifiedEvent: 'login' });
await check({ event: 'logout', accountId: String(req.session.userId), sessionId: req.sessionID });
await check({ event: 'account_change', accountId: String(req.session.userId), email: newEmail }); // the new email- The answer gains
account_event:{ event, recorded: true }, orrecorded: falsewith the samewhyvalues as a login. - Sent with
...evidence(req.body)from a form that carries the script, it is a full check. Without the token it is a degraded one (degraded: true): itsdecisionsays nothing about the visitor's network, so readaccount_event. A recorded event counts as a check. - A login and a signup are visits, so they still need the token:
evaluate()throws without it, as before.
tz comes with the browser's evidence (Sentinel.collect() returns it, a form
carries sentinel_tz), so evidence() above already sends it:
const result = await check({ token, fingerprintEventId, tz });
// result.timezone → { reported: 'Europe/Tallinn', country: 'EE', matches_ip: true, checked: true }A zone whose country disagrees with an ordinary-looking address adds
timezone_mismatch and raises allow to review; it never blocks. On a VPN,
proxy, Tor or datacenter exit, where a mismatch is expected, nothing is raised
(checked: false).
A value the API would ignore or misread throws a SentinelError, and nothing is
sent: an event other than login, signup, password_change,
verification, logout and account_change (read case-insensitively, so
'signin' is refused), a reason, verificationType or verifiedEvent
outside its list, a loginSucceeded or succeeded that is not true or
false, a tz, phone, userAgent or ip that is not a string, and a
sessionId that is not a string or a number or is longer than 256 characters.
A field that is undefined, null or '' is left out of the request.
After you submit your form once, setupStatus() (GET /v1/setup) tells you
whether the check arrived complete and what to fix:
const setup = await sentinel.setupStatus();
console.log(setup.status, '-', setup.message);
if (setup.next_step) console.log('Next:', setup.next_step);| Field | What it says |
|---|---|
status |
What the latest check showed: waiting (no check in the last 7 days, and none complete before), complete (both parts, live key), missing_network (no network token), network_unavailable (a token that could not be read), missing_device (no browser event id), device_unavailable (a browser event that could not be verified), test_mode (a test token, or the test key without both parts), test_complete (the test key with both parts: swap in the live key), ip_restricted, rate_limited or request_failed |
complete |
At least one complete live-key check has arrived |
message / next_step |
What the latest check showed, and what to do next (null when nothing is left) |
last_check_at, complete_checks, last_complete_at |
When the latest check arrived, how many complete ones did, and when the latest of those did |
website |
{ confirmed, origin }: the website the first complete check came from |
script_seen |
{ origin, at } where and when the page script last reported in, or null |
site_id, key_type, docs |
Your site ID, which key made the call (live or test), and a link to the guide |
It works with the live key and the test key, and returns the answer as the API sends it.
| Option | Type | Default | Description |
|---|---|---|---|
apiKey |
string | $MASKBREAK_API_KEY (then $SENTINEL_KEY, $SENTINEL_API_KEY) |
Your key starting with sk_live_; required unless one of those env vars is set |
endpoint |
string | https://maskbreak.com |
Override base URL |
timeoutMs |
number | 5000 |
Per-request timeout |
allowTestResults |
boolean | false |
Only for your own test runs: shouldBlock() treats test, sandbox and sample answers as a normal decision |
warnMissingBrowserPart |
boolean | true |
false turns off the one-time warning for a check sent without fingerprintEventId (since 0.5.0) |
Returns EvaluateResult. Throws SentinelError when token is missing or empty (except for the four account events below), for a value the API would ignore or misread (Refused before sending), and on network/API failure — an API error carries .status and .body.
| Field | Sent as | Notes |
|---|---|---|
token |
token |
The browser's network token. Required, except for event password_change, verification, logout and account_change |
fingerprintEventId |
fingerprintEventId |
The browser check; without it the SDK warns once (below) |
accountId |
accountId |
Your own id for the account (string or number); login protection needs it with event |
email |
email |
The address the person gave |
tz |
tz |
The visitor's IANA time zone (since 0.5.0) |
phone |
phone |
International format (since 0.5.0) |
event |
event |
login, signup, password_change, verification, logout or account_change (since 0.5.0) |
loginSucceeded |
loginSucceeded |
With login: true or false |
succeeded |
succeeded |
With password_change or verification: true or false (absent means pending) |
sessionId |
sessionId |
With an event: your session ID, at most 256 characters |
userAgent, ip |
userAgent, ip |
With an event: as your server received them |
reason |
reason |
With password_change: user, forgot or forced |
verificationType |
verificationType |
With verification: email, sms, phone_call, app, push, security_key or captcha |
verifiedEvent |
verifiedEvent |
With verification: login, signup, password_change or account_change |
fingerprintEventId— requests device signals (tampering, automation, emulator, …). When the device is identified,device.times_seen,device.first_seenanddevice.returningdescribe its retained sightings across Maskbreak, not just your account. These records are pruned after 90 days of inactivity;first_seenis not necessarily the device's lifetime first visit.accountId— your own user id for this session (take it from your server-side session, never from a form field); with an identified device, enables customer-scoped account linking (device.linked_accounts/device.multi_account). With a live key the response also carries theaccountobject (the account network, since 18 October 2026): devices this account used in 24 hours and 90 days, your accounts on this device and on this address in 24 hours,new_devicefor a returning account, and how many of your other accounts it shares a device or an address with. Its reasonsaccount_new_device,account_many_devicesandip_many_accountsinform only;device_many_accounts(3+ of your accounts on one device within 24 hours) raises an allow to review. Links are keyed hashes and never cross customers; the account network also keeps your accountId encrypted so your console can show which accounts are linked. Both are pruned 90 days after last use.- New
device.customer_historyreports distinct verified events, first/latest visit and associated account counts only for your customer account over a rolling 90-day window. Fresh live-key events start this history; test keys and duplicate event submissions do not add to it. DeriveaccountIdfrom your backend's authenticated session, never a browser-claimed account ID. Shared devices are not automatically fraud. Legacy fields above retain their meanings. - An optional, default-off GPU evidence beta is available over raw HTTP with explicit browser collection. Published SDK versions may omit
gpuEvidence; use the documented HTTP flow. It does not change fraud decisions or automatically link devices. email— addsemail.disposableto the response; burner domains escalateallowtoreview. With email and phone intelligence on (the default for accounts created from 23 October 2026),emailalso carries the provider'schecked,score,riskandsignals, and each address scored uses one of the plan's daily email-and-phone checks.
A check sent without fingerprintEventId has no browser part, so bots, fake
browsers and emulators go unchecked (the dashboard says the browser part is
missing). The first such call in a process prints one console.warn line
saying so and how to fix it; it never contains your key or anything about the
visitor. The fixed test_* tokens and the four account events never trigger
it. new Sentinel({ warnMissingBrowserPart: false }) turns it off.
Returns LookupResponse for a public IPv4/IPv6 address (wraps GET /v1/lookup/{ip}): a verdict, risk score and limited public-feed evidence from cloud-hosting ranges and Tor exit lists. Legacy VPN/proxy fields do not establish complete coverage. Use evaluate() with a browser SDK token for VPN/proxy evidence and service naming when known. known: false, false signals or an allow verdict are not a safety guarantee. Network metadata may be null. Has its own monthly allowance, 10× the plan's checks.
Wraps GET /v1/setup (since 0.5.0) and returns its answer as is: see Check your setup. Throws SentinelError on network/API failure.
Convenience: runs evaluate() and returns a boolean; it throws SentinelError in the same cases (no token, API failure), so call it inside try/catch. Default predicate is r => r.decision === 'block' (honors your dashboard rules and allow/block pins). Pass your own to build custom policies.
Since 0.4.0 a test, sandbox or sample answer (test, sandbox or sample is true) makes shouldBlock() return true whatever the predicate: the fixed test tokens are public, so a visitor whose real visit would be blocked could type test_clean into your form field. In your own test runs, create the client with new Sentinel({ allowTestResults: true }). Sentinel.isFixtureAnswer(result) tells you the same for a result you route yourself.
accountId,lookup()require v0.2.1 or later (npm install @sentinelsup/sdk@latest) — the older 0.1.2 silently ignoresaccountId/tz,phone,eventand the other login-protection fields, andsetupStatus(), require v0.5.0 or later: older versions leave them out of the request.
Deterministic test tokens exercise every decision path from a terminal — authenticated and rate-limited like real calls, but never billed, stored, or webhooked (responses carry "test": true):
await sentinel.evaluate({ token: 'test_vpn' }); // → engine decision: 'review' (your rules/pins may override)
await sentinel.evaluate({ token: 'test_clean' }); // → decision: 'allow' path
// also: test_proxy, test_datacenter, test_tor- No account yet? The public sandbox key
sk_test_sandboxanswers the sametest_*tokens with the same shapes — no signup, nothing stored. - Login protection, email and phone: test keys, test tokens and the sandbox never reach those providers.
account_takeover,emailandphoneanswerchecked: false, why: 'test', andaccount_eventanswersrecorded: false, why: 'test', so you can test the shape without using the day's email-and-phone checks. - CI / staging with real traffic: every account also has a personal
sk_test_…key (Settings → API keys) that runs the live detection — device intelligence, your rules and exception pins (login protection, email and phone intelligence and the account network do not run for it, and it adds nothing totimes_seenorlinked_accounts, which its answers leave out). Its events are marked test, kept out of your stats and never fire webhooks; its checks count toward the monthly allowance (the fixedtest_*tokens andsk_test_sandboxdo not). It is exempt from the account's IP allowlist.
Visitor checks (evaluate()) are counted per calendar month in UTC, with no hourly cap: Free — 10,000 a month, no credit card; paid plans from €29 a month (pricing). IP lookups (lookup()) have their own monthly allowance, 10× the plan's checks (100,000 on Free). A used-up month answers 429 with code: "monthly_quota_exceeded" and Retry-After until the 1st; there are no overage charges. The quick start lets the action through on that answer and logs it, in enforce mode too, so running out never takes your form offline.
On 503 with .body.code === 'storage_unavailable', the key could not be checked at that moment (not an invalid key; 401 is): retry later and apply your outage policy meanwhile.
On 429, the thrown SentinelError has .status === 429. This SDK exposes status and body, not HTTP response headers. If your integration needs Retry-After, use raw HTTP and read that header from the response (the X-RateLimit-* headers are not sent since 29 October 2026: no account has an hourly limit). Use bounded backoff and an endpoint-specific fallback; an unavailable check is not an allow verdict. Approved public-interest accounts have no monthly allowance and are exempt from the per-address request ceiling; /v1/usage and invalid-key protection keep their own limits.
evaluate() requires a non-empty token, except for the account events password_change, verification, logout and account_change (since 0.5.0). Raw HTTP accepts a missing or empty token for any check, as a degraded evaluation: use the HTTP reference for that path, as the quick start's watch mode does. Missing network evidence does not prove a visitor is safe.
npm ci --ignore-scripts
npm test
npm pack --dry-run --ignore-scripts
npm auditTests use local fixtures, not production keys. CI checks Node.js 18, 22 and 24. These commands do not publish a package.
Full types ship with the package. Importing Sentinel gives you the class plus EvaluateResult, DeviceIntel, EvaluateDetails, and SentinelError types, and since 0.5.0 AccountTakeover, AccountEvent, PhoneIntelligence, TimezoneSignal, EmailSignals and SetupStatus. EvaluateInput needs token; AccountEventInput is the token-less shape for the four account events.
import Sentinel, { EvaluateResult, SetupStatus } from '@sentinelsup/sdk';SDK-backed visits can supply VPN/proxy, cloud-hosting and Tor signals, with VPN/proxy service names when known. Available device intelligence adds browser tampering, automation, emulator and virtual-machine signals. Coverage depends on the evidence available; these are not guarantees of detecting every product or visitor. Bare-IP lookup is limited to public cloud-range and Tor evidence.
- Python SDK —
sentinelsupon PyPI - Free IP lookup tool — maskbreak.com/ip-lookup
MIT © Sentinel Edge Networks LTD
- Website — maskbreak.com
- API docs — maskbreak.com/api
- Blog — maskbreak.com/blog