Technical

Running a large list through a lookup safely

A list of ten thousand phone numbers is easy to start and easy to get wrong: a stray quote breaks the file, a retry storm trips a rate limit, and a crashed script re-runs everything. This post covers the two ways to run a list, a bulk job in the dashboard and your own loop over the REST API, and the three properties that keep both safe: strict input, success billing and a per-account cache.

By LookValid engineering. Published 2026-10-19.

Two ways to run a list

A bulk job is the simplest route. You upload a CSV in the dashboard, pick a live product and the column that holds the value, and the platform runs one lookup per row in the background through the same engine as a single API call. The bulk jobs guide walks through the screens. If you would rather keep control of ordering and storage, loop over POST /v1/<product> from your own code, which is shown below.

The limits for a bulk job are read from the code that enforces them:

Bulk job limits
LimitValue
Rows in one file10,000
File size5 MB
Jobs queued or running at once3
Jobs started per day30
Rows running at once inside a job4
Attempts per row before it is marked failed3
Result file kept after a finished job30 days

Bulk job limits

A larger list is split into files of at most 10,000 rows, one job per file.

Get the file right first

CSV looks trivial, but the format has rules. RFC 4180 says each line should hold the same number of fields, that a field containing commas, double quotes or line breaks should be enclosed in double quotes, and that a quote inside a quoted field is written twice 1.

Our reader follows that and is forgiving where spreadsheets are sloppy: it drops a leading byte order mark, accepts LF, CRLF or CR line ends, skips blank lines and pads a short row with empty cells. It is strict where a wrong guess would change your data. A quote that never closes, a row with more cells than the header, an empty or repeated column name, and control characters other than tab, carriage return and line feed are all refused with a message that names the line.

The result file is written defensively too. A cell that starts with =, +, - or @ gets a leading single quote, so opening the file in a spreadsheet never runs an input as a formula.

Control the cost

Cost control comes from three rules that apply to every lookup. First, success billing on most products: a row that finds nothing, fails or is refused costs nothing, while a per-call product (Link Safety Check) charges every completed row. A phone lookup is 1 credit per valid number, so the invalid rows in a messy list are free. Second, a bulk job holds its worst case on your balance before it starts, refuses the upload if you cannot cover it, and releases whatever is not spent. Third, results are cached per account for 7 days.

The cache is what makes re-runs safe. If a job crashes or a row is claimed twice, the second attempt finds the first attempt's cache entry and answers free, so a row is never charged twice inside the window. Your own script gets the same protection: a repeated input returns cache_hit: true and credits_charged: 0. Inputs are normalized before caching, so (415) 555-2671 and +14155552671 are one lookup.

  • Do not retry a found: false answer. It repeats for free until the cache expires.
  • Check credits_remaining on each response and stop the loop before the balance runs out.
  • Save every answer as you go, so a restart skips the rows you already have.

A REST loop with retries

A request that exceeds the key's limit gets HTTP 429, which RFC 6585 defines as the user having sent too many requests in a given amount of time, and the response may carry a Retry-After header 2. Our rate limits page lists the numbers. The loop below honors that header, writes one JSON line per row as it goes and skips rows it already holds.

curl -X POST https://YOUR_API_HOST/v1/phone \
  -H "Authorization: Bearer $LOOKVALID_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"phone":"+14155552671"}'

One lookup, the request the loop sends

import csv, json, os, time
import requests
​
URL = "https://YOUR_API_HOST/v1/phone"
HEADERS = {"Authorization": "Bearer " + os.environ["LOOKVALID_API_KEY"]}
​
done = set()
if os.path.exists("results.jsonl"):
    with open("results.jsonl") as f:
        done = {json.loads(line)["input"] for line in f}
​
with open("numbers.csv", newline="") as src, open("results.jsonl", "a") as out:
    for row in csv.DictReader(src):
        number = row["phone"]
        if number in done:
            continue
        for attempt in range(5):
            r = requests.post(URL, headers=HEADERS, json={"phone": number}, timeout=30)
            if r.status_code != 429:
                break
            time.sleep(float(r.headers.get("Retry-After", 1)))
        body = r.json()
        out.write(json.dumps({"input": number, "status": r.status_code, "body": body}) + "\n")
        out.flush()
        if r.status_code == 402:
            break

Python loop over a CSV with a phone column

A 402 means the balance is too low, so the loop stops instead of burning requests. Anything else, such as a 400 for a malformed number, is recorded and the loop moves on.

Start small

Run the first twenty rows before the full file, read the found and cache_hit fields, and check the credits spent against your expectation. Then upload the whole list. The Phone Number Validation product page lists what each answer contains, and the quickstart gets you a key in a few minutes.

Sources

  1. RFC 4180: Common Format and MIME Type for CSV Files, read 2026-10-19
  2. RFC 6585: Additional HTTP Status Codes (429 Too Many Requests), read 2026-10-19