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.

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.reasons with 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.