API Reference
Add network and device checks to your signup, login or checkout. Your backend gets the signals; you decide how to respond.
Last updated 19 October 2026 ·every API change lands on the changelog · additive-only stability policy
sk_test_sandbox key — no account required.Attack-specific playbooks: account takeover, card testing, bonus abuse, and more →
Quick Start
Three steps to your first complete check: add the script to the page with your form, send the check from your server, then submit your form once. Create a free key at /signup — 10,000 visitor checks a month on the Free plan, no card.
Using an AI coding assistant? Paste this prompt and it wires Maskbreak into your app end-to-end — frontend script, backend check, env var, and a test. It follows the machine-readable guide at maskbreak.com/integrate.md.
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.
Loading…
The example starts in watch mode: it sends every submission to Maskbreak, also one with a missing field (the dashboard then says which half did not arrive), logs the decision and lets every visitor continue. Once your dashboard Events look right, set MASKBREAK_MODE=enforce and redeploy: the same handler then stops block and holds review, missing evidence and errors. When the month's checks are used up (HTTP 429 with the code monthly_quota_exceeded), it lets the action through unchecked and logs it until the 1st, so running out never takes your form offline. The integration guide has the same handler and a Next.js App Router example.
// STEP 1 — HTML. The script on the page with your form, the class on the form itself. // Your site sends a Content-Security-Policy (helmet's default does)? Add Maskbreak's // hosts first, or the browser blocks the script: maskbreak.com/integrate.md#content-security-policy <script async src="https://maskbreak.com/assets/sentinel.js"></script> <form class="monocle-enriched" id="login-form"> <input type="email" name="email" required> <button type="submit">Continue</button> </form> // STEP 2 — Your frontend script. It never holds the form on Maskbreak: // a blocked or missing script only means the check gets less evidence. document.getElementById('login-form').addEventListener('submit', async (event) => { event.preventDefault(); const evidence = window.Sentinel ? await window.Sentinel.collect() : {}; const response = await fetch('/your-login-endpoint', { method: 'POST', headers: { 'Content-Type': 'application/json' }, // evidence = { token, fingerprintEventId, tz }, already the API's names body: JSON.stringify({ email: event.target.email.value, ...evidence }) }); // Handle response.status and the next step in your existing login UI. }); // STEP 3 — Node 18+ / Express backend. // express, app and existingLoginHandler belong to your application. // This check is NOT authentication and never creates a login session. // MASKBREAK_MODE unset or 'watch' (start here): send every submission, also one // with a missing field (the dashboard then says which half did not arrive), // log the answer and always continue. 'enforce': stop block; hold review, // missing evidence and errors (a used-up month continues, logged). const MASKBREAK_MODE = process.env.MASKBREAK_MODE || 'watch'; app.use(express.json({ limit: '16kb' })); app.use(express.urlencoded({ extended: false, limit: '16kb' })); app.post('/your-login-endpoint', async (req, res, next) => { const body = req.body || {}; // Form fields -> API names: monocle -> token, sentinel_fp -> fingerprintEventId, // sentinel_tz -> tz. Sentinel.collect() already returns the API names. const token = body.token || body.monocle; const fingerprintEventId = body.fingerprintEventId || body.sentinel_fp; const tz = body.tz || body.sentinel_tz; const missing = typeof token !== 'string' || !token || typeof fingerprintEventId !== 'string' || !fingerprintEventId; let result = null, hold = null, monthUsedUp = false, status = null; if (!missing || MASKBREAK_MODE !== 'enforce') { try { const response = await fetch('https://maskbreak.com/v1/evaluate', { method: 'POST', signal: AbortSignal.timeout(5000), // example budget; measure your own headers: { 'Authorization': 'Bearer ' + process.env.MASKBREAK_API_KEY, 'Content-Type': 'application/json' }, body: JSON.stringify({ token, fingerprintEventId, tz, email: body.email }) }); status = response.status; // 401: MASKBREAK_API_KEY is empty or wrong if (!response.ok) { const refusal = await response.json().catch(() => null); // The month's checks are used up: let the action through unchecked // until the 1st (00:00 UTC) and log it, as watch mode does. if (response.status === 429 && refusal?.code === 'monthly_quota_exceeded') monthUsedUp = true; else throw new Error('Visitor check unavailable'); } else { result = await response.json(); // Production gate: never authorize from a fixture or personal test key. if (!result || result.test || result.sandbox || result.sample || !['allow', 'review', 'block'].includes(result.decision)) { throw new Error('No production verdict'); } if (result.decision === 'block') { hold = [403, { error: 'Action refused by policy' }]; } else if (result.decision === 'review' || result.degraded || !result.device) { hold = [409, { next: 'step_up' }]; // implement a real step-up flow } } } catch { // Example temporary hold. Choose a fallback for each business action. hold = [503, { error: 'Check unavailable; try again shortly' }]; } } if (missing) hold = [409, { next: 'step_up' }]; // enforce never trusts half a check // One line per check. Never log the key, browser tokens or form data. console.log('[maskbreak]', MASKBREAK_MODE, 'HTTP ' + (status ?? 'none'), result ? result.decision : monthUsedUp ? 'monthly_quota_exceeded' : 'no answer', hold ? 'enforce would return ' + hold[0] : 'enforce would continue'); if (MASKBREAK_MODE === 'enforce' && hold) { return res.status(hold[0]).json(hold[1]); // never mint a session here } req.fraudCheck = result; return next(); // normal credential + authorization checks still run }, existingLoginHandler);
Before you enforce a verdict
Test normal decisions, missing evidence and customer recovery separately. These guides cover the application-side choices that an API response cannot make for you.
Try It Live
Hit the sandbox endpoint with one click. No signup, no API key — just real /v1/evaluate response shapes you can copy-paste into your code.
Production calls use POST /v1/evaluate with a token from the SDK and an Authorization: Bearer sk_live_... header. The sample endpoint returns the same response shape so you can wire your parsing logic before signing up.
How It Works
Maskbreak uses a 3-step Client → Your Backend → Maskbreak API flow to keep your secret key off the browser.
The Maskbreak SDK runs in your visitor's browser on the page with your form and adds two hidden fields to it: the encrypted network token (monocle) and the browser check (sentinel_fp).
Your form sends both to your own backend server along with the rest of the form data.
Your backend calls POST /v1/evaluate with your secret key and sends them as token and fingerprintEventId. Maskbreak answers allow, review or block, with the reasons.
Authentication
All requests to /v1/evaluate must include your secret API key as a Bearer token. Find your key in the Dashboard.
sk_live_… in your HTML, JavaScript, or any client-side code. Only use it in your backend server environment.Rotating your key
Rotate from the console (Integration → Rotate, or Settings → API keys; your password is asked again). The new key works at once and the previous key keeps working for 24 hours, so a deploy can switch over without a gap. If a key has leaked, Revoke old key now in Settings ends that grace immediately. During the grace both keys count against the same hourly allowance, and /v1/usage reports the same figures for either.
Every account has a personal sk_test_… key. It runs the live pipeline and records test events without sending live webhooks; its checks count toward your monthly allowance, it has its own hourly allowance and it bypasses the IP allowlist. The public sk_test_sandbox key only answers deterministic test tokens, with no account or stored events. A live-key IP allowlist restricts /v1/evaluate, /v1/lookup and minting a token at /oauth/token (400 unauthorized_client from any other address), not /v1/usage.
OAuth 2.0 Client Credentials New
Exchange your account email and API key for a short-lived bearer token at the standard client_credentials grant. The token carries your account id, never the key itself. It lives for 60 minutes, authenticates as your current key of its type, and is bound to the key it was minted with: after a rotation it keeps working while that key does (the previous key's 24-hour grace), and revoking the previous key, a second rotation or rotating the test key ends it at once with a 401, so mint a new token when you get one. A token minted with your sk_test_ key stays a test credential. API keys keep working exactly as before; this is additive.
POST /oauth/token
/.well-known/oauth-authorization-server and /.well-known/oauth-protected-resource.curl -X POST https://maskbreak.com/oauth/token \
-H "Content-Type: application/json" \
-d '{"grant_type":"client_credentials","client_id":"your@email.com","client_secret":"sk_live_..."}'Use the returned access_token anywhere an API key is accepted in the Authorization: Bearer header. Tokens expire; refresh by repeating the exchange. The key itself never leaves your server.
Embed the SDK
Add the Maskbreak SDK to the page with your form. One script loads both detection layers — network intelligence (VPN, proxy, datacenter) and device intelligence (antidetect browsers, bots, tampering) — and injects both into every form that carries class="monocle-enriched" (on the form itself, never on an input).
<!-- Maskbreak SDK — network + device, on the page with your form --> <script async src="loading..."></script> <!-- class="monocle-enriched" on the form itself, never on an input --> <form class="monocle-enriched" id="my-form"> <!-- The SDK fills these in by itself: --> <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" /> // time zone </form>
Your site sends a Content-Security-Policy? helmet's default policy does, and it blocks the script until you add the SDK's own hosts to the directives you have: script-src https://maskbreak.com https://mcl.maskbreak.com https://*.mcl.maskbreak.com https://mesu.maskbreak.com 'wasm-unsafe-eval', connect-src https://mcl.maskbreak.com https://*.mcl.maskbreak.com wss://mcl.maskbreak.com wss://*.mcl.maskbreak.com https://mesu.maskbreak.com and worker-src https://mesu.maskbreak.com blob:. A part the policy blocks leaves its value empty, and the check goes on without it. The integration guide shows the helmet configuration.
The SDK takes ~1–2 seconds to load and generate the tokens. If the device layer can’t run (e.g. a hardened browser blocks it), evaluation continues on the network layer alone.
Collect the Tokens
On submit, call Sentinel.collect() — it waits for the device layer to settle (at most 5 seconds; collect({ timeout: ms }) changes that) and returns { token, fingerprintEventId, tz }, already under the API's names. Forward them to your backend. Check that window.Sentinel exists first, and never hold the form when the script is missing or blocked. (A plain form post carries the hidden inputs monocle, sentinel_fp and sentinel_tz instead: your server sends them as token, fingerprintEventId and tz.) Client-rendered app (React, Next.js, Vue)? Nothing extra to add: the script keeps one hidden form of its own, so the token is there even for forms your app renders after the page loads.
document.getElementById('my-form').addEventListener('submit', async (e) => { e.preventDefault(); // Both layers (network token + device event id) and the time zone. // No script (blocked or still loading)? Send the form anyway. const evidence = window.Sentinel ? await window.Sentinel.collect() : {}; // Forward them to your backend with the rest of the form data const res = await fetch('/your-backend-endpoint', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: e.target.email.value, ...evidence // ← token, fingerprintEventId, tz: the API's own names }) }); });
Call the Evaluate Endpoint
loading…
Call this from your backend server — never from the browser. Pass the token from the client and your secret API key.
Request Body
Sentinel.collect() or input[name="monocle"] (that field's own name, monocle, is accepted too; token wins when both are sent). Send it for full network intelligence. Raw HTTP accepts a missing or empty token as a degraded evaluation; a non-string or provider-rejected token returns 400. The current Node, Python and PHP server SDKs require a non-empty token locally, so use raw HTTP for this fallback. Send the body as application/json; a JSON object sent with another Content-Type (or none), or a form body (token=…&fingerprintEventId=…), is read the same way.sentinel_fp; forward it here (either name is accepted) to unlock the device.* signals (antidetect, automation, emulator, …). Omit it and you get a network-only verdict.fingerprintEventId, Maskbreak counts how many distinct accounts this device has been linked to under your API key and returns device.linked_accounts / device.multi_account — multi-accounting detection with zero extra integration. Maskbreak keeps a keyed hash and an encrypted copy that only your console shows, deleted 90 days after the account was last checked; since 18 October 2026 it also fills the account object.email.disposable to the response — checked against a continuously refreshed feed of thousands of burner/disposable domains. A disposable hit adds the disposable_email reason, raises risk_score, and escalates allow to review. The address is checked transiently and never stored or logged.Europe/Tallinn. Our client SDK sends it automatically as sentinel_tz, which this endpoint also accepts; on your own you can read it with Intl.DateTimeFormat().resolvedOptions().timeZone — no permission prompt and no geolocation involved. Adds a timezone block to the response. When the connection looks ordinary but the browser's clock belongs to a different country, that is the shape of a residential proxy the network layer did not catch: it adds the timezone_mismatch reason, raises risk_score, and escalates allow to review. Deliberately not raised on a VPN, proxy, Tor or datacenter exit — disagreeing with the clock is what those do — and never enough to block on its own, because travellers and expatriates land here legitimately. Forward the tz returned by Sentinel.collect() in your raw HTTP request. The current server SDK helpers do not serialize tz; use raw HTTP when you need this field.Testing without a browser: send a deterministic test token — test_clean, test_vpn, test_proxy, test_datacenter, or test_tor — to exercise your allow / review / block handling end-to-end. Test calls are authenticated and rate-limited like real ones but never counted toward your monthly allowance, stored, or webhooked, and the response carries "test": true.
CI & staging without an account: the public sandbox key sk_test_sandbox accepts the same test_* tokens and returns the same deterministic shapes — no signup, nothing billed, nothing stored, never touches a real quota. It only answers test tokens (live traffic still needs your real key) and is rate-limited per IP. curl -X POST https://maskbreak.com/v1/evaluate -H "Authorization: Bearer sk_test_sandbox" -H "Content-Type: application/json" -d '{"token":"test_vpn"}'
Your own test key (full pipeline): every account also has a personal sk_test_… key (Settings → API keys), and unlike the sandbox it runs the complete live pipeline — real tokens, real device intelligence, your rules and exception pins included. Events it creates are badged as test in the console, left out of your stats, never fire webhooks, and the response carries "test": true. Its checks count toward your monthly allowance, like the console’s own checks; the fixed test tokens above do not. It has its own hourly bucket and is deliberately exempt from the IP allowlist, so CI and laptops can exercise the pipeline without punching holes in your production restriction.
// Your backend. Form fields -> API names: monocle -> token, // sentinel_fp -> fingerprintEventId, sentinel_tz -> tz (collect() already uses them). // Watch mode (start here) sends every submission, a missing field included. const b = req.body || {}; let data = null; try { 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({ token: b.token || b.monocle, fingerprintEventId: b.fingerprintEventId || b.sentinel_fp, tz: b.tz || b.sentinel_tz }) }); data = response.ok ? await response.json() : null; } catch { data = null; // unavailable: watch mode continues } // data.decision → 'allow' | 'review' | 'block' (covers VPN, proxy, // datacenter, Tor, antidetect, automation) — route on this // data.reasons → machine-readable why console.log('[maskbreak]', data ? data.decision : 'no answer'); if (process.env.MASKBREAK_MODE === 'enforce' && data && data.decision === 'block') { return res.status(403).json({ error: 'Suspicious connection.' }); }
# Your backend (Flask shown): JSON from Sentinel.collect(), or a posted form. # Form fields -> API names; watch mode (start here) sends every submission. import os, requests body = request.get_json(silent=True) or request.form try: data = requests.post( "https://maskbreak.com/v1/evaluate", headers={"Authorization": "Bearer " + os.environ["MASKBREAK_API_KEY"]}, json={ "token": body.get("token") or body.get("monocle"), "fingerprintEventId": body.get("fingerprintEventId") or body.get("sentinel_fp"), "tz": body.get("tz") or body.get("sentinel_tz"), }, timeout=5, ).json() except (requests.RequestException, ValueError): data = None # unavailable: watch mode continues # data["decision"] → 'allow' | 'review' | 'block' — route on this # data["reasons"] → machine-readable why print("[maskbreak]", (data or {}).get("decision", "no answer")) if os.environ.get("MASKBREAK_MODE") == "enforce" and (data or {}).get("decision") == "block": return "403 Suspicious connection", 403
curl -X POST 'https://maskbreak.com/v1/evaluate' \ -H 'Authorization: Bearer sk_live_YOUR_API_KEY' \ -H 'Content-Type: application/json' \ -d '{"token": "YOUR_BROWSER_TOKEN", "fingerprintEventId": "YOUR_DEVICE_EVENT", "tz": "Europe/Tallinn"}'
// Your backend: JSON from Sentinel.collect(), or a posted form. // Form fields -> API names; watch mode (start here) sends every submission. $in = json_decode(file_get_contents('php://input'), true) ?: $_POST; $ch = curl_init('https://maskbreak.com/v1/evaluate'); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 5, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('MASKBREAK_API_KEY'), 'Content-Type: application/json'], CURLOPT_POSTFIELDS => json_encode([ 'token' => $in['token'] ?? $in['monocle'] ?? null, 'fingerprintEventId' => $in['fingerprintEventId'] ?? $in['sentinel_fp'] ?? null, 'tz' => $in['tz'] ?? $in['sentinel_tz'] ?? null, ]), ]); $data = json_decode((string) curl_exec($ch), true); // null when unavailable: watch mode continues // $data['decision'] → 'allow' | 'review' | 'block' — route on this // $data['reasons'] → machine-readable why error_log('[maskbreak] ' . ($data['decision'] ?? 'no answer')); if (getenv('MASKBREAK_MODE') === 'enforce' && ($data['decision'] ?? null) === 'block') { http_response_code(403); exit(); }
Returning devices and account links
Send a fresh, real browser event to /v1/evaluate with a live key. device.customer_history counts distinct verified events on your account only, over the last 90 days. It includes first and latest visits, returning status and the number of associated accounts. The dashboard shows the 50 most recently seen devices under Events → Investigate. History starts after this feature is enabled on the service; old events are not backfilled. Repeated event submissions count once. Missing device evidence means no history—not a new or safe device.
For account links, your backend must set accountId from its own authenticated user/session, never copy a claimed account ID from the browser. Use a pseudonymous ID, not an email. Accounts sharing a device are context, not proof of fraud. Test-key traffic is excluded from this new history. Legacy times_seen, returning and first_seen fields keep their existing shared-counter meaning; use customer_history for your own visitors.
// Your backend: accountId comes from YOUR authenticated session.
const response = await fetch('https://maskbreak.com/v1/evaluate', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + process.env.MASKBREAK_API_KEY,
'Content-Type': 'application/json'
},
signal: AbortSignal.timeout(8000),
body: JSON.stringify({
token: req.body.token,
fingerprintEventId: req.body.fingerprintEventId,
accountId: req.user ? String(req.user.id) : undefined,
gpuEvidence: req.body.gpuEvidence // optional beta; otherwise omit
})
});
if (!response.ok) throw new Error('Visitor check unavailable');
const result = await response.json();
const history = result.device?.customer_history;
// history: {visits, returning, first_seen, last_seen, accounts, window_days:90}
// Use your existing decision/review policy. History is not authorization.Optional WebGPU resemblance beta
An administrator can enable the beta in Settings under Experimental: GPU resemblance. Your website must be confirmed first: the first complete check from it confirms it automatically. Collect your normal SDK event on that website, then request a challenge from your backend. The challenge requires a provider-verified event under five minutes old from the confirmed website, expires in two minutes and is bound to that customer, device and event. Loading the normal SDK does not run GPU collection. The separate collector requires HTTPS and worker-src blob: in your CSP.
// 1. Your backend requests a challenge using its LIVE API key.
// Protect your own route with session/CSRF checks and rate limits.
const response = await fetch('https://maskbreak.com/v1/device-evidence/challenge', {
method: 'POST',
headers: { Authorization: 'Bearer ' + process.env.MASKBREAK_API_KEY,
'Content-Type': 'application/json' },
signal: AbortSignal.timeout(6000),
body: JSON.stringify({ fingerprintEventId: req.body.fingerprintEventId })
});
if (!response.ok) return res.status(200).json({ status: 'unavailable' });
return res.json(await response.json()); // challenge, expires_at, protocol
// 2. Browser, after your required consent/notice flow:
// Load https://maskbreak.com/assets/device-evidence.js?v=1 separately.
// Pass the challenge from YOUR backend; never send your API key here.
const gpuEvidence = challenge
? await MaskbreakGPU.collect(challenge)
: { status: 'unavailable' };
// Forward only successful evidence with the SAME fingerprintEventId:
// gpuEvidence: gpuEvidence.features ? gpuEvidence : undefined
// If unavailable or your challenge route fails, continue normal checks.Use raw HTTP for this beta; published server SDKs may omit gpuEvidence. Limit: 60 challenges per customer per hour, 50 outstanding, one accepted GPU sample per verified event and a 30-second device cooldown. Collection stops after six seconds; unsupported or blocked browsers return unavailable. Store at most 200 devices, one latest encrypted feature vector per device, retained for 30 days after its last accepted sample. Disabling the beta deletes samples and challenges.
device.gpu_evidence reports recorded, disabled, invalid, expired_or_used, not_saved or unavailable when evidence can be processed. A nearest device and RMS distance are experimental resemblance only: smaller is closer, but no threshold proves identity. Similar GPUs can resemble each other, settings can change results, and browser data can be forged. GPU evidence never changes scores, blocks visitors or links accounts. Administrator opt-in is not visitor consent; establish your legal basis, explain collection and obtain any required consent. Start with approved test devices.
Experimental browser-consistency checks
Use the existing GPU beta opt-in and event-bound challenge for an optional review-step diagnostic. It compares browser/OS families between page and worker, broad WebGL/WebGPU vendor families, the WebGL report against the claimed system and two fresh calculation outputs, and hashes three small canvases in the browser (only 8-character tags leave it). Forward browserEvidence using raw HTTP with a live key; normal SDK loading does not run it.
// Browser: load /assets/browser-consistency.js?v=6 from maskbreak.com.
// Run only on an opted-in review step, after required notice/consent.
// Obtain the one-use challenge from YOUR authenticated backend route above.
const browserEvidence = challenge
? await MaskbreakBrowserConsistency.collect(challenge)
: { status: 'unavailable' };
// Forward to YOUR backend with the same session-bound fingerprintEventId.
// Do not expose a Maskbreak API key in browser code.
// Backend: optional field in the existing POST /v1/evaluate JSON body:
// browserEvidence: browserEvidence.protocol ? browserEvidence : undefined
// Send either browserEvidence OR gpuEvidence for a device event, not both.
// Read result.device?.browser_consistency:
// {status:'inconsistent', experimental:true, source:'browser_reported',
// affects_decision:false, findings:['browser_context'], checks:[...]}
// Keep the original decision/review policy. This report is not authorization.The API returns named checks and consistent, inconsistent or inconclusive, or an unavailable/invalid/disabled/replay status. Findings are recomputed server-side but remain forgeable browser reports, not hardware attestation. A vendor difference, hidden data, software rendering or missing WebGPU is not a risk flag. Three findings raise an allow to review, never to a block: a WebGL report that contradicts itself (gpu_inconsistent); one account showing two canvas fingerprints from two browsers on the same machine type (antidetect_randomization, needs an accountId); and five or more new canvas fingerprints on one machine type within a day, each from its own browser (antidetect_profile_burst). For those two, keyed hashes of the canvas readings, of the browser’s device identifier and, when you send one, of the account id are kept for 30 days for your account only. Raw reports and summaries are not stored; they do not appear in the event drawer. Test with approved devices before using findings in any policy.
Response Object
A successful call returns 200 OK with this JSON structure.
| Field | Type | Description |
|---|---|---|
| decision | string | Recommended action: "allow", "review", or "block". Advisory only — you set the policy. The reasons array explains every decision so you can log it or show an adverse-action reason to your own end user. If you configure custom rules in your dashboard, this field returns your action for a matching signal (most-severe rule wins). |
| engine_decision | string | decision_source is returned whenever your policy matched this event: "rules" (with rule_matched listing the signals that triggered a rule) or "exception" (with exception_matched naming the per-IP/visitor pin from your dashboard's Exceptions list — explicit pins outrank signal rules). engine_decision additionally appears when the match actually changed the decision, preserving the engine's own risk-score verdict. |
| risk_score | integer | Composite risk score 0–100 across all network and device signals. Not altered by custom rules. |
| isSuspicious | boolean | Legacy convenience flag. true when VPN, proxy, Tor, browser tampering (antidetect), automation, emulator or virtual machine fired. A datacenter or anonymous-network signal alone raises risk_score but does not set this flag — route on decision for full coverage. |
| ip | string | The unmasked, real IP address of the user. Mirrored at details.ip. |
| country | string | ISO 3166-1 country code (e.g. "US", "EE"). Mirrored at details.cc. |
| network. |
boolean | Commercial VPN detected (NordVPN, Proton, ExpressVPN, etc.). Mirrored at details.vpn. |
| network. |
boolean | Residential or SOCKS/HTTP proxy detected. Also true for Apple's iCloud Private Relay, which network.privacy_relay names. Mirrored at details.proxied. |
| network. |
boolean | true when the proxy is Apple's iCloud Private Relay, as Maskbreak's network intelligence recognises it (network.proxy is true too). On its own it returns review, never block, with the reason privacy_relay instead of proxy_detected. A Proxy rule set to Allow allows it too; set to Review or Block, it does not apply to the relay, which stays review. On every evaluation since 10 October 2026. |
| network. |
boolean | Traffic from a known datacenter / cloud provider ASN. Mirrored at details.dch. |
| network. |
boolean | Tor exit node detected. Mirrored at details.tor. |
| network. |
boolean | Any anonymization layer detected. Mirrored at details.anon. |
| network. |
boolean | true when neither datacenter nor proxy fired (looks like a real home IP). |
| network. |
string | Identified provider when known (e.g. "PROTON_VPN", "BRIGHT_DATA"). Mirrored at details.service. |
| network. |
boolean | How good the network reading was. complete is false for a partial reading (the visitor submitted before the network check finished, a higher chance of a wrong answer) and when there was no reading at all. sdk_blocked is true when a token arrived but the check could not measure the visitor: the address it reports is private, missing or the network check's own infrastructure, which is what a blocked or stripped measurement looks like. Then the network flags are not evidence of a clean connection. Both are present on live evaluations (not on test tokens, the sandbox or samples), are information only and never change decision. |
| device. |
boolean | Antidetect / fingerprint-spoofing browser (Multilogin, Kameleo, GoLogin, etc.). Only present when fingerprintEventId was sent. |
| device. |
boolean | Automated browser detected (Puppeteer, Playwright, Selenium). |
| device. |
boolean | Mobile emulator. |
| device. |
boolean | The browser runs inside a virtual machine. On its own it returns review (since 29 September 2026). |
| device. |
boolean | Private / incognito browsing mode detected. |
| device. |
boolean | Enhanced privacy settings active (e.g. Brave Shields, Firefox resist-fingerprinting). |
| device. |
boolean | The visitor IP appears on email-spam or attack-source blocklists. |
| device. |
boolean | This device is being identified unusually often (High-Activity Device). Informational — frequent visits alone aren't fraud, but combined with other signals they often indicate automation or farming. Adds reason code high_activity_device. |
| device. |
string | Stable device fingerprint hash. Same device returns the same id across sessions even after cookies clear — useful for ATO defense and device clustering. |
| device. |
number | Upstream tampering score, 0–1, or 0 when unavailable. Not a probability of fraud or proof of a genuine browser. Use checked_signals and antidetect to interpret the result. |
| device. |
boolean / string[] | complete means browser tampering and bot checks returned results, not that every optional check ran. checked_signals lists device boolean fields actually checked. An unchecked field stays false for compatibility; it is not a clean result. These fields do not change your decision policy. Older responses and demo fixtures may omit this metadata; missing metadata does not prove checks ran. |
| device. |
integer | Cumulative sightings of the one-way device hash while its history record exists. It is not a count of your customers, not scoped to your key, and not a rolling 90-day count. Records are eligible for pruning after 90 days without a sighting. A value of 1 is limited evidence of novelty, not proof of fraud. |
| device. |
string | ISO 8601 time of the first sighting in the current device-history record. This history is not scoped to your API key and can restart after inactivity pruning. Use your own account history for customer age. The separate linked_accounts field remains customer-scoped. |
| device. |
boolean | True when times_seen > 1 — this device has been evaluated before. |
| device. |
integer | How many distinct accounts this device has been seen with under your API key in the last 90 days. Only present when you pass your own accountId in the request alongside fingerprintEventId. The link is stored as keyed hashes and never crosses to another customer. |
| device. |
boolean | True when linked_accounts > 1 — the core multi-accounting signal (bonus abuse, trial farming, duplicate signups). Adds reason code multi_account_device. |
| account. |
integer | Your accounts checked on this device in the last 24 hours, this one included; null without device evidence. Three or more adds device_many_accounts and raises allow to review. The account object is present when you send accountId with a live key (since 18 October 2026). |
| account. |
integer | Devices this account was checked on in the last 24 hours. Three or more adds account_many_devices. |
| account. |
integer | Your accounts checked from this address in the last 24 hours, this one included. Null when no address was measured or it is a shared exit (a VPN, proxy, datacenter, Tor exit, Apple’s relay or a mobile carrier’s shared address), which is never linked. Three or more adds ip_many_accounts. |
| account. |
boolean | True when this account was checked before on another device and never on this one; adds account_new_device. Null without device evidence. |
| account. |
integer | Your other accounts that share a device or an address with this one within 90 days; the console lists them. Nothing links across customers. |
| email. |
boolean | Present only when the request included the optional email parameter. true when the address uses a known burner/disposable domain — adds the disposable_email reason and escalates allow to review. |
| timezone. |
string | Present only when the request included a recognised tz. The IANA zone the browser reported, echoed back. |
| timezone. |
string | ISO 3166-1 alpha-2 country that zone belongs to. |
| timezone. |
boolean | true when the zone's country equals country (the exit IP's). Build your own rule on this if you want a policy stricter than ours. |
| timezone. |
boolean | false on a VPN, proxy, Tor or datacenter exit, where a mismatch is expected and we raise nothing. When false, read matches_ip as information rather than as a verdict. |
| reasons | string[] | Machine-readable codes for which signals fired (e.g. "vpn_detected", "datacenter_asn", "antidetect_browser"). |
| evaluated_in_ms | integer | Server-side processing time for this request, in milliseconds. |
| device_ |
string | Whether the browser check was usable: "ok" (the device event resolved and device is present), "unavailable" (an event id was sent but did not resolve: malformed, expired, from elsewhere, or the check was unreachable) or "not_sent" (no event id, for example a form whose sentinel_fp field was removed). Present on live evaluations, not on test tokens, the sandbox or samples. It never changes decision: hold sensitive actions such as signups, payouts and password resets yourself when it is not "ok". |
| evidence. |
integer / boolean | How fresh the evidence was, with only what is known: seconds since the network reading (network_age_s) and since the device event (device_age_s), and whether the device event was marked as a replay of an earlier one (device_replayed). Absent when nothing is known. Information only; old or replayed evidence deserves a fresh check before a sensitive action. |
{
"status": "success",
"isSuspicious": true,
"decision": "review",
"risk_score": 65,
"ip": "185.107.80.12",
"country": "EE",
"network": {
"vpn": true,
"proxy": false,
"privacy_relay": false,
"datacenter": true,
"tor": false,
"anonymous": true,
"residential": false,
"service": "PROTON_VPN"
},
"reasons": ["vpn_detected", "datacenter_asn"],
"evaluated_in_ms": 126,
"details": {
"ip": "185.107.80.12",
"cc": "EE",
"vpn": true,
"proxied": false,
"anon": true,
"dch": true,
"service": "PROTON_VPN"
}
}{
"status": "success",
"isSuspicious": false,
"decision": "allow",
"risk_score": 0,
"ip": "82.131.45.9",
"country": "DE",
"network": {
"vpn": false,
"proxy": false,
"privacy_relay": false,
"datacenter": false,
"tor": false,
"anonymous": false,
"residential": true,
"service": null
},
"reasons": [],
"evaluated_in_ms": 118
}How the decision is made
The engine returns block for a proxy, Tor exit, detected bot, emulator or browser tampering. A VPN alone, a browser inside a virtual machine, or Apple's iCloud Private Relay alone (the one proxy that is reviewed, not blocked) returns review. Other signals and your policy can affect the final answer. A cloud-server or anonymous-network flag alone is not a hard block. Your rules and exceptions can override the decision; engine_decision is included when an override changes it, not on every response.
Degraded answers. Missing network evidence produces degraded: true, ip: "unknown" and false network flags. Those false flags mean unavailable evidence, not a clean connection. Device or email signals, rules and exceptions can still produce review or block. Only the bare non-suspicious allow fallback is omitted from evaluation totals and Events; meaningful verdicts follow normal event and usage handling. Authenticated attempts still use the hourly request allowance.
Extra flags. test: true on any test-token or test-key answer, sandbox: true when the public sk_test_sandbox key was used, sample: true on /v1/evaluate/sample. /v1/lookup also carries an additive network.cloud object naming the provider when the address sits in a published cloud range.
Reason codes
Every value reasons[] can carry, and what it means. New codes are only ever added.
account_many_devices— one of your accounts checked on three or more devices within 24 hours (when you sendaccountId; informational)account_new_device— an account you checked before, now on a device it has never used (when you sendaccountId; informational)anonymous_network— an anonymising network with no operator namedantidetect_browser— a fake browser (antidetect tooling or a tampered environment)antidetect_profile_burst— five or more new browsers on one machine type within a day, each with its own canvas fingerprint: one machine’s antidetect profiles (browser-consistency beta; review only)antidetect_randomization— one account showing two canvas fingerprints from two browsers on the same machine type: a randomising antidetect browser (browser-consistency beta, sent with an account id; review only)automation_detected— a script or headless browserdatacenter_asn— a datacenter or cloud addressdevice_many_accounts— three or more of your accounts on one device within 24 hours (when you sendaccountId; raises allow to review, never to block)disposable_email— a throwaway email addressemulator_detected— an emulatorgpu_inconsistent— the browser’s own WebGL report contradicts itself, naming a GPU it cannot have (browser-consistency beta; review only)high_activity_device— a device seen unusually often (soft signal)ip_blocklisted— an address on a blocklistip_many_accounts— three or more of your accounts from one address within 24 hours; shared exits such as VPNs and mobile carriers never count (when you sendaccountId; informational)multi_account_device— a device already behind several of your accountsprivacy_relay— Apple's iCloud Private Relay, in place of proxy_detected (review on its own)private_browsing— an incognito or private windowproxy_detected— a residential or cloud proxytimezone_mismatch— browser time zone disagrees with the address (only when checked)tor_exit_node— a Tor exitvirtual_machine— a browser inside a virtual machine (review on its own)vpn_detected— a VPN service (network.service names it when known)
Test tokens
Send one of these as token with your live key, your test key or sk_test_sandbox. With your own keys they obey your Rules and exception pins (so test_vpn is a one-line way to verify a rule); they are never billed. Calls made from the console are stored as test rows so the Events log has something to show.
| Token | Pretend visitor | Signals | Decision |
|---|---|---|---|
test_clean | 203.0.113.42 · US | nothing | allow |
test_vpn | 198.51.100.18 · NL | VPN, datacenter (PROTON_VPN) | review |
test_proxy | 203.0.113.9 · DE | proxy, datacenter (BRIGHT_DATA) | block |
test_datacenter | 203.0.113.7 · DE | datacenter (AWS) | allow |
test_tor | 203.0.113.99 · null | Tor (TOR) | block |
IP Lookup
A verdict for a bare IP address — no browser token needed. Same key and hourly cap as /v1/evaluate; lookups have their own monthly allowance, 10× the plan’s visitor checks (100,000 a month on the Free plan).
GET /v1/lookup/{ip} — for server-side screening where no client runs: allowlist checks, batch scoring, enriching your own logs. Network signals only (there is no device to fingerprint), so use /v1/evaluate whenever a browser is involved. On a bare IP the Tor-exit and cloud-range checks fire; VPN and proxy tunnels — and the service name in network.service — are detected on a live visit via /v1/evaluate, so signals.vpn and signals.proxied come back false here.
curl https://maskbreak.com/v1/lookup/185.220.101.34 \
-H "Authorization: Bearer sk_live_YOUR_API_KEY"
{
"ip": "185.220.101.34",
"known": true,
"verdict": "block", // allow | review | block
"risk_score": 90,
"signals": { "vpn": false, "proxied": false, "tor": true, "dch": false, "anon": true },
"network": { "asn": 60729, "org": "Tor exit node", "country": null, "city": null,
"registration_country": "DE", "source": "asn_table" },
"latency_ms": 12
}
known: false means no intelligence source had an opinion — the verdict is then allow with risk_score: 0, which is “nothing found,” not a clean guarantee. Exception pins you configure in the dashboard apply here too (verdict_source: "exception" with engine_verdict preserved, additively). Signal spellings (proxied, dch) are frozen — safe to parse.
Rate Limits
Every plan has a monthly allowance of visitor checks (calls to /v1/evaluate, the SDKs' evaluate() included) and an hourly cap that protects the service, and a per-address ceiling runs alongside them. Whichever you hit first returns 429.
| Scope | Limit |
|---|---|
| Free plan | 10,000 visitor checks / month · up to 1,000 / hour |
| Starter | 50,000 / month · up to 5,000 / hour |
| Growth | 250,000 / month · up to 20,000 / hour |
| Scale | 1,000,000 / month · up to 50,000 / hour |
| Enterprise | Agreed in the contract |
| Public interest | No monthly allowance, no hourly cap |
| Per source IP | 200,000 requests / hour (approved public-interest keys pass through it) |
Monthly allowances count per calendar month in UTC and reset at 00:00 UTC on the 1st; unused checks do not carry over. /v1/lookup and the MCP lookup_ip tool have their own monthly allowance of 10× the plan’s visitor checks (100,000 on Free) and share the plan’s hourly cap. The fixed test_* tokens and the public sandbox key are never counted; calls with your test key and the console’s own checks run the full detection, so they count toward the month. There are no overage charges: when the month’s allowance is used up, calls return 429 with "code": "monthly_quota_exceeded" and a Retry-After that counts down to the 1st, until the month resets or you upgrade. Accounts created before 1 October 2026 keep 1,000 checks an hour with no monthly cap until 30 November 2026. The per-IP ceiling is a backstop against runaway abuse from a single machine. Retry-After is included on every 429 response. Plans and prices
When the month’s allowance is used up
HTTP/1.1 429 Too Many Requests Retry-After: 172800 // seconds until 00:00 UTC on the 1st { "error": "…", // human-readable; branch on code "code": "monthly_quota_exceeded" }
Keyed /v1/evaluate and /v1/lookup responses also carry X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset (unix seconds) so you can back off before the 429 — and an X-Request-Id header you can quote to support when something looks wrong.
Keys on the public-interest program (hospitals, public health, government, elections, emergency services, universities, registered non-profits) have no hourly cap: the three X-RateLimit-* headers are omitted rather than filled with a sentinel, and /v1/usage reports hourly_limit: null with uncapped: true.
Reliability & fail-open behaviour
Maskbreak returns the best available evidence when an intelligence layer is unavailable. degraded: true specifically reports missing network intelligence; a device-provider failure alone does not set it. A missing device object is not proof that browser checks passed. With a network outage or missing token, only the bare non-suspicious allow fallback is excluded from evaluation totals and Events. Independent signals or policy overrides may still yield a stored review or block; personal test keys retain test-event semantics. Attempts still consume the hourly request allowance. A provider-rejected or non-string token instead returns 400 Invalid token.. Choose an explicit endpoint-specific fallback for missing evidence; do not equate unavailable checks with a safe visitor.
Error Codes
All error responses from the API include an error string field. One exception, noted under 403: a block at the edge returns plain text, not JSON.
-
400
Bad Request — Invalid
token(rejected by the provider, or not a string). A missing or empty token is not an error: it is answered fail-open withdegraded: true. -
400
Bad Request on
/v1/lookup/{ip}— the path is not one public IPv4 or IPv6 address:Invalid IP format.orPrivate or reserved IP — nothing to look up.The second also answers an IPv4-mapped address (::ffff:followed by the IPv4): send the IPv4 alone. -
401
Unauthorized — Invalid or missing API key.
Missing Key.also answersAuthorization: Bearerwith nothing after it: the variable holding your key was empty where the call ran (for exampleMASKBREAK_API_KEYnot set on that server or in that shell). Node sends an unset variable as the wordundefined, which answersInvalid API Key.On/v1thehintfield names the likely cause. -
403
Forbidden — Account suspended (contact support@maskbreak.com), or the caller's IP is not on your key's IP allowlist (Settings → API keys → Allowed server addresses; the
hintfield names the calling address). Not retryable from the same address.
Third cause, and the confusing one: a bareerror code: 1010intext/plainwith no JSON body is not us — that is our edge rejecting your HTTP client's default User-Agent.Python-urllibis the common one. Set any realUser-Agentheader and it clears; every official SDK already does. -
429
Rate Limited — the plan’s hourly cap (1,000/hour on Free) or its monthly allowance is used up, or the separate per-address protection limit was hit. A used-up month carries
"code": "monthly_quota_exceeded", and itsRetry-Aftercounts down to 00:00 UTC on the 1st. The test key has its own hourly cap; its checks count toward the same monthly allowance. Approved public-interest accounts have no cap on their account, but are not exempt from every safety control:/v1/usage, sample responses, OAuth and invalid-key protection have independent limits. HonorRetry-Afterwhen returned. - 500 Server Error — unexpected internal error. Use bounded backoff within your action's deadline. A retry may repeat a completed evaluation; never retry a payment or other business mutation blindly.
-
503
Service Unavailable — with
codestorage_unavailable: we could not check your key or account at that moment (our account store did not answer). It is not an invalid key: a key that matches no account is always 401. Nothing was evaluated and no quota was used. Retry afterRetry-After(30 seconds) and apply your outage policy meanwhile; never treat it as an allow.
Usage
Your key’s quota position as an endpoint — poll it instead of scraping rate-limit headers off your last response.
GET /v1/usage — authenticate with the API key itself (Authorization: Bearer …). Works with both key types — sk_live_ and sk_test_ each report their own hourly bucket, named in key_type — and the call is free: it never consumes quota, so a monitor polling it can’t eat into your limit.
curl https://maskbreak.com/v1/usage \
-H "Authorization: Bearer sk_live_YOUR_API_KEY"
{
"key_type": "live", // or "test"
"plan": "free", // free | starter | growth | scale | custom | public_interest
"monthly_limit": 10000,
"month_used": 2481,
"monthly_lookup_limit": 100000,
"month_lookups_used": 5210,
"month_resets_at": "2026-08-01T00:00:00.000Z", // 00:00 UTC on the 1st of next month
"grandfathered_until": null, // set while an account created before 1 Oct 2026 keeps its previous allowance
"hourly_limit": 1000,
"used_this_hour": 412,
"remaining": 588,
"resets_at": "2026-07-19T11:00:00.000Z", // end of the rolling 60 minutes that began with your first call; null if nothing used yet
"total_evaluations": 183204,
"limit_hits": 3
}
used_this_hour and remaining are advisory: the hourly counters are in-memory and per-process, so they reset on a deploy — treat them as a live gauge, not an audit record (enforcement of the actual limit is unaffected). total_evaluations (every counted /v1/evaluate, /v1/lookup and MCP lookup_ip call; month_used and month_lookups_used split this month by kind) and limit_hits are durable account totals. Added on 30 September 2026 (additive): plan (free, starter, growth, scale, custom or public_interest), monthly_limit and month_used (visitor checks this calendar month), monthly_lookup_limit and month_lookups_used (/v1/lookup and MCP lookup_ip calls this month), month_resets_at (00:00 UTC on the 1st of next month) and grandfathered_until (when the previous allowance ends for an account created before 1 October 2026, otherwise null). Existing fields are unchanged.
Limits and notices. The hourly window starts with your first call; the monthly allowance follows the calendar month (UTC). Usage warnings are sent to the owner at 80% and at the cap of the hourly window, at most once per threshold per 24 hours for a live key, and at about 80% and 100% of the monthly allowance. The test key sends no hourly warnings; its checks count toward the monthly allowance, so they can trigger the monthly ones. /v1/usage consumes no evaluation quota but is limited to 120 reads/minute per address, including public-interest accounts (429 with Retry-After). The endpoint does not enforce the key's IP allowlist; suspension still returns 403. Repeated invalid credentials can trigger a 15-minute address lockout (429, Retry-After: 900).
Webhooks
Threat alerts pushed to Slack, Discord, or your own endpoint — configured on the console’s Integration tab, under Alerts.
When they fire. On engine-detected threats (VPN, proxy, Apple's iCloud Private Relay (review), Tor, datacenter with tampering, bots, antidetect browsers, a virtual machine (review)) and on any evaluation your own custom rules decided to block. Slack and Discord URLs get a channel-ready message; any other HTTPS endpoint receives a JSON event (event: "threat.detected" with IP, threat type, and the signal details). The dashboard's Send test button delivers the same shape with event: "test" — if your receiver switches strictly on the event type, handle both, or the test button will look like a silent failure.
Verifying authenticity. Every delivery is signed with your account's signing secret (shown next to the webhook URL in the dashboard, where Replace secret creates a new one: deliveries are signed with it at once, so update your receiver right after, and replace it whenever someone who saw it should no longer be able to sign alerts). Compute HMAC_SHA256(secret, timestamp + "." + rawBody) using the X-Maskbreak-Timestamp header and compare it (constant-time) to the hex digest in X-Maskbreak-Signature (after the sha256= prefix). Reject anything unsigned, mismatched, or older than 5 minutes.
Copy-paste receivers. The two classic mistakes are verifying against the re-serialized parsed body instead of the raw bytes, and comparing with ==. These receivers do it right:
const crypto = require('crypto'); const express = require('express'); const app = express(); // Raw body required — verify the exact bytes Maskbreak signed, // never a re-serialized JSON.parse() of them. app.post('/webhooks/sentinel', express.raw({ type: 'application/json' }), (req, res) => { const secret = process.env.SENTINEL_WEBHOOK_SECRET; // shown next to the URL in the dashboard const ts = req.get('X-Maskbreak-Timestamp') || ''; const sig = req.get('X-Maskbreak-Signature') || ''; // Freshness: reject anything older than 5 minutes (replay defense) const age = Math.abs(Date.now() / 1000 - Number(ts)); if (!ts || !Number.isFinite(age) || age > 300) return res.status(400).end(); // "sha256=" + HMAC_SHA256(secret, timestamp + "." + rawBody), hex digest const expected = 'sha256=' + crypto.createHmac('sha256', secret) .update(ts + '.').update(req.body).digest('hex'); // Constant-time compare — never === const a = Buffer.from(sig), b = Buffer.from(expected); if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) { return res.status(401).end(); } const event = JSON.parse(req.body); // { event: "threat.detected", event_id, ip, threat_type, details } // ... handle it (queue it, alert, block the IP upstream) res.status(200).end(); // answer 2xx fast — deliveries time out after 10 s });
import hashlib, hmac, os, time from flask import Flask, request, abort app = Flask(__name__) SECRET = os.environ["SENTINEL_WEBHOOK_SECRET"].encode() # shown next to the URL in the dashboard @app.post("/webhooks/sentinel") def sentinel_webhook(): ts = request.headers.get("X-Maskbreak-Timestamp", "") sig = request.headers.get("X-Maskbreak-Signature", "") # Freshness: reject anything older than 5 minutes (replay defense) try: fresh = abs(time.time() - int(ts)) <= 300 except ValueError: fresh = False if not fresh: abort(400) # "sha256=" + HMAC_SHA256(secret, timestamp + "." + raw_body), hex digest. # Sign request.get_data() — the exact raw bytes, not request.json. expected = "sha256=" + hmac.new( SECRET, ts.encode() + b"." + request.get_data(), hashlib.sha256 ).hexdigest() # Constant-time compare — never == if not hmac.compare_digest(sig, expected): abort(401) event = request.get_json(force=True) # {"event": "threat.detected", "event_id": ..., "ip": ..., "details": ...} # ... handle it (queue it, alert, block the IP upstream) return "", 200
Delivery & retries. Each event is attempted up to 3 times: a failed delivery is retried after roughly 1 minute, then roughly 8 minutes. Retries are best-effort and in-process — a deploy mid-retry drops the remaining attempts — so treat the dashboard Events log as the source of truth and the webhook as a best-effort push. The 25 most recent deliveries (event, outcome, error) are listed in the dashboard under Developer tools → Webhook alerts, retained 30 days; the header chip shows current health, and if 5 consecutive deliveries fail we email you — a dead webhook must never read as “no threats.” Deliveries time out after 10 s and never follow redirects.
Dedupe on event_id. Every payload carries an event_id — 32 hex chars, unique per event, present on test events too — and every delivery also carries it in the X-Maskbreak-Event-Id header. Retried deliveries reuse the same event_id, so key your processing on it and each event is handled exactly once no matter how many attempts reach you.
Generic-endpoint payload. What your receiver actually gets (Slack/Discord URLs get a formatted message instead):
{
"event": "threat.detected",
"event_id": "9f2ce7a4c1d84b7fa3d2c05e8b6f4a19", // unique per event; same on retries
"timestamp": "2026-07-19T09:14:03.512Z",
"ip": "185.220.101.34",
"threat_type": "Tor", // or "Custom rule (…)" / "Customer exception (…)"
"details": {
"ip": "185.220.101.34", "cc": "DE",
"vpn": false, "proxied": false, "tor": true, "dch": false,
"bot": false, "tampering": false, "antidetect": false,
"virtual_machine": false, // since 29 Sep 2026; tampering = tampered browser or emulator
"privacy_relay": false, // since 10 Oct 2026; true = Apple's iCloud Private Relay (review), proxied stays true
"decision": "block",
"rule_matched": null, // signals, when your rule caused the block
"exception_matched": null // pins, when your exception caused it
}
}
MCP for AI Agents
A hosted Model Context Protocol server — point an AI agent at it and it can screen IPs with real Maskbreak verdicts.
Endpoint: POST https://maskbreak.com/mcp (streamable HTTP, stateless). Tools: lookup_ip — the same verdict as GET /v1/lookup — and service_status. Authenticate with either a classic API key Authorization: Bearer sk_live_… or an OAuth access_token from POST /oauth/token. Anonymous use shares the free tool's tight per-IP limits; authenticated calls share your plan's hourly cap and count toward the same monthly allowance as /v1/lookup (your IP allowlist applies here too).
{
"mcpServers": {
"sentinel": {
"type": "http",
"url": "https://maskbreak.com/mcp",
"headers": { "Authorization": "Bearer sk_live_YOUR_API_KEY" }
}
}
}
API Stability & Versioning
What you can build on without worrying about the ground moving.
Versioning. The API is versioned in the path (/v1/). Within a major version we make only additive changes: new response fields, new optional request parameters, new signal reasons. Your integration must tolerate unknown fields in responses — that is the only forward-compatibility requirement we place on you.
Breaking changes. Renaming or removing response fields, changing types or semantics, or retiring an endpoint only happens in a new major version (/v2/). When that day comes, /v1/ keeps working for at least 12 months after the announcement.
Deprecation notice. Any deprecation is announced at least 90 days in advance via the changelog and email to affected API keys, with a documented migration path. Enterprise agreements can pin longer support windows — support@maskbreak.com.
Common questions
monthly_quota_exceeded; Retry-After counts down to 00:00 UTC on the 1st). There are no overage charges: upgrade from the console to raise the allowance, from Starter at €29 a month up to Enterprise from €990 a month. Public-interest organisations (hospitals, public health, government, elections, emergency services, universities, registered non-profits) can apply to have the cap removed on the public-interest program page.