v1 Loading…

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

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.

Node.js SDK npm install @sentinelsup/sdk npm ↗ · GitHub ↗
Python SDK pip install sentinelsup PyPI ↗ · GitHub ↗
OpenAPI 3.1 https://maskbreak.com/openapi.json Spec ↗ ·
Set up with AI Claude Code · Cursor · Copilot · Windsurf

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.
Your Maskbreak API Endpoint
Loading…
Your backend calls this URL. Your API key authenticates the request.
Start in watch mode

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.

Integration pattern — keep your existing login handler
// 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.

Request
GET https://maskbreak.com/v1/evaluate/sample
   ?scenario=clean
No auth required · 30 req/min · returns sample data
Response
awaiting run…
{
  "click ▶ Run Request": "to see live JSON"
}

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.

Look Up a Real IP
genuine pipeline verdict · no key · rate-limited — a few tries per minute
Request
POST https://maskbreak.com/api/lookup
{ "ip": "185.220.101.34" }
Real verdict from the live pipeline (a Tor exit is prefilled). Tightly rate-limited — a few tries per minute; use GET /v1/lookup with a key for volume.
Response
awaiting run…
{
  "click ▶ Run Lookup": "for a live verdict on this IP"
}

How It Works

Maskbreak uses a 3-step Client → Your Backend → Maskbreak API flow to keep your secret key off the browser.

1
Browser Collection

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

2
Token Forwarded

Your form sends both to your own backend server along with the rest of the form data.

3
API Evaluation

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.

Authorization: Bearer sk_live_YOUR_SECRET_KEY
Keep your key secret
Never put 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.

OAuth Token Endpoint
POST /oauth/token
Exchange email + API key for a bearer token. Discovery metadata at /.well-known/oauth-authorization-server and /.well-known/oauth-protected-resource.
cURL
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.

1

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

HTML — the page with your form
<!-- 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.

2

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.

JavaScript (Frontend)
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
    })
  });
});
3

Call the Evaluate Endpoint

POST loading…

Call this from your backend server — never from the browser. Pass the token from the client and your secret API key.

Request Body

token string for full network checks
The encrypted network token from 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.
fingerprintEventId string
The device-intelligence event id — the SDK captures it and injects it as 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.
accountId string
Optional: your own account/user id for this session. Combined with 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 string
Optional: the email the visitor is signing up with. Adds 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.
tz string
Optional: the visitor's IANA timezone, e.g. 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.vpn boolean Commercial VPN detected (NordVPN, Proton, ExpressVPN, etc.). Mirrored at details.vpn.
network.proxy 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.privacy_relay 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.datacenter boolean Traffic from a known datacenter / cloud provider ASN. Mirrored at details.dch.
network.tor boolean Tor exit node detected. Mirrored at details.tor.
network.anonymous boolean Any anonymization layer detected. Mirrored at details.anon.
network.residential boolean true when neither datacenter nor proxy fired (looks like a real home IP).
network.service string Identified provider when known (e.g. "PROTON_VPN", "BRIGHT_DATA"). Mirrored at details.service.
network.complete / network.sdk_blocked 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.antidetect boolean Antidetect / fingerprint-spoofing browser (Multilogin, Kameleo, GoLogin, etc.). Only present when fingerprintEventId was sent.
device.automation boolean Automated browser detected (Puppeteer, Playwright, Selenium).
device.emulator boolean Mobile emulator.
device.virtual_machine boolean The browser runs inside a virtual machine. On its own it returns review (since 29 September 2026).
device.incognito boolean Private / incognito browsing mode detected.
device.privacy_mode boolean Enhanced privacy settings active (e.g. Brave Shields, Firefox resist-fingerprinting).
device.ip_blocklisted boolean The visitor IP appears on email-spam or attack-source blocklists.
device.high_activity 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.visitor_id 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.tampering_score 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.complete / device.checked_signals 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.times_seen 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.first_seen 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.returning boolean True when times_seen > 1 — this device has been evaluated before.
device.linked_accounts 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.multi_account boolean True when linked_accounts > 1 — the core multi-accounting signal (bonus abuse, trial farming, duplicate signups). Adds reason code multi_account_device.
account.device_accounts_24h 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.devices_24h integer Devices this account was checked on in the last 24 hours. Three or more adds account_many_devices.
account.ip_accounts_24h 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.new_device 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.connected_accounts 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.disposable 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.reported string Present only when the request included a recognised tz. The IANA zone the browser reported, echoed back.
timezone.country string ISO 3166-1 alpha-2 country that zone belongs to.
timezone.matches_ip 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.checked 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_evidence 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.network_age_s / evidence.device_age_s / evidence.device_replayed 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.
200 OK — Suspicious connection
{
  "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"
  }
}
200 OK — Clean connection
{
  "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 send accountId; informational)
  • account_new_device — an account you checked before, now on a device it has never used (when you send accountId; informational)
  • anonymous_network — an anonymising network with no operator named
  • antidetect_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 browser
  • datacenter_asn — a datacenter or cloud address
  • device_many_accounts — three or more of your accounts on one device within 24 hours (when you send accountId; raises allow to review, never to block)
  • disposable_email — a throwaway email address
  • emulator_detected — an emulator
  • gpu_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 blocklist
  • ip_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 send accountId; informational)
  • multi_account_device — a device already behind several of your accounts
  • privacy_relay — Apple's iCloud Private Relay, in place of proxy_detected (review on its own)
  • private_browsing — an incognito or private window
  • proxy_detected — a residential or cloud proxy
  • timezone_mismatch — browser time zone disagrees with the address (only when checked)
  • tor_exit_node — a Tor exit
  • virtual_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.

TokenPretend visitorSignalsDecision
test_clean203.0.113.42 · USnothingallow
test_vpn198.51.100.18 · NLVPN, datacenter (PROTON_VPN)review
test_proxy203.0.113.9 · DEproxy, datacenter (BRIGHT_DATA)block
test_datacenter203.0.113.7 · DEdatacenter (AWS)allow
test_tor203.0.113.99 · nullTor (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 plan10,000 visitor checks / month · up to 1,000 / hour
Starter50,000 / month · up to 5,000 / hour
Growth250,000 / month · up to 20,000 / hour
Scale1,000,000 / month · up to 50,000 / hour
EnterpriseAgreed in the contract
Public interestNo monthly allowance, no hourly cap
Per source IP200,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 with degraded: true.
  • 400 Bad Request on /v1/lookup/{ip} — the path is not one public IPv4 or IPv6 address: Invalid IP format. or Private 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 answers Authorization: Bearer with nothing after it: the variable holding your key was empty where the call ran (for example MASKBREAK_API_KEY not set on that server or in that shell). Node sends an unset variable as the word undefined, which answers Invalid API Key. On /v1 the hint field 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 hint field names the calling address). Not retryable from the same address.

    Third cause, and the confusing one: a bare error code: 1010 in text/plain with no JSON body is not us — that is our edge rejecting your HTTP client's default User-Agent. Python-urllib is the common one. Set any real User-Agent header 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 its Retry-After counts 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. Honor Retry-After when 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 code storage_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 after Retry-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.

Ready to run it on real traffic?
Free key in under a minute — 10,000 checks a month, no card.
Start free Try it live
FAQ

Common questions

How do I authenticate with the Maskbreak API?
Pass your API key as a Bearer token in the Authorization header: Authorization: Bearer YOUR_API_KEY. API keys are generated from the dashboard after signup, one per account.
What is the API response time?
Each evaluation reports its server processing time in evaluated_in_ms. End-to-end latency also depends on network travel and the intelligence sources used. Set a deadline on your backend, measure your own traffic, and choose a fallback for timeouts or incomplete evidence.
What languages and SDKs are supported?
Official SDKs exist for Node.js (@sentinelsup/sdk), Python (sentinelsup) and PHP (sentinelsup/sdk on Packagist), plus a hosted MCP server for AI agents. The REST API can be called directly from any HTTP-capable environment including Go, PHP, Ruby, Java, Rust, C#, Kotlin, and Swift.
What's in the Free plan?
10,000 visitor checks a month (up to 1,000 an hour) with no credit card required. Every endpoint and signal is on the Free plan — VPN detection, residential-proxy scoring, antidetect browser detection and bot fingerprinting. Paid plans raise the allowance: Starter €29, Growth €99 and Scale €299 a month, excluding VAT.
What happens if I exceed my rate limit?
The API returns HTTP 429 with a Retry-After header. Two plan limits apply: the hourly cap (Retry-After says when the hourly window resets) and the monthly allowance (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.