How to Approve Real Users and Block Fake Accounts at Sign Up with Lookup and Verify in Python

August 27, 2026
Written by

By implementing onboarding intelligence with Twilio Lookup and phone verification with Twilio Verify, you can build seamless sign ups and higher pass rates, while still blocking fraud. Combining multiple fraud checks like detecting line type and proving phone number possession into one flow creates a resilient yet frictionless defense layer to block fake accounts while ensuring a smooth path for real users.

By the end of this tutorial you will have a working Python example that can collect a user's name and phone number and conduct a multi-step orchestrated identity verification flow. You can also find the completed code on GitHub.

Programming Language Support

This tutorial is geared towards Python developers, but you can find other languages below:

Prerequisites to building with Twilio Lookup and Verify

To code along with this post you will need:

curl -X POST "https://verify.twilio.com/v2/Services" \
    --data-urlencode "FriendlyName=My Verify Service" \
    -u $TWILIO_ACCOUNT_SID:$TWILIO_AUTH_TOKEN
If you're testing outside of the US and Brazil, you may see Error 60619: Lookup Request Cannot be Completed in Twilio Region. To bypass this, you will need carrier approval for Lookup Identity Match. Alternatively, you can use our sandbox experience with test credentials and magic numbers.

Set up your Python project

Now start your project to build a trusted sign up flow:

mkdir twilio-trusted-signups
cd twilio-trusted-signups
pip install flask twilio python-dotenv

Create a .env file and add the following keys:

TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
TWILIO_AUTH_TOKEN=your_auth_token
VERIFY_SERVICE_SID=VAxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Building the Verification Pipeline

This project will codify 4 layers of checks on a phone number during sign up. The best part is that the user won't know 3 of them are happening, and they get progressively more intense so we're filtering out bad actors faster and cheaply before taking more drastic actions.

Here's a look at what we're building:

A flowchart showing the process of user signup with document verification, including rejection and approval steps.

Process flow diagram for orchestrating onboarding intelligence

Step 1 - Check the line type

First, use the Lookup API line type intelligence package to make sure we're dealing with a mobile number. The code explicitly filters out landlines, nonfixed VoIP, toll free, (and pagers for fun) but you can customize this easily. Learn more about potential line types the API can return in the documentation.

Step 2 - [Optional] Check the line status

Then make sure the line is reachable. Use the Lookup API line status package to filter out inactive and unreachable numbers. Note - this is commented out by default in the code below since it is in Private Beta and requires an extra step to get access. To request access for Lookup Line Status, submit this form.

Step 3 - Match the name to the phone number

In the last of our background checks, use the Lookup API Identity Match package to verify that the submitted name matches the phone number. Identity Match compares user-supplied data against authoritative sources for a zero-knowledge result, in other words a way to verify the data's accuracy without revealing the underlying data. Check the individual firstNameMatch and lastNameMatch fields directly and require each to be either exact_match or high_partial_match. This allows common variations like nicknames or middle names used as a first name, while still rejecting mismatches. Any other result (a no_match, partial_match, or null) will reject the request.

You can change these requirements to fit your business logic or reduce false negatives. Learn more about Identity Match field values in the documentation.

Step 4 - Phone verification

If all of the lookup steps pass, the user will receive an OTP and complete a standard phone verification flow.

To implement all four steps, copy the following code into a new file called app.py:

import os
import sys
from dotenv import load_dotenv
from flask import Flask, render_template, request
from twilio.rest import Client
load_dotenv()
TWILIO_ACCOUNT_SID = os.environ.get("TWILIO_ACCOUNT_SID")
TWILIO_AUTH_TOKEN = os.environ.get("TWILIO_AUTH_TOKEN")
VERIFY_SERVICE_SID = os.environ.get("VERIFY_SERVICE_SID")
if not all([TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, VERIFY_SERVICE_SID]):
    sys.exit(
        "Missing required env vars. Check TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, VERIFY_SERVICE_SID."
    )
PORT = int(os.environ.get("PORT", 3000))
client = Client(TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN)
app = Flask(__name__)
def log_step(steps, label, detail, passed):
    steps.append({"label": label, "detail": detail, "passed": passed})
    icon = "✅" if passed else "❌"
    print(f"{icon} {label}: {detail}")
def normalize(value):
    return str(value or "").strip().lower()
def run_onboarding_intelligence(phone_number, first_name, last_name):
    steps = []
    # ---- 1) Line Type Intelligence
    try:
        lti = client.lookups.v2.phone_numbers(phone_number).fetch(
            fields="line_type_intelligence"
        )
    except Exception as e:
        return {"ok": False, "reason": "LOOKUP_FAILED", "detail": str(e), "steps": steps}
    line_type = normalize((lti.line_type_intelligence or {}).get("type"))
    # https://www.twilio.com/docs/lookup/v2-api/line-type-intelligence#type-property-values
    blocked_line_types = {"landline", "nonfixedvoip", "tollfree", "pager"}
    lti_passed = line_type not in blocked_line_types
    log_step(steps, "Line Type Intelligence", line_type, lti_passed)
    if not lti_passed:
        return {"ok": False, "reason": "LINE_TYPE_BLOCKED", "steps": steps}
    # ---- 2) Line Status
    # Uncomment once you have access to the package: https://docs.google.com/forms/d/e/1FAIpQLSfXowQ9dUGgDNc_onA0yj2_Mo3tXxFWK67SpDfOZjONothBYQ/viewform
    # try:
    #     ls = client.lookups.v2.phone_numbers(phone_number).fetch(fields="line_status")
    # except Exception as e:
    #     return {"ok": False, "reason": "LOOKUP_FAILED", "detail": str(e), "steps": steps}
    # line_status = normalize((ls.line_status or {}).get("status"))
    # ls_passed = line_status not in {"inactive", "unreachable"}
    # log_step(steps, "Line Status", line_status, ls_passed)
    # if not ls_passed:
    #     return {"ok": False, "reason": "LINE_STATUS_BLOCKED", "steps": steps}
    # ---- 3) Identity Match
    try:
        im = client.lookups.v2.phone_numbers(phone_number).fetch(
            fields="identity_match",
            first_name=first_name,
            last_name=last_name,
        )
    except Exception as e:
        return {"ok": False, "reason": "LOOKUP_FAILED", "detail": str(e), "steps": steps}
    im_error_code = (im.identity_match or {}).get("error_code")
    if im_error_code:
        log_step(steps, "Identity Match", f"unavailable (error_code: {im_error_code})", False)
        return {"ok": False, "reason": "IDENTITY_MATCH_UNAVAILABLE", "steps": steps}
    accepted_matches = {"exact_match", "high_partial_match"}
    first_name_match = (im.identity_match or {}).get("first_name_match")
    last_name_match = (im.identity_match or {}).get("last_name_match")
    im_passed = first_name_match in accepted_matches and last_name_match in accepted_matches
    log_step(steps, "Identity Match", f"first: {first_name_match}, last: {last_name_match}", im_passed)
    if not im_passed:
        return {"ok": False, "reason": "IDENTITY_MATCH_FAILED", "steps": steps}
    return {"ok": True, "steps": steps}
# -------------------- Routes --------------------
@app.get("/")
def index():
    return render_template("index.html", page="signup", title="Signup")
@app.post("/start")
def start():
    phone_number = request.form.get("phoneNumber", "").strip()
    first_name = request.form.get("firstName", "").strip()
    last_name = request.form.get("lastName", "").strip()
    if not all([phone_number, first_name, last_name]):
        return render_template("index.html", page="rejected", title="Error", reason="MISSING_FIELDS"), 400
    gate = run_onboarding_intelligence(phone_number, first_name, last_name)
    if not gate["ok"]:
        # boolean pass/fail + reason code (rendered)
        return render_template(
            "index.html",
            page="rejected",
            title="Rejected",
            reason=gate["reason"],
            steps=gate["steps"],
        ), 403
    # ---- 4) Verify: send OTP
    try:
        print(f"Sending OTP to: {phone_number}")
        client.verify.v2.services(VERIFY_SERVICE_SID).verifications.create(
            to=phone_number, channel="sms"
        )
    except Exception as e:
        print(f"{phone_number} {first_name} {last_name}")
        print(f"Error sending OTP: {e}")
        return render_template("index.html", page="rejected", title="Error", reason="OTP_SEND_FAILED"), 500
    return render_template(
        "index.html",
        page="verify",
        title="Verify",
        phone_number=phone_number,
        steps=gate["steps"],
    )
@app.post("/check")
def check():
    phone_number = request.form.get("phoneNumber", "").strip()
    code = request.form.get("code", "").strip()
    if not all([phone_number, code]):
        return render_template("index.html", page="rejected", title="Error", reason="MISSING_FIELDS"), 400
    try:
        result = client.verify.v2.services(VERIFY_SERVICE_SID).verification_checks.create(
            to=phone_number, code=code
        )
        if result.status == "approved":
            return render_template("index.html", page="approved", title="Approved")
        return render_template("index.html", page="rejected", title="Rejected", reason="OTP_INVALID"), 401
    except Exception as e:
        return render_template("index.html", page="rejected", title="Error", reason="OTP_CHECK_FAILED"), 500
if __name__ == "__main__":
    app.run(port=PORT, debug=False)

Then create a new file called templates/index.html where we'll render a very basic UI to collect a phone number, first name, and last name:

<!doctype html>
<html>
<head>
 <meta charset="utf-8" />
 <title>{{ title }}</title>
 {% if page == "signup" %}
 <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/intl-tel-input@25.12.4/build/css/intlTelInput.css" />
 {% endif %}
</head>
<body>

 {% if page == "signup" %}
 <h1>Signup</h1>
 <form id="signup-form" method="POST" action="/start">
  <label>Phone: <input id="phone" type="tel" /></label><br/>
  <label>First name: <input name="firstName" /></label><br/>
  <label>Last name: <input name="lastName" /></label><br/>
  <button type="submit">Submit</button>
 </form>
 <script src="https://cdn.jsdelivr.net/npm/intl-tel-input@25.12.4/build/js/intlTelInput.min.js"></script>
 <script>
   const input = document.querySelector("#phone");
   const form = document.querySelector("#signup-form");
   const iti = intlTelInput(input, {
     initialCountry: "us",
     hiddenInput: () => ({ phone: "phoneNumber" }),
     loadUtils: () => import("https://cdn.jsdelivr.net/npm/intl-tel-input@25.12.4/build/js/utils.js"),
   });
   form.addEventListener("submit", (e) => {
     if (!iti.isValidNumber()) {
       e.preventDefault();
       alert("Please enter a valid phone number.");
     }
   });
 </script>

 {% elif page == "verify" %}
 <h1>Enter OTP</h1>
 <ul>
   {% for step in steps %}
   <li>{{ '✅' if step.passed else '❌' }} <strong>{{ step.label }}:</strong> <code>{{ step.detail }}</code></li>
   {% endfor %}
 </ul>
 <p>We sent a code to <code>{{ phone_number }}</code>.</p>
 <form method="POST" action="/check">
  <input type="hidden" name="phoneNumber" value="{{ phone_number }}" />
  <label>Code: <input name="code" /></label><br/>
  <button type="submit">Verify</button>
 </form>

 {% elif page == "approved" %}
 <h1>Approved</h1>
 <p><a href="/">Back</a></p>

 {% elif page == "rejected" %}
 <h1>{{ title }}</h1>
 <p>reason: <code>{{ reason }}</code></p>
 {% if steps %}
 <ul>
   {% for step in steps %}
   <li>{{ '✅' if step.passed else '❌' }} <strong>{{ step.label }}:</strong> <code>{{ step.detail }}</code></li>
   {% endfor %}
 </ul>
 {% endif %}
 <p><a href="/">Back</a></p>
 {% endif %}

</body>
</html>

Run and test the code

Save your files and run the project with:

python app.py

Open http://localhost:3000 and test it out with your personal mobile number. You should see logs like:

Server running: http://localhost:3000
✅ Line Type Intelligence: mobile
✅ Line Status: active
✅ Identity Match: first: exact_match, last: exact_match
Sending OTP to: +1**********

At this point you may get Error 60619: Lookup Request Cannot be Completed in Twilio Region. Learn more about availability by country and request access here. Alternatively, you can use our sandbox experience with test credentials and magic numbers.

You can also test with a toll free or VoIP number like +17739857836 and you'll see an error with LINE_TYPE_BLOCKED. Or use your real phone number but with a different name and see Identity Match fail. Testing all possible outcomes of line status and identity match is a little tricker, so we recommend using test credentials and magic numbers.

Pricing considerations and next steps

One of the reasons this is 4 different API calls is that we want to be considerate of price. Line Type Intelligence and Line Status are the cheapest Lookup packages, while Identity Match and Verify are more expensive. Learn more about Lookup pricing (varies by country) and rearrange the steps to fit your use case.

Bundling Lookup and Verify is a great way to filter out unwanted bots, fake accounts, and reduce sign up fraud. It also allows you to validate real users seamlessly. For more information, check out:

I can't wait to see what you build and secure.