Django
requests.post from wherever you already validate the form. The Django-specific trap is the IP: REMOTE_ADDR is your load balancer's address on almost every production deployment, not the visitor's.
Setup
- 1Put the key in an environment variable and read it with os.environ, not in settings.py.
- 2Resolve the client IP correctly — see the snippet, and read the warning on X-Forwarded-For.
- 3POST to https://www.layercall.com/v1/score/user with an explicit timeout.
- 4Raise ValidationError on block so the form redisplays with the message.
- 5Do it in the form's clean() rather than the view, so every code path that uses the form is covered.
Code
import os import requests from django.core.exceptions import ValidationError ENDPOINT = "https://www.layercall.com/v1/score/user" def client_ip(request): """ Only trust X-Forwarded-For if you terminate TLS behind a proxy you control. If anything can reach Django directly, a client can set this header to whatever it likes and pick its own reputation. """ xff = request.META.get("HTTP_X_FORWARDED_FOR", "") if xff and getattr(settings, "TRUST_PROXY", False): return xff.split(",")[0].strip() return request.META.get("REMOTE_ADDR", "") class SignupForm(forms.Form): email = forms.EmailField() def __init__(self, *args, request=None, **kwargs): self.request = request super().__init__(*args, **kwargs) def clean(self): cleaned = super().clean() try: r = requests.post( ENDPOINT, headers={"X-Api-Key": os.environ["LAYERCALL_KEY"]}, json={ "ip": client_ip(self.request), "email": cleaned.get("email"), }, timeout=5, ) r.raise_for_status() scored = r.json() except requests.RequestException: return cleaned # fail open if scored["verdict"] == "block": raise ValidationError(scored["summary"]) cleaned["risk_verdict"] = scored["verdict"] return cleaned
What to do with each verdict
Three outcomes, and the middle one is the one worth getting right — refusing a real customer usually costs more than reviewing them.
| Verdict | In Django |
|---|---|
| allow | Return cleaned data and let the view save. |
| review | Save with is_active=False, or a risk_verdict column, and require email confirmation before the account can do anything. |
| block | raise ValidationError(scored['summary']). The summary is written for a person to read, so it renders straight into the form's error list. |
What comes back
A real response, generated from the live API rather than written by hand. Branch on verdict; summary is a sentence written to be shown to a person, and components_checked tells you what actually went into the score.
Show the full response(POST /v1/score/user)
{ "risk_score": 65, "verdict": "review", "summary": "Needs review (65/100) — Tor exit node, commercial VPN and datacenter ASN.", "components": { "email": { "email": "test@guerrillamail.com", "normalized_email": "test@guerrillamail.com", "risk_score": 100, "verdict": "block", "status": "do_not_mail", "sub_status": "disposable", "deliverability_score": 0, "did_you_mean": null, "signals": { "syntax_valid": true, "mx_found": true, "is_disposable": true, "is_homograph": false, "is_role_account": true, "is_free_provider": false, "is_suspicious_handle": true, "is_tagged": false, "is_risky_tld": false, "is_new_domain": null, "has_spf": true, "has_dmarc": true, "has_website": true, "mailbox_exists": null, "is_catch_all": null, "mailbox_status": "unavailable", "has_digital_footprint": null }, "domain": "guerrillamail.com", "domain_age_days": null, "mx_provider": null, "mx_records": [ "mail.guerrillamail.com." ], "abuse_reports": 0, "digital_footprint": { "has_gravatar": false, "gravatar_profile_url": null, "breach_count": null, "seen_in_breach": null } }, "phone": { "parse_status": "ok", "phone": "+14155552671", "risk_score": 0, "signals": { "syntax_valid": true, "is_possible": true, "is_voip": false, "is_premium_rate": false, "is_toll_free": false, "assigned_area_code": true, "is_fictional": false }, "number": { "e164": "+14155552671", "country": "US", "national": "(415) 555-2671", "international": "+1 415 555 2671", "line_type": "fixed_line_or_mobile" }, "verdict": "allow", "abuse_reports": 0 }, "ip": { "ip": "185.220.101.1", "risk_score": 60, "signals": { "is_vpn": true, "is_proxy": true, "is_datacenter": true, "is_tor": true, "recent_abuse": false, "is_hijacked_netblock": false }, "geo": { "country": "DE", "city": "Berlin", "asn": "AS60729", "isp": "Stiftung Erneuerbare Freiheit" }, "vpn_provider": null, "hijacked_source": null, "verdict": "review", "abuse_reports": 0 } }, "components_checked": [ "email", "phone", "ip" ], "linkage": { "device_email_count": null, "email_device_count": 0, "email_ip_count": 0, "subnet_rate_1h": 2, "domain_rate_1h": 0 }, "actor": { "type": "unknown", "proven": false, "basis": "none", "operator": null, "trigger": null, "detail": "No signature and no device fingerprint. Drop fp.js on the page, or pass the agent's signed request, to get an answer here." }, "top_signals": [ "ip: tor exit node", "ip: commercial vpn", "ip: datacenter asn", "email: disposable domain", "email: role account", "email: machine-generated handle" ] }
Traps specific to Django
Behind nginx, ELB, Cloudflare or Heroku's router, REMOTE_ADDR is the proxy. Every visitor then scores against the same IP — usually a datacenter one, which is exactly the profile that raises risk. You will see uniformly elevated scores and conclude the API is broken.
Trusting it unconditionally is worse than using REMOTE_ADDR, because it lets a client choose its own IP. Gate it behind an explicit setting as above, and set that setting only where a proxy you control is guaranteed to be in front.
Omit timeout= and a slow response hangs the worker until the upstream gives up, which on a synchronous WSGI deployment means one fewer worker for the duration. Always pass it.
Questions
View, form, or signal?
The form's clean(). A view covers one code path; the form covers every path that uses it, including the admin and any API serializer built on it. A post_save signal is too late — the row already exists.
Will this slow down my signup page?
By roughly the round trip, around 300ms. If that matters more than blocking the signup, move it to a Celery task and score after the fact — the signal is just as valid a second later, you just have to undo rather than refuse.