Most integration examples show the happy path and stop. This one is written as Django middleware you would actually deploy: guarded by path so it stays off the fast path, failing open when the vendor has a bad day, and applying different strictness to different actions rather than one global rule.
Everything here uses the official Python SDK, published on PyPI as sentinelsup and imported as sentinel. It is a single module built on urllib from the standard library, so it adds no dependencies to your project.
Install and configure
pip install sentinelsup
The client reads SENTINEL_KEY from the environment, so nothing goes in settings.py and the key never reaches the client:
# .env — never committed
SENTINEL_KEY=snt_live_...
On the frontend, the collector sets a token that your templates forward as the X-Sentinel-Token header on guarded requests. Everything below assumes that header.
The middleware
One class, guarded by path prefix, parameterised by how strict each group of routes should be. Any vendor failure results in the request continuing.
# yourapp/middleware.py
import logging
from django.http import JsonResponse
from sentinel import Sentinel, SentinelError
log = logging.getLogger(__name__)
# Refuse anything not clean on money movement; refuse only the worst band
# elsewhere. A single global threshold is always wrong somewhere.
STRICT = ('/api/withdraw', '/api/transfer', '/api/redeem')
GUARDED = ('/api/checkout', '/api/signup')
class SentinelMiddleware:
def __init__(self, get_response):
self.get_response = get_response
self.sentinel = Sentinel() # reads SENTINEL_KEY
def __call__(self, request):
path = request.path
strict = path.startswith(STRICT)
# Cheap string check first: product pages and static never call out.
if not strict and not path.startswith(GUARDED):
return self.get_response(request)
token = request.META.get('HTTP_X_SENTINEL_TOKEN')
if not token:
# An ad blocker or a CSP failure can legitimately stop the
# collector. Degraded, not hostile — unless the action is strict.
if strict:
return JsonResponse({'error': 'missing security token'}, status=400)
request.sentinel = None
return self.get_response(request)
try:
result = self.sentinel.evaluate(
token=token,
account_id=str(request.user.pk) if request.user.is_authenticated else None,
)
except SentinelError as e:
# Fail open on vendor or network trouble. Rate limits and
# post-hoc review still apply; a detection outage should not
# become a checkout outage.
log.warning('sentinel unavailable: %s', e)
request.sentinel = None
return self.get_response(request)
request.sentinel = result
refuse = result.is_suspicious if strict else result.is_blocked
if refuse:
log.info('sentinel refused %s score=%s reasons=%s',
path, result.risk_score, result.reasons)
return JsonResponse(
{'error': 'blocked', 'reasons': result.reasons}, status=403
)
return self.get_response(request)
Register it after authentication, so request.user is populated and device-to-account linking works:
# settings.py
MIDDLEWARE = [
# ...
'django.contrib.auth.middleware.AuthenticationMiddleware',
'yourapp.middleware.SentinelMiddleware',
]
Two details worth not skipping. is_suspicious is true for anything that is not allow, which is what you want on withdrawals; is_blocked is true only for the worst band, which is what you want on signup. And the reason list goes to your logs, not to the response — telling a blocked client precisely which signal fired is free tuning feedback for whoever is probing you.
Signup, where the view is the better place
Middleware runs before the form is parsed, so it cannot pass the submitted email. When you want the email signal, do the check in the view instead:
# yourapp/views.py
from django.http import JsonResponse
from sentinel import Sentinel, SentinelError
sentinel = Sentinel()
def signup(request):
form = SignupForm(request.POST)
if not form.is_valid():
return JsonResponse({'errors': form.errors}, status=400)
try:
result = sentinel.evaluate(
token=request.META.get('HTTP_X_SENTINEL_TOKEN', ''),
email=form.cleaned_data['email'], # transient, never stored
)
except SentinelError:
result = None # fail open
if result and result.is_blocked:
return JsonResponse({'error': 'signup unavailable'}, status=403)
user = form.save()
# 'review' is not 'block'. Step up instead of refusing.
if result and result.decision == 'review':
require_email_verification(user)
return JsonResponse({'ok': True})
Passing email adds email.disposable to the result and escalates a clean verdict to review on a burner domain. It never blocks on its own — and it should not; the reasoning is in flag it, don't block on it.
Django REST Framework
For DRF, a permission class keeps the check declarative and per-view:
# yourapp/permissions.py
from rest_framework.permissions import BasePermission
from sentinel import Sentinel, SentinelError
sentinel = Sentinel()
class NotFraudulent(BasePermission):
message = 'Request refused by risk checks.'
def has_permission(self, request, view):
token = request.META.get('HTTP_X_SENTINEL_TOKEN')
if not token:
return True # degraded, not hostile
try:
result = sentinel.evaluate(token=token)
except SentinelError:
return True # fail open
request.sentinel = result
return not result.is_blocked
class CheckoutView(APIView):
permission_classes = [IsAuthenticated, NotFraudulent]
Screening without a browser token
Not everything has a browser attached. For log enrichment, batch review of yesterday's signups, or screening a server-to-server caller, lookup() scores a bare IP with no token:
result = sentinel.lookup('185.220.101.34')
result['verdict'] # 'allow' | 'review' | 'block'
result['risk_score'] # 0-100
result['signals'] # {'vpn': ..., 'proxied': ..., 'tor': ..., 'dch': ...}
result['network'] # {'asn': ..., 'org': ..., 'country': ...}
One caveat that catches people: known: False means the feeds hold no data for that address. It is not a clean bill of health, and treating it as one turns every unlisted IP into a trusted one.
Rolling it out
Do not enable enforcement everywhere at once. Run in monitor mode first — keep the middleware, log the verdict, refuse nothing:
MONITOR_ONLY = True
if refuse and not MONITOR_ONLY:
return JsonResponse({'error': 'blocked'}, status=403)
if refuse:
log.info('would refuse %s score=%s', path, result.risk_score)
A week of that gives you your own baseline: what share of genuine traffic lands in review, which reasons dominate, and whether your own QA, staff tooling or office VPN trips it. Then enforce on the one action that is actually costing money, and expand from there. A mis-set threshold discovered in production on every route at once is a revenue incident; discovered on one route in monitor mode it is a config change.
Frequently Asked Questions
Should the middleware fail open or closed?
Open, for almost every action. If the detection API is unreachable and you fail closed, every signup and checkout during the outage fails; if you fail open, a window of traffic goes unscreened while your rate limits and post-hoc review still apply. The second is nearly always the cheaper incident. The exception is actions that are both irreversible and high-value, where the better answer is to queue for manual review rather than auto-approve or auto-refuse.
Where should the check live — middleware or the view?
Middleware when the rule is about the path, the view when the rule is about the payload. A middleware that guards checkout and withdrawal by prefix is clean and easy to audit. A signup check that needs the submitted email to enrich the verdict belongs in the view or form, because the middleware runs before you have parsed and validated it.
Does the SDK add dependencies to my Django project?
No. The Python SDK is a single module built on urllib from the standard library, so it pulls in nothing and works on any Django version that runs on a supported Python. It is published on PyPI as sentinelsup and imported as sentinel.
How do I keep this off the fast path for anonymous browsing?
Guard by prefix and return early. The middleware should do a cheap string check on request.path and hand straight back to get_response for anything that is not a high-value action. Product pages, static assets and health checks should never reach an outbound call — a fraud check on a page view is latency you are paying for nothing.
How should I roll this out to an existing app?
In monitor mode: run the middleware, record the verdict, and refuse nothing for about a week. That gives you your own baseline — what proportion of genuine traffic lands in review, which reason codes dominate, and whether internal QA or staff tooling trips it. Then enforce on one action, starting with whichever is actually costing money, and expand.
Drop it into your Django app
One pip install, one middleware, sub-40ms server-side p95.
Try Sentinel free →