> ## Documentation Index
> Fetch the complete documentation index at: https://docs.finowl.co/llms.txt
> Use this file to discover all available pages before exploring further.

# Fraud checks at sign up

> Validate the email, phone number and IP address of every new sign up in parallel, and decide whether to allow, challenge or block it.

Fake sign ups waste free trials, skew your metrics and send mail to addresses that bounce. Three checks catch most of them: is the email real, is the phone number real, and where is the request coming from. This guide runs all three in parallel when someone registers, then turns the answers into one decision.

## How it fits in your flow

<Steps>
  <Step title="User submits the sign-up form">
    Run your usual checks first, such as required fields. They are free and instant.
  </Step>

  <Step title="Your server calls Finowl three times, in parallel">
    Send the email to `POST /v1/email/validate`, the client IP to `POST /v1/ip/lookup` and the phone number to `POST /v1/phone/validate`. Each costs 1 credit, so a sign up costs 3.
  </Step>

  <Step title="Combine the answers">
    Block what can't work, such as an email that can't receive mail. Challenge what looks suspicious, such as a Tor exit node. Allow the rest.
  </Step>
</Steps>

## Example

```python Python (Flask) theme={null}
import os
from concurrent.futures import ThreadPoolExecutor

import requests
from flask import Flask, jsonify, request
from werkzeug.middleware.proxy_fix import ProxyFix

app = Flask(__name__)
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1)  # Trust one proxy for the client IP.

session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['FINOWL_API_KEY']}"
pool = ThreadPoolExecutor(max_workers=8)


def finowl(path, body):
    try:
        response = session.post(f"https://api.finowl.co{path}", json=body, timeout=5)
        return response.json() if response.ok else None
    except requests.RequestException:
        return None  # Fail open: never block sign up on an outage.


def check(email, phone, ip, form_country=None):
    ip_job = pool.submit(finowl, "/v1/ip/lookup", {"ip": ip})
    email_job = pool.submit(finowl, "/v1/email/validate", {"email": email})

    # Use the form's country, or else the IP country, to read numbers without a +.
    ip_info = ip_job.result()
    hint = form_country or (ip_info or {}).get("country_code") or None
    phone_body = {"phone": phone, **({"country": hint} if hint else {})}
    phone_info = finowl("/v1/phone/validate", phone_body)

    return email_job.result(), phone_info, ip_info


@app.post("/signup")
def signup():
    form = request.json
    email_info, phone_info, ip_info = check(
        form["email"], form["phone"], request.remote_addr, form.get("country")
    )

    # 1. Email: block what can't receive mail, offer typo fixes.
    if email_info and email_info["verdict"] == "invalid":
        return jsonify(
            error="This email address can't receive mail.",
            suggestion=email_info["did_you_mean"],
        ), 400
    if email_info and email_info["disposable"]:
        return jsonify(error="Please use a permanent email address."), 400

    # 2. Phone: ask for a fix, and store the clean format.
    if phone_info and not phone_info["valid"]:
        return jsonify(error="Please check your phone number.", reason=phone_info["reason"]), 400
    phone_e164 = phone_info["e164"] if phone_info else form["phone"]

    # 3. IP and cross-checks: challenge rather than block.
    risk = []
    if ip_info and ip_info["tor"]:
        risk.append("tor")
    if ip_info and ip_info["hosting"]:
        risk.append("hosting")
    if email_info and email_info["verdict"] == "risky":
        risk.append("email_" + email_info["reason"])
    if (
        ip_info and phone_info
        and ip_info["country_code"] and phone_info["country_code"]
        and ip_info["country_code"] != phone_info["country_code"]
    ):
        risk.append("country_mismatch")

    user = create_user(
        form["email"],
        phone_e164,
        signup_country=(ip_info or {}).get("country_code") or None,
        risk=risk,
    )
    if risk:
        send_verification_code(user)  # Step up: confirm the phone before unlocking the trial.
        return jsonify(status="verify_phone"), 202
    return jsonify(status="ok"), 201
```

The IP lookup runs first because its country is a useful hint for reading a phone number typed without a `+`. The email check runs alongside it, so the whole check takes about as long as two lookups.

## Acting on each answer

| Check | Answer | Suggested action |
| - | - | - |
| Email | `verdict: invalid` | Block. Show `did_you_mean` as a one-click fix when it is set. |
| Email | `reason: disposable` | Block, or allow with a limited trial. These addresses are thrown away within hours. |
| Email | `reason: a_record_fallback` | Allow, but confirm the address with a verification email before relying on it. |
| Email | `role: true` | Allow for B2B products. Note that `info@` reaches a team, not a person. |
| Phone | `valid: false` | Ask the user to correct it. `reason` tells you whether it's too short, too long or missing a country. |
| Phone | `valid: true` | Store `e164`. Send a one-time code if you need proof the user owns it. |
| IP | `tor: true` | Challenge with a CAPTCHA or phone code. Some products block Tor outright. |
| IP | `hosting: true` | Challenge. Real users rarely sign up from a cloud server, but VPNs and privacy relays also show up here. |
| IP | `reserved: true` | Fix your setup. You are sending your proxy's address, not the client's. |

<Tip>
  No single signal proves fraud. A traveler can have a phone from one country and an IP from another, and many people use VPNs. Combine signals and prefer a challenge over a block for anything except an email that can't receive mail.
</Tip>

## Using the IP country in your own logic

`country_code` from `ip/lookup` is reliable at country level, so it works well as a default and as a signal:

* **Prefill forms.** Use it as the default country for the phone field, currency and tax region. Let the user change it.
* **Read local phone numbers.** Pass it as `country` to `phone/validate` when the user didn't pick one, as in the example.
* **Compare with what the user tells you.** A billing country or phone country that differs from the IP country is a weak risk signal on its own and a stronger one alongside `hosting` or `tor`.
* **Apply regional rules.** Route sign ups from countries you don't serve to a waitlist, or show the right terms and consent wording.

Store the country with the account, as in `signup_country` above, rather than the IP address itself. It is less sensitive and is usually all you need later. If you show users a location from `ip/lookup`, credit "IP geolocation by [DB-IP](https://db-ip.com)".

## Design decisions

<AccordionGroup>
  <Accordion title="Fail open or fail closed?" icon="code-branch">
    The example fails open: if Finowl is slow or unavailable, the sign up continues without that check. A `503` such as `dns_unavailable` or `data_unavailable` is not charged, so you can also retry once while the user waits.
  </Accordion>

  <Accordion title="Do I need all three checks?" icon="sliders">
    No. Email validation alone catches typos and throwaway addresses, which are the most common problems. Add the IP check if you offer a free trial or credits, and the phone check if you collect a number anyway.
  </Accordion>

  <Accordion title="Tell users before you block" icon="message">
    Explain what to fix: "This email address can't receive mail. Did you mean [jane@gmail.com](mailto:jane@gmail.com)?" Don't tell users which risk signals you saw, because that helps abusers adjust.
  </Accordion>
</AccordionGroup>

<Warning>
  Email, phone and IP lookups process personal data. Mention them, and their purpose of preventing fraud, in your privacy notice. See [Data handling and privacy](/concepts/data-privacy).
</Warning>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.