# Maskbreak Integration Guide

Reviewed against the HTTP contract and server SDKs on 2026-10-19.
This guide is for developers and AI coding assistants adding a fraud check to
an existing application. Keep the application's authentication, authorization,
CSRF protection and rate limits in place.

- [API reference](https://maskbreak.com/api)
- [OpenAPI specification](https://maskbreak.com/openapi.json)
- [SDKs and Cloudflare Workers](https://maskbreak.com/integrations)
- [Product capabilities and limitations](https://maskbreak.com/llms.txt)

Get an account at [signup](https://maskbreak.com/signup). Configure the API key
in your server's environment or secret store as `MASKBREAK_API_KEY`. Never put it
in frontend code, commit it, or paste it into a public AI conversation.
If your app already uses another variable name, keep that name consistent in
your deployment settings and server code. The dashboard and examples below use
`MASKBREAK_API_KEY`.

Three steps give you the first complete check, one that carries both the
network token and the browser check. The dashboard's setup uses the same names:

1. **Add the script to the page with your form** (section 1).
2. **Send the check from your server** (section 2).
3. **Submit your form once.** Run your app where you can open the page, on your
   computer or on your site, and submit the form like a visitor would. The
   first check then appears in the dashboard (Integration tab and Events); if
   half of it did not arrive, the dashboard says which half.

One field mapping applies everywhere, whichever way the browser part sends it:

| Form field added by the script | `Sentinel.collect()` returns | Send to `POST /v1/evaluate` as | Layer |
|---|---|---|---|
| `monocle` | `token` | `token` | network |
| `sentinel_fp` | `fingerprintEventId` | `fingerprintEventId` | device (browser check) |
| `sentinel_tz` | `tz` | `tz` (optional) | browser time zone |

`/v1/evaluate` also accepts `sentinel_fp` and `sentinel_tz` under their form
names; always send the network token as `token`. Optional `email`
(disposable-domain check) and `accountId` (linked accounts) go in the same
JSON body.

## 1. Collect browser evidence

Load the unified SDK on the page containing the protected form, and put
`class="monocle-enriched"` on the `<form>` element itself (never on an input):

```html
<script async src="https://maskbreak.com/assets/sentinel.js" data-site="YOUR_SITE_ID"></script>

<form class="monocle-enriched" method="POST" action="/your-login-endpoint">
  <!-- Your existing login inputs stay here.
       The SDK adds monocle (network), sentinel_fp (device) and sentinel_tz. -->
</form>
```

`data-site` is optional. The dashboard's setup screen (Integration tab) shows
this line with your account's own site ID in place of `YOUR_SITE_ID`: a public
ID (`site_` and 20 letters and digits), not a secret and not the API key, so it
belongs in the page. With it, the script tells Maskbreak once per page load
which site it runs on: one request to `https://maskbreak.com/v1/site-seen`
carrying only the ID, to which the visitor's browser adds the page's origin
(scheme, host and port). No cookies, no page path or query and no visitor data
are sent, nothing is stored in the browser, and the setup screen shows "We see
Maskbreak on <your site>" before your server code is in place. Without the
attribute, or with a value that is not a site ID (such as the placeholder
above), the script works exactly the same and sends nothing extra. An AI
coding assistant given the dashboard's setup instructions gets the line with
the real ID: keep `data-site` as given.

Does your site send a `Content-Security-Policy` header? Many Express apps do
through helmet, whose default policy blocks this script until you add
Maskbreak's hosts: see [Content Security Policy](#content-security-policy)
below. The fields fill in a second or two after the page loads; a form posted
faster carries only what has arrived, and watch mode (section 2) reports that.

Ordinary form submissions forward the injected `monocle`, `sentinel_fp` and
`sentinel_tz` fields. For a form your JavaScript submits (fetch/XHR, React,
Next.js, Vue), call `Sentinel.collect()` when the form is submitted and send
its result with your existing form data. Check that the script is there first,
and never hold the form because of it: a blocked or missing script only means
the check runs without that evidence.

```javascript
const evidence = window.Sentinel ? await window.Sentinel.collect() : {};
// evidence = { token, fingerprintEventId, tz }: send it with the form data.
```

The script keeps one hidden form of its own on the page (`#_sntl_net`), so a
client-rendered app (React, Next.js, Vue, Svelte) gets the network token without
any extra markup, also for forms it renders after the page has loaded, and
`collect()` returns it. Read it with `collect()`: a form your app renders after
the page has loaded may post its own fields without `monocle`. The hidden form
is never submitted and is not one of your forms; it carries `data-sentinel-net`,
so if your own tests select a bare `form`, use `form:not([data-sentinel-net])`.

`collect()` waits at most 5 seconds for the device check, then returns what it
has: `fingerprintEventId` is `null` when the check has not finished (it keeps
running and still fills your forms when it does), and `token` is `null` until
the network script has written it. Pass `collect({ timeout: 2000 })` to change
the wait (milliseconds); `window.Sentinel.ready()` takes the same option.

### Content Security Policy

If your site sends a `Content-Security-Policy` header, add these sources to the
directives you already have (keep `'self'` and your own hosts). All of them are
Maskbreak's own hosts. helmet's default policy (`script-src 'self'`) blocks the
script until they are added:

```
script-src  https://maskbreak.com https://mcl.maskbreak.com https://*.mcl.maskbreak.com https://mesu.maskbreak.com 'wasm-unsafe-eval'
connect-src https://maskbreak.com https://mcl.maskbreak.com https://*.mcl.maskbreak.com wss://mcl.maskbreak.com wss://*.mcl.maskbreak.com https://mesu.maskbreak.com
worker-src  https://mesu.maskbreak.com blob:
```

`maskbreak.com` serves `sentinel.js` and the network script's loader, which
continues on `mcl.maskbreak.com` and its measurement hosts, and receives the
optional `data-site` report (the only reason it is in `connect-src`);
`mesu.maskbreak.com` serves the device check, which compiles WebAssembly and
starts a worker. A policy without one of them breaks nothing on your page: the
browser blocks that part, and the evaluation goes on without it (no `token`, or
`fingerprintEventId: null`; a blocked `data-site` report only means the setup
screen cannot show where the script runs). Check your browser console for CSP
reports after adding the script.

With helmet, extend its defaults rather than replacing them:

```javascript
app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      scriptSrc: ["'self'", 'https://maskbreak.com', 'https://mcl.maskbreak.com', 'https://*.mcl.maskbreak.com', 'https://mesu.maskbreak.com', "'wasm-unsafe-eval'"],
      connectSrc: ["'self'", 'https://maskbreak.com', 'https://mcl.maskbreak.com', 'https://*.mcl.maskbreak.com', 'wss://mcl.maskbreak.com', 'wss://*.mcl.maskbreak.com', 'https://mesu.maskbreak.com'],
      workerSrc: ["'self'", 'https://mesu.maskbreak.com', 'blob:']
    }
  }
}));
```

Send evidence to your own backend, not to the Maskbreak API with a secret key
from the browser. Treat a missing collector, token or device event as unavailable
evidence. Do not fabricate a token or replace it with a sample.

## 2. Evaluate before the protected action

Roll out in two steps with one switch, `MASKBREAK_MODE`. The same handler
serves both, so switching needs no code change.

1. **Watch (the default).** Deploy with `MASKBREAK_MODE` unset or `watch`.
   Every submission is sent to `/v1/evaluate`, also when a browser field is
   missing: Maskbreak then records which half did not arrive, and the
   dashboard shows it instead of waiting in silence. One log line records the
   decision, reasons, `risk_score`, the `X-Request-Id` response header and what
   enforce mode would have done. The existing handler always runs, including
   when browser evidence is missing, the check fails or Maskbreak is
   unavailable, so a mistake in the integration cannot lock real users out.
2. **Enforce.** After a few days, compare those log lines with the
   dashboard's Events. When they look right, set `MASKBREAK_MODE=enforce` and
   redeploy. The handler then stops `block`, holds `review`, missing or
   degraded evidence for your verification flow (a submission with a missing
   field is held without a call), rejects test, sample and sandbox responses,
   and holds the action when the check is unavailable. One exception: 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, as watch mode does, until the allowance resets on the 1st at 00:00 UTC
   or the plan changes. Running out of checks never takes your form offline.

This Node.js/Express example uses the HTTP API so it can forward all three
browser fields, including the optional timezone. The application must already
define `existingLoginHandler`; it performs the actual credential verification,
session creation and authorization. Configure your normal body-size limits.

In enforce mode the example holds incomplete or review results instead of
automatically allowing them. Connect that response to your own
verification/review flow. For a lower-risk action, you may choose another
documented fallback policy.

```javascript
// Server-side only. Node.js 18+ (fetch and AbortSignal.timeout).
// 'watch' (default): send every submission, log what Maskbreak would do, 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 fields: 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, requestId = null, hold = null, monthUsedUp = false;
  let status = null, apiError = null; // the HTTP status and the API's own error
  // Watch mode sends the check even with a field missing, so the dashboard
  // can say which half did not arrive. Enforce mode holds it without a call.
  if (!missing || MASKBREAK_MODE !== 'enforce') {
    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, fingerprintEventId, tz })
      });
      requestId = response.headers.get('x-request-id');
      status = response.status;
      if (!response.ok) {
        const refusal = await response.json().catch(() => null);
        apiError = refusal?.code ?? refusal?.error ?? 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('Evaluation unavailable');
      } else {
        result = await response.json();
        if (!result || !['allow', 'review', 'block'].includes(result.decision) ||
            result.test || result.sample || result.sandbox) {
          throw new Error('No production verdict');
        }
        if (result.decision === 'block') {
          hold = [403, 'Request declined.'];
        } else if (result.decision === 'review' || result.degraded || !result.device) {
          hold = [409, 'Verification required.'];
        }
      }
    } catch {
      hold = [503, 'Verification unavailable. Try again later.'];
    }
  }
  if (missing) hold = [409, 'Verification required.']; // enforce never trusts half a check
  // One line per check. Never log the key, browser tokens or form data.
  // status 401: MASKBREAK_API_KEY is empty or wrong where this code runs.
  console.log('[maskbreak]', JSON.stringify({
    mode: MASKBREAK_MODE,
    status,
    error: apiError,
    decision: result?.decision ?? null,
    reasons: result?.reasons ?? null,
    risk_score: result?.risk_score ?? null,
    request_id: requestId,
    month_used_up: monthUsedUp,
    enforce_would: hold ? 'return ' + hold[0] : 'continue'
  }));
  if (MASKBREAK_MODE === 'enforce' && hold) {
    return res.status(hold[0]).json({ error: hold[1] });
  }
  return next(); // Watch, or a passed gate; existing authentication still runs.
}, existingLoginHandler);
```

Watch mode is for rollout, not permanent protection: it records what would
have happened and blocks nothing. Enforce mode is the fraud gate; keep its
checks intact when you adapt the example.

For signup or checkout, keep the same separation: a fraud gate followed by the
existing action handler. Do not derive a trusted `accountId` from arbitrary
client input; if linking accounts, use the application's server-validated identifier.

### Forms rendered by JavaScript: Next.js App Router

A form your app renders and submits with `fetch` sends the evidence that
`Sentinel.collect()` returns, already under the API's names. Load the script
once in the root layout:

```jsx
// app/layout.js
import Script from 'next/script';

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>
        {children}
        <Script src="https://maskbreak.com/assets/sentinel.js" data-site="YOUR_SITE_ID" strategy="afterInteractive" />
      </body>
    </html>
  );
}
```

Collect on submit, in the client component that renders the form. A blocked
or missing script never stops the form:

```jsx
// app/signup/signup-form.js
'use client';

export default function SignupForm() {
  async function onSubmit(event) {
    event.preventDefault();
    const email = new FormData(event.currentTarget).get('email');
    const evidence = window.Sentinel ? await window.Sentinel.collect() : {};
    const response = await fetch('/api/signup', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email, ...evidence })
    });
    // Handle response.status in your existing signup UI.
  }
  return (
    <form onSubmit={onSubmit}>
      <input type="email" name="email" required />
      <button type="submit">Sign up</button>
    </form>
  );
}
```

In TypeScript, declare the global once:
`declare global { interface Window { Sentinel?: { collect(o?: { timeout?: number }): Promise<{ token: string | null; fingerprintEventId: string | null; tz: string | null }> } } }`.

Check on the server, in the Route Handler, with the same watch/enforce switch
as the Express handler. The key stays in a server-only environment variable
(never a `NEXT_PUBLIC_` one):

```javascript
// app/api/signup/route.js: Next.js App Router Route Handler (server only).
const MASKBREAK_MODE = process.env.MASKBREAK_MODE || 'watch';

export async function POST(request) {
  const body = (await request.json().catch(() => null)) || {};
  const { token, fingerprintEventId, tz } = body; // the names collect() returns
  const missing = typeof token !== 'string' || !token ||
    typeof fingerprintEventId !== 'string' || !fingerprintEventId;
  let result = null, hold = null, monthUsedUp = false, status = null, apiError = null;
  if (!missing || MASKBREAK_MODE !== 'enforce') { // watch mode always asks
    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, fingerprintEventId, tz, email: body.email })
      });
      status = response.status;
      if (!response.ok) {
        const refusal = await response.json().catch(() => null);
        apiError = refusal?.code ?? refusal?.error ?? null;
        // The month's checks are used up: continue unchecked until the 1st, logged.
        if (response.status === 429 && refusal?.code === 'monthly_quota_exceeded') monthUsedUp = true;
        else throw new Error('Evaluation unavailable');
      } else {
        result = await response.json();
        if (!result || !['allow', 'review', 'block'].includes(result.decision) ||
            result.test || result.sample || result.sandbox) {
          throw new Error('No production verdict');
        }
        if (result.decision === 'block') {
          hold = [403, 'Request declined.'];
        } else if (result.decision === 'review' || result.degraded || !result.device) {
          hold = [409, 'Verification required.'];
        }
      }
    } catch {
      hold = [503, 'Verification unavailable. Try again later.'];
    }
  }
  if (missing) hold = [409, 'Verification required.'];
  // status 401: MASKBREAK_API_KEY is empty or wrong where this code runs.
  console.log('[maskbreak]', JSON.stringify({
    mode: MASKBREAK_MODE,
    status,
    error: apiError,
    decision: result?.decision ?? null,
    reasons: result?.reasons ?? null,
    month_used_up: monthUsedUp,
    enforce_would: hold ? 'return ' + hold[0] : 'continue'
  }));
  if (MASKBREAK_MODE === 'enforce' && hold) {
    return Response.json({ error: hold[1] }, { status: hold[0] });
  }
  return createAccount(body); // your existing signup logic, unchanged
}
```

A Server Action that reads `formData.get('monocle')` gets no token when the
form appeared after a client-side navigation: call `collect()` in the browser
instead. If your app sets a Content-Security-Policy (in `next.config.js`
headers or in middleware), add the sources listed under
[Content Security Policy](#content-security-policy) to it.

### Official server SDKs

Node: `npm install @sentinelsup/sdk`; Python: `pip install sentinelsup`;
PHP: `composer require sentinelsup/sdk`. Their evaluation helpers accept
`token`, `fingerprintEventId`, optional `accountId` and `email`.
They refuse to send a request without a non-empty token and do not forward
`tz`. In watch mode, send a submission that arrived without a token over raw
HTTP as above, so the dashboard can say the token did not arrive; use raw HTTP
too when you need timezone input.

### Raw HTTP / Python

Forward both evidence fields in every language. This function performs only
the evaluation. Wrap it like the handler above: read `MASKBREAK_MODE`
(default `watch`), log one line and continue in watch mode, and apply the same
decision, test-mode and unavailable-evidence guards in enforce mode before
taking a protected action. `raise_for_status()` raises on a used-up month too:
when the 429 carries the code `monthly_quota_exceeded`, let the action through
and log it, as the handler above does.

```python
import os
import requests

# In watch mode, call it for every submission: pass None for a missing field.
def evaluate(token: str | None, fingerprint_event_id: str | None, tz: str | None = None) -> dict:
    response = requests.post(
        "https://maskbreak.com/v1/evaluate",
        headers={
            "Authorization": f"Bearer {os.environ['MASKBREAK_API_KEY']}",
            "Content-Type": "application/json",
        },
        json={"token": token, "fingerprintEventId": fingerprint_event_id, "tz": tz},
        timeout=5,
    )
    response.raise_for_status()
    return response.json()
```

Python 3.10+; `requests` uses connect/read timeouts, not a hard total wall-clock
deadline. Choose a bounded timeout strategy appropriate to your server.

## 3. Read the response

Selected fields from the public synthetic VPN sample:

```json
{
  "sample": true,
  "decision": "review",
  "risk_score": 65,
  "ip": "198.51.100.18",
  "country": "NL",
  "network": {
    "vpn": true,
    "proxy": false,
    "privacy_relay": false,
    "datacenter": true,
    "anonymous": true,
    "tor": false,
    "residential": false,
    "service": "PROTON_VPN"
  },
  "reasons": ["vpn_detected", "datacenter_asn"],
  "evaluated_in_ms": 28
}
```

- Route on `decision`: `allow`, `review` or `block`. The base policy reviews
  VPN-only traffic, since 2026-09-29 a browser inside a virtual machine
  (`virtual_machine`), and since 2026-10-10 Apple's iCloud Private Relay
  (reason `privacy_relay`, `network.privacy_relay: true`; `network.proxy`
  stays true, and any other proxy still blocks); customer rules and exception
  pins can change the final result.
- `risk_score` is a signal score, not a probability of fraud or an accuracy measure.
- `isSuspicious` is a legacy engine flag, not the final policy decision. It
  never changes with email, timezone, custom rules or exception pins.
- `device` appears when device evidence resolves successfully; sending an event
  identifier does not guarantee a device block. The VPN sample has no device
  evidence. The `bot` sample includes synthetic device signals.
- Live evaluations also say how usable the evidence was, without changing
  `decision`: `device_evidence` is `"ok"`, `"unavailable"` (an event id was sent
  but did not resolve) or `"not_sent"`; `network.complete` is false for a
  partial network reading and `network.sdk_blocked` is true when the network
  check could not measure the visitor; `evidence.network_age_s`,
  `evidence.device_age_s` and `evidence.device_replayed` say how fresh it was.
  In enforce mode the handler above already holds every result without
  `device`, which covers a `device_evidence` other than `"ok"`. For a sensitive
  action (signup, payout, password reset) you may also hold when
  `network.sdk_blocked` is true, and ask for a fresh check when the evidence is
  old or replayed. Test tokens, the sandbox and samples do not carry these fields.
- `network.service` names the VPN/proxy service when known, otherwise it may be null.
- `evaluated_in_ms` is server processing time; the sample's 28 is a fixture,
  not a latency measurement. End-to-end timing includes collection and transport.
- Optional `email` adds disposable-domain screening; it is checked transiently.
  Optional `accountId` (your own internal user ID, never an email or a name)
  supports per-customer linked-account counts and, since 18 October 2026, the
  `account` object: how many devices this account used and how many of your
  accounts this device or address carried in the last 24 hours, plus the
  reasons `account_new_device`, `account_many_devices`, `ip_many_accounts`
  (informational) and `device_many_accounts` (3 or more accounts on one device
  in 24 hours: raises allow to review). Send it at signup with the new
  account's ID and at sign-in with the account being signed into.
- With account credentials, missing network tokens can produce `degraded: true`
  over raw HTTP. Unavailable evidence must not silently count as a clean check.

See [OpenAPI](https://maskbreak.com/openapi.json) for the complete response,
optional fields and reason codes. Do not reject future additive fields.

## Confirm your website is connected

Step 3 of the setup: run your app where you can open the page, on your
computer or on your site, and submit your form once. The check appears in the
dashboard (Integration tab and Events). A complete check carries both halves,
the network token and the browser check; when one did not arrive, the
dashboard says which. The usual causes: the class is not on the form, the
site's Content-Security-Policy blocks the script, or the server skips the call
when a field is empty (in watch mode it sends every submission). In watch
mode, check that each submission also wrote one `[maskbreak]` log line. Nothing
in the dashboard? That line's `status` says why: 401 means `MASKBREAK_API_KEY`
is empty or wrong where the form runs (a call without a usable key cannot be
linked to your account, so the dashboard never sees it), and no line at all
means the handler did not run.

Testing on your computer? Your live key works on `localhost`: its first
complete check there finishes the setup, and your website is confirmed later,
from its public address. The test key is for automated tests: its checks are
marked TEST and never finish the setup. A complete check sent with the test
key shows that your code works (the dashboard says so); send the form once
more with your live key to finish.

The first complete check from a public website, sent with your live key,
confirms that website in the dashboard by itself: there is nothing to start or
fill in. Checks from `localhost` or another development address, the
dashboard's own browser tests, test keys, samples, old or replayed events and
incomplete checks do not confirm a website. The confirmation says that
evidence reached Maskbreak from that website, not that you own the domain or
that your app acts on the decision. Before switching to enforce, separately
test that your backend in enforce mode continues an allow, pauses a review and
stops a block without removing its existing security.

## Test checklist

These are reproducible contract tests, not a real-world detection benchmark.
Run them in staging with protected actions stubbed out. In enforce mode the
handler above intentionally rejects test/sample responses; do not remove that
guard on a live action to make a fixture pass.

### A. Exercise each base decision without an account

```bash
curl --fail-with-body 'https://maskbreak.com/v1/evaluate/sample?scenario=vpn'
curl --fail-with-body https://maskbreak.com/v1/evaluate \
  -H 'Authorization: Bearer sk_test_sandbox' \
  -H 'Content-Type: application/json' \
  -d '{"token":"test_vpn"}'
```

| Public sample scenario | Sandbox token | Base decision |
|---|---|---|
| clean | test_clean | allow |
| vpn | test_vpn | review |
| datacenter | test_datacenter | allow |
| proxy | test_proxy | block |
| tor | test_tor | block |
| bot | Not a deterministic test token | block |

The cloud-server fixture has a signal without a base block. The bot sample
contains synthetic browser-tampering and automation evidence. VPN does not
prove the block path; use proxy or Tor fixtures for that branch.

Samples and sandbox calls do not contact live intelligence providers.
Deterministic tokens are unbilled and not webhooked. Account/console test
events may be retained as test events; do not assume all testing is unstored.
Public samples and sandbox calls are rate-limited.

### B. Prove your application's failure paths

Stub the evaluation response locally and run every case in both modes.

In watch mode, confirm that every case below, including block, missing
evidence and an outage, writes one log line and reaches the existing handler,
and that a submission with a missing field still reaches `/v1/evaluate`.

In enforce mode, confirm that:

- review pauses the action for your verification flow;
- block does not reach the protected handler;
- timeout, invalid JSON, missing decision, 401, 403, 503 and any other 429 follow your outage policy;
- a 429 with the code `monthly_quota_exceeded` (the month's checks are used up)
  reaches the existing handler unchecked and writes its log line;
- missing token, missing device event, missing device result and degraded evidence
  cannot silently become trusted sessions;
- sample/sandbox/test responses cannot authorize a real action;
- only an acceptable live allow result reaches the existing action handler;
- that handler still checks credentials, permissions, CSRF and rate limits.

Never flood production or send real signup emails to run this checklist.

### C. Evaluate real detection separately

Use a small, consented set of your own browsers and connections. Record the
test date, browser/version, scenario, expected outcome, available signals and
actual decision. Keep identifiers and raw evidence private.

Measure false flags as well as missed cases. Compare VPN-only, ordinary home
connections, cloud-hosted clients and permitted automation separately. Report
sample size, uncertainty, collection failures and the effect of custom rules.
Fixture success is not evidence of detection accuracy.

Measure collection time and end-to-end p50/p95/p99 separately from server
processing time; include failures, retries, regions and warm/cold conditions.
No guaranteed global latency or controlled accuracy benchmark is provided here.

Start in watch mode before enforcing on real users. See
[rollout guidance](https://maskbreak.com/blog/fraud-detection-rollout) and
[timeout policies](https://maskbreak.com/blog/fraud-api-timeouts).

## Error handling and operating scope

- 400: invalid request/token; fix the input, do not blindly retry.
- 401: missing or invalid account credential. `Missing Key.` with an
  `Authorization: Bearer` header usually means the key variable was empty
  where the call ran; the `hint` field names the likely cause.
- 403: credential/account restrictions or an edge rejection; inspect the JSON
  error and request ID when available. Do not bypass an allowlist.
- 429: honor `Retry-After`; queue, back off or use your chosen fallback. With
  `code: "monthly_quota_exceeded"` the calendar month's allowance is used up
  and `Retry-After` counts down to 00:00 UTC on the 1st: do not retry in a
  loop. The handlers above let the action through unchecked and log it until
  then, in enforce mode too, so a used-up month (or a bot flood that used it
  up) never takes the form offline; a bigger plan raises the allowance at once.
  The account owner gets an email at about 80% and 100% of the month.
- 503 with `code: "storage_unavailable"`: the key or account could not be
  checked at that moment. It is not an invalid key; honor `Retry-After` and use
  your outage policy meanwhile. Never treat it as an allow.
- Timeout/5xx: bounded retries only where safe. Never label an unavailable
  evaluation as a successful check or retry the protected action blindly.
- Keep diagnostics free of secret keys, browser tokens and unnecessary personal data.

Account test keys run the full live detection, send no webhooks and are stored
as test events; their checks count toward the monthly allowance (the fixed test
tokens do not). They are not the same as deterministic fixtures and must remain
secret. IP lookups have their own monthly allowance, ten times the checks;
inspect `/v1/usage` instead of hardcoding a customer's limit.

Bare-IP screening uses `GET /v1/lookup/{ip}`. Its current production coverage
is Tor exits and cloud ranges, not the live visitor's VPN/proxy or device
checks. An unknown lookup result is not evidence that a session is safe.

Review [privacy](https://maskbreak.com/privacy), the
[DPA](https://maskbreak.com/dpa) and
[security limitations](https://maskbreak.com/security-whitepaper) for your
deployment. The SDK does not by itself establish a lawful basis or compliance.
