Signup check
Email, IP and phone in one call, with cross-checks between them and a single verdict. The recommended way to screen new accounts.
POST /v1/signup/check · 3 credits (+5 with verify_mailbox)
This is the one to use on a signup form. It runs the email, IP and phone checks together, adds checks that only make sense in combination, and gives you one verdict for the person.
Request
Send any combination of the fields. At least one is required.
| Field | Type | Description |
|---|---|---|
email |
string | The email they signed up with. |
ip |
string | Their IP address, as your server sees it. See which IP to send. |
phone |
string | Their phone number, if you collect one. |
phone_country |
string | Two-letter country, for numbers typed without +country. |
verify_mailbox |
boolean | Also verify the mailbox over SMTP (+5 credits). |
curl https://veqen-app.com/v1/signup/check \
-H "Authorization: Bearer $VEQEN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"email": "jane@mailinator.com", "ip": "185.220.101.1", "phone": "+14155552671"}'
def screen_signup(email, ip, phone=None):
r = requests.post(
"https://veqen-app.com/v1/signup/check",
headers={"Authorization": f"Bearer {VEQEN_API_KEY}"},
json={"email": email, "ip": ip, "phone": phone},
timeout=10,
)
r.raise_for_status()
return r.json()["risk"]
async function screenSignup({ email, ip, phone }) {
const r = await fetch("https://veqen-app.com/v1/signup/check", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.VEQEN_API_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ email, ip, phone }),
});
if (!r.ok) throw new Error(`Veqen ${r.status}`);
return (await r.json()).risk;
}
Response
The response holds the full result of each individual check (email, ip, phone, or null when you didn't send that field) plus the combined risk:
{
"email": { "email": "jane@mailinator.com", "disposable": true, "risk": { "...": "..." } },
"ip": { "ip": "185.220.101.1", "flags": { "tor": true, "hosting": false }, "risk": { "...": "..." } },
"phone": { "e164": "+14155552671", "line_type": "fixed_line_or_mobile", "risk": { "...": "..." } },
"risk": {
"score": 99,
"level": "high",
"verdict": "refuse",
"reasons": [
{ "code": "disposable_domain", "impact": 90, "source": "email", "message": "mailinator.com hands out throwaway inboxes." },
{ "code": "tor_exit_node", "impact": 85, "source": "ip", "message": "This address is a Tor exit node." },
{ "code": "automation_pattern", "impact": 40, "source": "signup", "message": "A throwaway inbox from a server or Tor network is the classic bot-signup pattern." },
{ "code": "phone_ip_country_mismatch", "impact": 15, "source": "signup", "message": "The phone number is from US but the visitor is connecting from DE." }
]
}
}
Each reason says which input it came from in source: email, ip, phone, or signup for the cross-checks.
Cross-checks
| Code | Impact | When |
|---|---|---|
automation_pattern |
40 | A throwaway email arriving from a datacenter or Tor network. |
phone_ip_country_mismatch |
15 | The phone number's country differs from the IP's country. Common for travellers and VPN users, so it's a nudge, not a block. |
How the score is combined
Signals combine like independent odds, so two medium signals add up to a stronger one, but never jump straight to 100:
score = 100 × (1 − (1 − a/100) × (1 − b/100) × …)
Two 50-point signals give 75. A 90-point signal alone gives 90.
Recommended handling
const risk = await screenSignup({ email, ip, phone });
if (risk.verdict === "refuse") {
return res.status(400).json({ error: "We couldn't verify these details. Please use a permanent email address." });
}
const user = await createUser({ email, needsVerification: risk.verdict === "review" });
- Don't tell people which signal tripped. It helps abusers tune around you.
- Do log
risk.reasonswith the user record. It makes support and later reviews easy. - Fail open. If the call errors or times out, create the account and require email confirmation.