The call itself is one line. Everything that makes a fraud check safe to deploy is around it: which routes it runs on, what it does when the vendor is having a bad day, and — specific to FastAPI — which side of the event loop it waits on.

This is written as a dependency rather than middleware, because dependencies are per-route, typed, and overridable in tests. Middleware sees every request including your static files and your health check, and then you spend the rest of the afternoon writing path prefixes to keep it off the fast path.

Install and configure

The official Python SDK is published on PyPI as sentinelsup and imported as sentinel. It is a single module built on urllib from the standard library, so it adds nothing to your dependency tree.

pip install sentinelsup

The client reads SENTINEL_KEY from the environment. Nothing goes in your settings module, and the key never reaches the browser:

# .env — never committed
SENTINEL_KEY=sk_live_...

On the frontend, the collector produces a token. Forward it from your own pages as the X-Sentinel-Token header on the requests you want screened; everything below assumes that header.

The dependency

One factory, parameterised by how strict the route should be. Any vendor failure returns None and the request continues.

# app/deps.py
import logging
from fastapi import Header, HTTPException, Request
from sentinel import Sentinel, SentinelError

log = logging.getLogger(__name__)

# One client for the process. Give it a short timeout: this sits in front
# of a user-facing action, so a slow answer is worth less than no answer.
client = Sentinel(timeout=1.5)   # reads SENTINEL_KEY


def screen(strict: bool = False):
    """Screening dependency. strict=True refuses anything not clean;
    the default refuses only a hard block."""

    def dependency(
        request: Request,
        x_sentinel_token: str | None = Header(default=None),
    ):
        if not x_sentinel_token:
            # An ad blocker or a CSP mistake can legitimately stop the
            # collector. Degraded, not hostile — unless this is money.
            if strict:
                raise HTTPException(status_code=400, detail='missing security token')
            return None

        try:
            result = client.evaluate(
                token=x_sentinel_token,
                account_id=getattr(request.state, 'user_id', None),
            )
        except SentinelError as e:
            # Fail open. A detection outage must not become a checkout outage.
            log.warning('sentinel unavailable: %s', e)
            return None

        refuse = result.is_suspicious if strict else result.is_blocked
        if refuse:
            log.info('refused path=%s score=%s reasons=%s',
                     request.url.path, result.risk_score, result.reasons)
            raise HTTPException(
                status_code=403,
                detail={'error': 'blocked', 'reasons': result.reasons},
            )
        return result

    return dependency

The FastAPI-specific part

Look at the inner function again: it is def, not async def. That is deliberate and it is the one detail in this post that is genuinely about FastAPI rather than about fraud.

The SDK is synchronous — urllib, a blocking socket read. FastAPI runs synchronous dependencies and path operations in a threadpool, so the event loop stays free to serve other requests while this one waits on the network. Declare the same function async def and you have moved a blocking call directly onto the loop: one slow verdict now stalls every concurrent request in the process, and the symptom at 200 requests per second is a latency cliff that looks nothing like a fraud problem.

If you would rather keep the dependency async def for consistency with the rest of your codebase, hand the blocking part off explicitly:

import anyio

async def dependency(request: Request, x_sentinel_token: str | None = Header(default=None)):
    ...
    result = await anyio.to_thread.run_sync(
        lambda: client.evaluate(token=x_sentinel_token)
    )

Either shape is fine. Blocking the loop is not.

Different actions, different strictness

Attach the dependency where the decision matters, not globally. A single threshold across every route is always wrong somewhere: too tight on a newsletter form, too loose on a withdrawal.

# app/routes.py
from fastapi import APIRouter, Depends
from app.deps import screen

router = APIRouter()

# Signup: refuse only a hard block (proxy, Tor, automation, emulator,
# tampering). VPN users are customers.
@router.post('/signup', dependencies=[Depends(screen())])
async def signup(payload: SignupIn):
    ...

# Money leaving: refuse anything that is not clean.
@router.post('/withdraw', dependencies=[Depends(screen(strict=True))])
async def withdraw(payload: WithdrawIn):
    ...

When you want the verdict in the handler rather than just the gate, take it as a value. review is the interesting case — it is the one that should cost the user a step, not the account:

@router.post('/checkout')
async def checkout(payload: CheckoutIn, verdict = Depends(screen())):
    if verdict and verdict.decision == 'review':
        return await step_up(payload)        # OTP, 3DS, manual queue
    return await charge(payload)

The rule that matters here: review must not route to the same place as block. If it does, you have built a hard block with extra steps, and every VPN user in your funnel is a refused customer you will never hear about. There is more on that split in route on the decision, sort on the score.

Pass an account id and get multi-accounting for free

The account_id argument in the dependency above is doing more than logging. When it is present, the same device arriving under a second account is linkable, which is the signal that actually catches trial abuse, referral farming and bonus abuse — none of which look wrong on any single request.

Two constraints worth knowing before you wire it up. Linking is per-customer and hash-only: your accounts are never linked against another customer's, and the raw device identifier is not what gets stored. And it only works if you pass a stable id — a session id changes on every login and links nothing.

Testing it in CI

The public sandbox key returns the documented response shapes for a fixed set of tokens, with no account and no live pipeline behind it. Point your test environment at it and your fraud paths become ordinary tests:

curl -X POST https://maskbreak.com/v1/evaluate \
  -H "Authorization: Bearer sk_test_sandbox" \
  -H "Content-Type: application/json" \
  -d '{"token":"test_proxy"}'

# token            decision   risk_score   reasons
# test_clean       allow        0          []
# test_datacenter  allow       15          [datacenter_asn]
# test_tor         block       15          [tor_exit_node, anonymous_network]
# test_vpn         review      65          [vpn_detected, datacenter_asn]
# test_proxy       block       80          [proxy_detected, datacenter_asn]
# tests/conftest.py
import os
os.environ.setdefault('SENTINEL_KEY', 'sk_test_sandbox')

# tests/test_withdraw.py
def test_withdraw_refuses_a_proxy(client):
    r = client.post('/withdraw', json={'amount': 100},
                    headers={'X-Sentinel-Token': 'test_proxy'})
    assert r.status_code == 403

def test_withdraw_allows_a_clean_session(client):
    r = client.post('/withdraw', json={'amount': 100},
                    headers={'X-Sentinel-Token': 'test_clean'})
    assert r.status_code == 200

For the failure path, override the dependency instead of mocking the network. That is the reason to use a dependency in the first place:

app.dependency_overrides[screen] = lambda: None   # vendor down → request proceeds

Notice what that test asserts: when the fraud check is unavailable, the withdrawal still goes through. If that makes you uncomfortable, the fix is a lower rate limit and a review queue, not a check that fails closed.

The short version

  • Use a dependency, not middleware. Per-route, typed, overridable in tests.
  • Keep the dependency def, not async def, so the blocking SDK call runs in the threadpool.
  • Short timeout, and treat any failure as allow.
  • Strict on money, hard-block-only on signup, nothing on static routes.
  • Pass a stable account_id if multi-accounting is part of your problem.
  • Test the fraud paths with the sandbox key; test the outage path with a dependency override.
FAQ

Frequently Asked Questions

Should the FastAPI dependency be async or sync?

Sync. The Python SDK uses blocking urllib, and FastAPI runs synchronous dependencies in a threadpool, so the event loop keeps serving other requests while the call is in flight. An async def dependency that makes a blocking call stalls every concurrent request in the process. If you want it async for consistency, wrap the call with anyio.to_thread.run_sync.

Middleware or dependency for fraud checks in FastAPI?

A dependency, in almost every case. Middleware runs on every request including static files and health checks, so you end up maintaining path prefixes to keep it off the fast path. A dependency attaches to the specific routes where a decision is worth making, takes parameters such as strictness, and can be replaced with dependency_overrides in tests.

What should happen when the fraud API times out?

Allow the request. A check that fails closed converts a vendor incident into an outage of your signup, checkout or login, which is almost always the more expensive failure. Set a short client timeout, catch the error, return no verdict, and count the event so a quiet degradation still shows up on a dashboard.

How do I test fraud paths without hitting the live API?

Use the public sandbox key sk_test_sandbox with the deterministic test tokens — test_clean, test_datacenter, test_vpn, test_proxy, test_tor. Each returns a fixed decision and risk score, so allow, review and block paths become ordinary assertions in CI with no account and no live traffic.

Wire it up on real traffic

The sandbox key returns the documented allow, review and block shapes with no account. When you are ready for live verdicts, the free tier is 1,000 requests per hour.

Try Maskbreak free →