MyLeadFox

MyLeadFox API

Pull shop lists, look up domains and find contacts from your own tools.

Base URLhttps://go.myleadfox.com/api/v1

The API gives your own tools the same data, filters and credits as the web app. Every call is a POST with a JSON body to https://go.myleadfox.com/api/v1 followed by the endpoint, authenticated with your API key, and every reply is JSON. Four endpoints cover it: count the shops that match a filter set, page through their rows, fetch shops by domain, and find contacts for a list of domains.

New here? Get a key, read the rules, then make your first call: each step is below, in order. Every request field, every response field and every refusal is listed under each endpoint.

Before you start: get a key

  1. Log in to the app at https://go.myleadfox.com. The account needs a plan or a pass running (see the table below).
  2. Open API Keys from the app’s menu, or go straight to https://go.myleadfox.com/api-keys. Only the account owner can create, rotate or revoke keys. Team members see the list (name, last 6 characters, scopes, created, last used) read-only, and can use a key the owner gives them.
  3. Give the key a name that says where it will be used, such as “Zapier” (up to 60 characters).
  4. Tick the scopes (permissions) it needs, under Scopes: search, download and enrich. All three are ticked by default; untick what this key will not use. A key needs at least one. What each allows is in the table below.
  5. Optional: fill in IP allowlist with the addresses that may use the key, as IPs or ranges separated by commas (for example 203.0.113.0/24, 198.51.100.7). Leave it empty and the key works from any address — that is the default.
  6. Click Create key and copy the key straight away. It starts with mlf_live_ and is shown once: “Copy this key now. It will never be shown again.” Afterwards only its last 6 characters are shown, to everyone, including the owner. If you lose it, rotate the key to get a new one.

The three scopes (permissions)

ScopeAllowsSpends
searchPOST /search — count a filter set and get a search_id.search credits
downloadPOST /rows and POST /lookup — receive shop rows.download credits (leads)
enrichPOST /enrich — find contacts for domains.enrichment credits

A call that needs a scope the key does not carry is refused with 403 insufficient_scope (the body names it in required_scope). A key that only counts searches should not carry the scopes that spend download or enrichment credits.

Rotating and revoking

Rotating a key issues a new secret and stops the old one at once; the name, scopes and allowlist stay the same. Revoking stops the key for good. The account owner is emailed whenever a key is created, rotated or revoked (the email never contains the key). Keys belong to the account, not to a person. The full guide: Create and rotate API keys.

Who gets the API

Access is checked on every call, against the account that pays (the team owner’s, for a team member). A key keeps working only while the plan does: access is re-checked on every call, so a key made on a plan that later lapses is refused with 402 no_active_plan on its very next call.

AccountAPI accessKeys at a time
Starter (monthly or yearly)YesUp to 3
Pro (monthly or yearly)YesUp to 3
24-hour or 72-hour passYes, while the pass runs. Calls spend the pass allowance.Up to 3
Lifetime dealYes1
Older plans no longer soldYesUp to 3
Trial package from before paid sign-upNo — 402 no_active_plan (or 403 free_trial_no_api)—
No plan or pass runningNo — 402 no_active_plan—
Subscription payment failedNot until the invoice is settled — 402 account_frozen—

The rules

  1. One address. Every call goes to https://go.myleadfox.com/api/v1 followed by the endpoint: /search, /rows, /lookup or /enrich. myleadfox.com and www.myleadfox.com are the website and do not serve the API.
  2. Every call is POST. There are no GET endpoints.
  3. Parameters go in a JSON body, never in the URL. The URL is only the address of the endpoint.
  4. Two request headers: Authorization: Bearer mlf_live_YOUR_KEY (or X-API-Key: mlf_live_YOUR_KEY) and Content-Type: application/json.
  5. Responses are JSON only, refusals included. There is no CSV output and no format parameter.
  6. Unknown fields are silently ignored. A misspelled field is not an error: the call runs with that field’s default. Check names against the tables under Endpoints.
  7. Errors are never charged. A refused call (4xx), a failure on our side (500) and a rate-limit refusal (429) cost nothing.
  8. Checked in this order on every call: the body’s shape (a 422 comes first, before the key), then the key, the IP allowlist, the account, the plan, the key’s scope, the rate limit, and only then the call itself — which is where credits are charged (each endpoint below says exactly when).
  9. There is no idempotency key. A retried /rows or /lookup call is charged again; after a timeout on your side, check Download History in the app before you retry. A resent /enrich batch never charges twice for one domain. See Retries and timeouts.
  10. One response header to handle: Retry-After, sent only with a 429.

Common mistakes, and what you will see

MistakeWhat you getFix
Calling the website (myleadfox.com or www.myleadfox.com) instead of go.myleadfox.comYou get a web page back, not JSON: the website's 404 page, or a Cloudflare challenge page.Use https://go.myleadfox.com/api/v1/… exactly.
Using GET405 with the body {"detail":"Method Not Allowed"} and the header Allow: POST.Use POST.
Putting parameters in the URL (…/rows?page=2)They are not read. With no JSON body: 422, {"detail":[{"type":"missing","loc":["body"],…}]}. With a body: the URL parameters are silently ignored and the defaults apply.Put every parameter in the JSON body.
No Content-Type: application/json (form data, or text/plain)422 with "Input should be a valid dictionary or object to extract fields from".Send the header and a raw JSON body.
Sending page or limit to /searchIgnored. /search never returns rows; you get a count.Page rows with POST /rows.
A misspelled field ("pgae": 3) or a field that does not exist ("format": "csv")Silently ignored: the call succeeds with the default (page 1; JSON).Check field names against the tables under Endpoints.
Authorization: Basic … or a key without "Bearer "401 missing_api_key.Authorization: Bearer mlf_live_… or X-API-Key: mlf_live_….

Your first call

Count the shops in the United States. It costs 1 search credit.

From a terminal (curl), Python or JavaScript

The same call in three languages: pick one in the bar above the code. The Python uses the requests package; the JavaScript uses fetch and is meant for a server (Node.js 18 or later), never a web page, because the key must stay secret.

Request
curl -X POST https://go.myleadfox.com/api/v1/search \
  -H 'Authorization: Bearer mlf_live_YOUR_KEY' \
  -H 'Content-Type: application/json' \
  -d '{
  "filters": [
    { "kind": "dropdown", "col": "country", "values": ["US"] }
  ]
}'

From Zapier, Make, n8n or any HTTP tool

Use the tool’s generic HTTP request module (not a form or webhook trigger) with these settings:

SettingValue
MethodPOST
URLhttps://go.myleadfox.com/api/v1/search
Header 1Authorization: Bearer mlf_live_YOUR_KEY
Header 2Content-Type: application/json
Body typeRaw / JSON (not form fields)
Body{"filters": [{"kind": "dropdown", "col": "country", "values": ["US"]}]}
Parse the response asJSON

The response

A real reply. total is how many shops match, search_id names this search, and credits shows what the call spent and what you have left.

Response — HTTP 200
{
  "search_id": "de11cac33c3da5df",
  "search_url": "/grid/de11cac33c3da5df",
  "total": 1281396,
  "pages": 12814,
  "page_size": 100,
  "filters": [
    {
      "kind": "dropdown",
      "col": "country",
      "values": [
        "US"
      ]
    }
  ],
  "search": null,
  "sort": [],
  "global_search": false,
  "credits": {
    "search_remaining": 4999,
    "download_remaining": 200000,
    "enrich_remaining": 500,
    "charged": 1,
    "charged_unit": "searches"
  }
}

Next: get the rows

Send the search_id to https://go.myleadfox.com/api/v1/rows and ask for page 1. Each row delivered costs 1 download credit.

Request
curl -X POST https://go.myleadfox.com/api/v1/rows \
  -H 'Authorization: Bearer mlf_live_YOUR_KEY' \
  -H 'Content-Type: application/json' \
  -d '{"search_id": "de11cac33c3da5df", "page": 1, "columns": ["domain", "name", "country"]}'

What each call uses

Three balances, the same three as in the app: search credits, download credits (leads) and enrichment credits. They are separate: spending one never touches the others.

CallKey scopeBalanceCostFree
POST /searchsearchsearch credits1 per call, also when nothing matchesA count that fails on our side (500) is refunded.
POST /rowsdownloaddownload credits (leads)1 per row deliveredPage 1 of an empty result (0 rows, 0 charged). A page past the end (404).
POST /lookupdownloaddownload credits (leads)1 per domain found and deliveredDomains in missing and in withheld_for_credits.
POST /enrichenrichenrichment credits1 per domain with outcome enrichedThe other six outcomes: already, not_in_grid, no_contact_found, error, not_attempted, skipped_no_credit.

When a balance is smaller than the call

CallBalance at 0Balance smaller than the call
/search402 insufficient_credits (needed 1).—
/rows402 insufficient_credits (needed = limit). Nothing delivered.You get as many rows as the balance covers, truncated_by_credits is true, and you pay only for those.
/lookup402 insufficient_credits (needed = number of domains sent). Nothing delivered.The first domains found, in your order, are delivered. The rest are listed in withheld_for_credits, free.
/enrich402 insufficient_credits, but only if at least one domain needs a fresh check. A batch that is all already / not_in_grid still succeeds.The call succeeds. Domains the balance did not cover come back skipped_no_credit, free. They are not necessarily the last ones in your list.

The credits block

Balances live in the JSON body, in a credits object. The balances are read after the charge, so they already include this call.

From a successful /rows call
"credits": {
  "search_remaining": 4993,
  "download_remaining": 199900,
  "enrich_remaining": 500,
  "charged": 100,
  "charged_unit": "leads"
}
FieldMeaning
search_remainingSearch credits left after this call.
download_remainingDownload credits (leads) left after this call.
enrich_remainingEnrichment credits left after this call.
chargedHow much this call spent, as a whole number (0 on a refusal).
charged_unit"searches", "leads" or "enrichments": the balance this call draws on, named even when charged is 0 (a successful /enrich that charged nothing still says "enrichments"). "none" only on a refusal.
  • null is not zero. A null balance means the account has no defined allowance of that kind, or the balances could not be read for a moment after the call (charged and charged_unit are still filled in then). It never means 0.
  • Present on every successful call, and on every refusal made after your key and account were accepted: 429 rate_limited, 402 insufficient_credits, the 400s, the 404s search_not_found and page_out_of_range, 403 feature_disabled, and 500.
  • Absent on the key and account refusals (401 missing_api_key and invalid_api_key; 403 ip_not_allowed, account_inactive, free_trial_no_api and insufficient_scope; 402 no_active_plan and account_frozen), and on 405, 404 Not Found and 422 schema errors.

Recipes

Export a grid search into a spreadsheet

  1. Build the search in the app’s grid. The address in your browser becomes https://go.myleadfox.com/grid/<id>. Copy the id: the 16 characters after /grid/. Any grid link works, including one a teammate shared.
  2. Ask for page 1: POST https://go.myleadfox.com/api/v1/rows with {"search_id": "<id>", "page": 1, "columns": [...]}. Add "include_total": true on page 1 if you want to know how many pages there are. If the order matters, send the same sort on every page: sort is not stored in the id.
  3. Turn each object in rows into a spreadsheet row. Use the columns array as your header row and read each value by its name.
  4. Ask for page 2, 3 and so on until you reach the last page. The page after the last one answers 404 page_out_of_range, free, with the real page count: that is your signal to stop.
  5. If truncated_by_credits is true:
    • truncated_by_credits is decided BEFORE the page is read: it is true whenever your balance was smaller than limit. That includes a short LAST page that came back complete.
    • So read credits.download_remaining on the same response. If it is 0 (or null), the balance ran out and this page may be cut: top up and ask for the SAME page again — that call delivers and charges the whole page again, including the rows you already received (limit only caps from the start of a page; it cannot skip rows). If you sent include_total, you can confirm first: the page can hold min(limit, total − (page − 1) × 100) rows; if returned equals that, nothing is missing.
    • If download_remaining is above 0, this page almost certainly had fewer rows than limit because it was the last page. Do NOT ask for it again (that would charge it twice); carry on — the next page answering 404 page_out_of_range confirms it. If the next page returns rows instead, your balance was topped up or reset during that call and the earlier page really was cut: ask for that earlier page again.
  6. If a page answers 402 insufficient_credits, your download balance is 0 and nothing was delivered or charged. Top up, then carry on from that same page.

Each row costs 1 download credit. The id stores the search, not a copy of the rows, so every page is read live: if the data changes while you page, the result can change with it.

Kept in Download History.

  • Every successful /rows and /lookup call writes one entry to Download History in the app — one entry per call, so 15 pages make 15 entries, grouped by their search_id. Each entry shows the time, the leads charged and the last 6 characters of the key that made the call.
  • The response body is kept for 100 days and can be downloaded again there, as JSON, at no charge and as often as you like. The saved copy is the response as it was delivered, without its credits block — not a re-run of the query.
  • API entries cannot be sent to Google Sheets (that export is for CSV downloads made in the app).
  • The response does not carry a history id: find the entry by its time and lead count. There is no API endpoint for listing or fetching history; it is in the app.
  • /enrich calls are not saved in Download History, so store their results on your side.
Python — the same loop, writing shops.csv page by page
import csv, time, requests

URL = "https://go.myleadfox.com/api/v1/rows"
HEADERS = {"Authorization": "Bearer mlf_live_YOUR_KEY", "Content-Type": "application/json"}
SEARCH_ID = "de11cac33c3da5df"            # the id from https://go.myleadfox.com/grid/<id>
COLUMNS = ["domain", "name", "country", "products"]
START_PAGE = 1                            # to resume, set this to the page the script printed
TRIES = 3                                 # attempts per page on a 500 from the API (refunded, safe)
WAITS = 10                                # most 429 waits per page before giving up


def fetch(page):
    """Ask for one page. Returns (HTTP status, JSON body or None)."""
    failures = waits = 0
    while True:
        r = requests.post(URL, headers=HEADERS, timeout=60,
                          json={"search_id": SEARCH_ID, "page": page, "columns": COLUMNS})
        is_json = r.headers.get("Content-Type", "").startswith("application/json")
        if not is_json:                           # not from the API (e.g. a gateway page), any status:
            return r.status_code, None            # the page MAY already be delivered and charged
        if r.status_code == 429 and waits < WAITS:  # the API's own 429: never charged, wait as told
            waits += 1
            try:
                wait = int(r.headers.get("Retry-After", "5"))
            except ValueError:
                wait = 5
            time.sleep(max(1, wait))
            continue
        if r.status_code != 500:
            return r.status_code, r.json()
        failures += 1                             # a 500 from the API is refunded: safe to retry
        if failures >= TRIES:
            return r.status_code, r.json()
        time.sleep(5 * failures)                  # a short wait, then the SAME page again


page = START_PAGE
maybe_last = None                                 # a short page that looked complete (see below)
with open("shops.csv", "w" if START_PAGE == 1 else "a", newline="") as f:
    w = csv.DictWriter(f, fieldnames=COLUMNS)     # read values by NAME, not by position
    if START_PAGE == 1:
        w.writeheader()
    while True:
        try:
            status, body = fetch(page)
        except requests.RequestException as e:    # no answer at all, e.g. a timeout on your side
            print(f"No answer for page {page} ({e}). Before resuming from page {page}, look for it "
                  "in Download History: if it is there, it was delivered and charged.")
            break
        if body is None:                          # not an API reply: do NOT retry blindly
            print(f"Page {page}: HTTP {status} without a JSON body. Before resuming from page {page}, "
                  "look for it in Download History: if it is there, it was delivered and charged.")
            break
        if status == 404 and body.get("error") == "page_out_of_range":
            break                                 # past the last page: done
        if status == 402 and body.get("error") == "insufficient_credits":
            # download balance is 0: nothing delivered, nothing charged
            print(f"Download balance used up. Top up, then resume with START_PAGE = {page}")
            break
        if status != 200:
            print(f"Page {page}: HTTP {status} {body.get('error')}: {body.get('message')}")
            break
        if maybe_last is not None:
            # The short page before this one was NOT the last page after all: your balance was
            # topped up (or reset) during that call, so it really was cut. Get it again first.
            print(f"Page {maybe_last} was cut short after all. Resume with START_PAGE = {maybe_last} "
                  "(that page is delivered and charged again in full).")
            break
        w.writerows(body["rows"])                 # saved the moment it arrives
        f.flush()
        left = (body.get("credits") or {}).get("download_remaining")
        if body["truncated_by_credits"] and left in (0, None):
            # The balance ran out inside this page. Asking for the SAME page again delivers and
            # charges the WHOLE page again, including the rows just saved: remove those duplicates.
            print(f"Balance ran out at page {page} ({len(body['rows'])} rows saved). If fewer than 100 "
                  "rows came back this may simply have been the LAST page, complete: check the shop "
                  "total before resuming. Otherwise top up, then resume with START_PAGE = "
                  f"{page} (that page is delivered and charged again in full).")
            break
        # truncated_by_credits with a balance still left = almost certainly a short LAST page,
        # complete: carry on. The next page answering 404 confirms it; if it returns rows instead,
        # the check above stops and names the cut page. Never re-ask blindly: it charges twice.
        if body["truncated_by_credits"]:
            maybe_last = page
        page += 1

Each page is written to the file the moment it arrives, so nothing you have paid for is lost when the loop stops early. It stops cleanly on a 402 insufficient_credits and when a page is cut short by your balance (truncated_by_credits with download_remaining at 0), and prints the page to resume from; any other refusal stops with its error code and message. It waits and retries the API’s own JSON 429 as its Retry-After says (a limited number of times), and retries a JSON 500 from the API (never charged) a few times with a short wait. If a short page looked complete but the next page still returns rows, it stops and names the page to fetch again (your balance was topped up during that call). It does NOT retry a reply that is not JSON (a gateway error or challenge page), or a call that got no answer at all: either may already have been delivered and charged. It stops and tells you to check Download History first — see Retries and timeouts below.

Retries and timeouts

  • There is no idempotency key. A retry that reaches the server is a new call: it does the work again and charges again.
  • A retried /rows or /lookup call is charged again, even if the first one was delivered and you never saw its answer.
  • After a client-side timeout on /rows or /lookup, look in Download History in the app before you retry: if the call is there (match it by time and leads charged), it was delivered and charged, and you can download that response again for free instead of paying for it twice.
  • A JSON 429, a JSON 500 and any other JSON refusal from the API cost nothing, so retrying those never double-charges.
  • A reply that is NOT JSON (a gateway error, a timeout page or a challenge page, whatever its status) did not come from the API, and it can arrive after the call was already delivered and charged. Never retry it blindly: treat it like a timeout and check Download History first.
  • /enrich: Resending a batch never charges twice for one domain: every domain the first call finished as enriched or no_contact_found comes back as already, free — even if you never saw the first response. Domains that came back error, or that were cut off when the call died, were refunded; on a resend they are checked again and charged only if contacts are found.

Enrich a list of domains

  1. Split your domains into batches of at most 100. In an automation tool with a short time limit per step, send smaller batches.
  2. POST https://go.myleadfox.com/api/v1/enrich with {"domains": ["a.com", "b.com"]}. Raise your tool's timeout to its maximum: each domain is checked live, and a call with 100 domains can take over a minute.
  3. For each entry in results, read outcome first. enriched and already carry the top 5 contacts per domain (best first) in people; write one spreadsheet row per listed person. people_count is how many were found in total and can be larger than the list. not_in_grid and no_contact_found are final — do not resend them.
  4. Collect the domains that came back not_attempted (the call ran out of time) and send them again. Send error domains again later. Send skipped_no_credit domains again after topping up your enrichment credits.
  5. Resending a batch never charges twice for one domain: every domain the first call finished as enriched or no_contact_found comes back as already, free — even if you never saw the first response. Domains that came back error, or that were cut off when the call died, were refunded; on a resend they are checked again and charged only if contacts are found.
  6. Only enriched costs an enrichment credit. /enrich calls are not saved in Download History, so store the results on your side.

All seven outcomes are explained under Enrich.

Endpoints

Four endpoints, all POST, all under https://go.myleadfox.com/api/v1. Each one below lists every request field, a real response, every response field and what it refuses.

Rows: one page of shops

POSThttps://go.myleadfox.com/api/v1/rows

Returns one numbered page of shops for a search: by search_id, or by sending the filters again. Pages are always 100 rows and any page can be asked for directly — page 500 needs no walk through 1 to 499. This is the only endpoint that returns rows for a whole search.

Key scope
download
Spends
download credits (leads)
Cost
1 download credit (lead) per row delivered

Request body fields

FieldTypeRequired / defaultLimitWhat it does
search_idstring or nulloptional — defaults to nullwins over filters and search when both are sentA saved search: a 16-character id. It is the id in a grid URL — https://go.myleadfox.com/grid/<search_id> — so you can copy it from any grid link (your own, a teammate's, a shared one), or take it from a /search response. It never expires. It stores the filters and the search text, not a snapshot of rows, so every call runs live against today's data. If you send search_id AND filters or search, the search_id is used and the others are ignored. An id that does not resolve is a 404 search_not_found.
filtersarray of filter objectsoptional — defaults to [] (no filter)at most 64 filters; a regex filter at most 25 parts of at most 200 characters each (past either: 422)The filter expression. Each entry names a kind, a column (col) and its operator; entries join with conn (and / or) and any one can be negated with not: true. An empty list matches every shop. Every kind and operator is in the Filters section, with a runnable example each. A filter whose CONTENT is wrong (no col, an unknown or hidden column, an unknown conn, a bad value) is a 400 invalid_request; an entry that is not a JSON object at all is a 422.
searchstring or nulloptional — defaults to nullno length boundGlobal text search: a case-insensitive substring match across six columns (domain, name, tagline, description_preview, emails, categories). It ANDs onto the whole filter expression. A call with non-empty search text is a "global text search", which uses more rate-limit units (see Limits).
sortarray of {col, dir} objectsoptional — defaults to [] (unsorted)at most 8 keys (more: 422)Ordering, most significant key first: [{"col": "products", "dir": "desc"}]. dir is asc or desc (any case) and defaults to asc. An unknown or hidden column is a 400 unknown_column; an entry with no col, or a dir that is not asc/desc, is a 400 invalid_request; an entry that is not a JSON object is a 422. Sort is NOT stored in a search_id — send it again on every call that needs it.
pageintegeroptional — defaults to 11 to 200,000 (outside: 422)Which 100-row page. Page 1 is rows 1–100, page 2 is rows 101–200, and so on. A page past the last one is a 404 page_out_of_range, free, carrying the real page count.
limitintegeroptional — defaults to 1001 to 100 (outside: 422)The most rows to return from that page. It does not make pages smaller — page 2 is still rows 101–200 — it only caps how many of them come back. You are charged for rows delivered, so a smaller limit costs less. Page 2 with limit 3 returns rows 101–103. To walk a whole result set, leave limit at 100.
columnsarray of strings, or nulloptional — defaults to null (every column)at most 200 names (more: 422)Which columns each row carries. null, [] or ["all"] means all 100 of them (the full list is in the Columns section). Any unknown or hidden name is a 400 unknown_column naming it. Duplicates are collapsed and your order is kept. It changes the payload, not the price.
include_totalbooleanoptional — defaults to false—Send true or false. true adds total and pages to the response (a live count, run at the moment of the call). With a global text search it raises the call's rate-limit units from 8 to 15 (see Limits); without global text search it uses 1 unit either way. It never changes the credits charged.

Ignored here: format is not a field: the response is always JSON, whatever you send. Any other unknown field is ignored too.

Example

Request
curl -X POST https://go.myleadfox.com/api/v1/rows \
  -H 'Authorization: Bearer mlf_live_YOUR_KEY' \
  -H 'Content-Type: application/json' \
  -d '{
  "filters": [
    { "kind": "dropdown", "col": "country", "values": ["US"] }
  ],
  "limit": 3,
  "columns": ["domain", "name", "country", "products"]
}'
Response — HTTP 200
{
  "search_id": "de11cac33c3da5df",
  "page": 1,
  "page_size": 100,
  "limit": 3,
  "returned": 3,
  "columns": [
    "domain",
    "name",
    "country",
    "products"
  ],
  "rows": [
    {
      "domain": "a-1batteries.com",
      "name": "A-1 Accredited Batteries",
      "products": 65,
      "country": "US"
    },
    "... 2 more, trimmed for the documentation"
  ],
  "truncated_by_credits": false,
  "credits": {
    "search_remaining": 4997,
    "download_remaining": 199997,
    "enrich_remaining": 500,
    "charged": 3,
    "charged_unit": "leads"
  }
}

Every response field

FieldTypeMeaning
search_idstringThe id of this filter set. If you sent filters by hand, the id is computed but not saved, so it opens in the app only if the same search was saved before (by /search or the grid). To be sure a search_id works later, get it from /search.
pageintegerThe page you asked for.
page_sizeintegerAlways 100.
limitintegerThe limit you sent, or 100.
returnedintegerHow many rows are in this response — and how many leads were charged.
columnsarray of stringsThe columns each row carries, in your order. Use this list for the order of your spreadsheet columns.
rowsarray of objectsOne object per shop, keyed by column name, with raw values (numbers as numbers, text as text). The order of keys inside a row is not guaranteed to match columns (see the sample) — read values by name. A value that was never measured is an empty string "", for every column type — never null, never 0 (see "A missing value is not a zero" under Columns).
truncated_by_creditsbooleantrue when your download balance was smaller than limit. truncated_by_credits is decided BEFORE the page is read: it is true whenever your balance was smaller than limit. That includes a short LAST page that came back complete. Read it together with credits.download_remaining: a cut only if that is 0 (or null) — see Good to know below.
totalinteger — only with include_totalHow many shops match, counted live.
pagesinteger — only with include_totalHow many 100-row pages that makes.
creditsobjectThe credits block — see "What each call uses". Here charged equals returned and charged_unit is "leads".

Good to know

  • You pay for rows delivered, never rounded up to a page: a last page of 37 rows costs 37, and page 1 of an empty result costs 0.
  • If your download balance is above 0 but smaller than limit, you receive as many rows as it covers, truncated_by_credits is true, and you pay only for those. At 0 the call is a 402 and nothing is delivered.
  • truncated_by_credits is decided BEFORE the page is read: it is true whenever your balance was smaller than limit. That includes a short LAST page that came back complete.
  • So read credits.download_remaining on the same response. If it is 0 (or null), the balance ran out and this page may be cut: top up and ask for the SAME page again — that call delivers and charges the whole page again, including the rows you already received (limit only caps from the start of a page; it cannot skip rows). If you sent include_total, you can confirm first: the page can hold min(limit, total − (page − 1) × 100) rows; if returned equals that, nothing is missing.
  • If download_remaining is above 0, this page almost certainly had fewer rows than limit because it was the last page. Do NOT ask for it again (that would charge it twice); carry on — the next page answering 404 page_out_of_range confirms it. If the next page returns rows instead, your balance was topped up or reset during that call and the earlier page really was cut: ask for that earlier page again.
  • Every successful /rows and /lookup call writes one entry to Download History in the app — one entry per call, so 15 pages make 15 entries, grouped by their search_id. Each entry shows the time, the leads charged and the last 6 characters of the key that made the call.
  • The response body is kept for 100 days and can be downloaded again there, as JSON, at no charge and as often as you like. The saved copy is the response as it was delivered, without its credits block — not a re-run of the query.
  • The examples below were generated in order on one test account, but its download balance was SET before three of them (to 40 before "Balance smaller than the page" and "Past the last page", to 0 before "Download balance at 0"). So their balances do not follow on from one another — a 404 never adds credits.

More real responses

With include_total

The same search, one column, limit 1 and include_total true: total and pages come back.

Request
curl -X POST https://go.myleadfox.com/api/v1/rows \
  -H 'Authorization: Bearer mlf_live_YOUR_KEY' \
  -H 'Content-Type: application/json' \
  -d '{
  "filters": [
    { "kind": "dropdown", "col": "country", "values": ["US"] }
  ],
  "limit": 1,
  "include_total": true,
  "columns": ["domain"]
}'
Response — HTTP 200
{
  "search_id": "de11cac33c3da5df",
  "page": 1,
  "page_size": 100,
  "limit": 1,
  "returned": 1,
  "columns": [
    "domain"
  ],
  "rows": [
    {
      "domain": "a-1batteries.com"
    }
  ],
  "truncated_by_credits": false,
  "total": 1281396,
  "pages": 12814,
  "credits": {
    "search_remaining": 4997,
    "download_remaining": 199996,
    "enrich_remaining": 500,
    "charged": 1,
    "charged_unit": "leads"
  }
}

Jump straight to any page

Page 500, limit 2: no walk through pages 1 to 499. city is "" here: there is no city value for this shop, and a missing value comes back as an empty string, never null.

Request
curl -X POST https://go.myleadfox.com/api/v1/rows \
  -H 'Authorization: Bearer mlf_live_YOUR_KEY' \
  -H 'Content-Type: application/json' \
  -d '{
  "filters": [
    { "kind": "dropdown", "col": "country", "values": ["US"] }
  ],
  "page": 500,
  "limit": 2,
  "columns": ["domain", "city"]
}'
Response — HTTP 200
{
  "search_id": "de11cac33c3da5df",
  "page": 500,
  "page_size": 100,
  "limit": 2,
  "returned": 2,
  "columns": [
    "domain",
    "city"
  ],
  "rows": [
    {
      "domain": "apushreview.com",
      "city": ""
    },
    "... 1 more, trimmed for the documentation"
  ],
  "truncated_by_credits": false,
  "credits": {
    "search_remaining": 4997,
    "download_remaining": 199994,
    "enrich_remaining": 500,
    "charged": 2,
    "charged_unit": "leads"
  }
}

Balance smaller than the page

Run with the test account's download balance set to 40. limit is left at 100: 40 rows are delivered and charged, and truncated_by_credits is true.

Request
curl -X POST https://go.myleadfox.com/api/v1/rows \
  -H 'Authorization: Bearer mlf_live_YOUR_KEY' \
  -H 'Content-Type: application/json' \
  -d '{
  "filters": [
    { "kind": "dropdown", "col": "country", "values": ["US"] }
  ],
  "columns": ["domain"]
}'
Response — HTTP 200
{
  "search_id": "de11cac33c3da5df",
  "page": 1,
  "page_size": 100,
  "limit": 100,
  "returned": 40,
  "columns": [
    "domain"
  ],
  "rows": [
    {
      "domain": "a-1batteries.com"
    },
    "... 39 more, trimmed for the documentation"
  ],
  "truncated_by_credits": true,
  "credits": {
    "search_remaining": 4997,
    "download_remaining": 0,
    "enrich_remaining": 500,
    "charged": 40,
    "charged_unit": "leads"
  }
}

Past the last page

Run separately, with the test account's download balance set to 40 again (a 404 never adds credits). Page 9 of a 1-page result: free. Stop your paging loop here.

Request
curl -X POST https://go.myleadfox.com/api/v1/rows \
  -H 'Authorization: Bearer mlf_live_YOUR_KEY' \
  -H 'Content-Type: application/json' \
  -d '{
  "filters": [
    { "kind": "text", "col": "domain", "mode": "exact", "value": "gymshark.com" }
  ],
  "page": 9
}'
Response — HTTP 404
{
  "error": "page_out_of_range",
  "message": "That page does not exist. This result set has 1 page(s).",
  "pages": 1,
  "requested": 9,
  "credits": {
    "search_remaining": 4997,
    "download_remaining": 40,
    "enrich_remaining": 500,
    "charged": 0,
    "charged_unit": "none"
  }
}

Download balance at 0

Run with the download balance set to 0. Sent with limit 1, so needed is 1 (needed = limit). Nothing is delivered.

Request
curl -X POST https://go.myleadfox.com/api/v1/rows \
  -H 'Authorization: Bearer mlf_live_YOUR_KEY' \
  -H 'Content-Type: application/json' \
  -d '{
  "filters": [
    { "kind": "dropdown", "col": "country", "values": ["US"] }
  ],
  "limit": 1
}'
Response — HTTP 402
{
  "error": "insufficient_credits",
  "message": "Not enough download credits: this call needs 1 and 0 remain.",
  "pool": "download",
  "needed": 1,
  "remaining": 0,
  "credits": {
    "search_remaining": 4997,
    "download_remaining": 0,
    "enrich_remaining": 500,
    "charged": 0,
    "charged_unit": "none"
  }
}

What it refuses

ResponseWhen
insufficient_credits · 402Download balance is 0 (pool "download", needed = limit). Nothing is delivered. Also returned, with nothing delivered, if another call spends the balance at the same moment. A balance that is merely smaller than limit is NOT refused: you get what it covers.
page_out_of_range · 404The page is past the last one (page 2 or later with no rows). Carries pages (the real count) and requested. Page 1 of an empty result is not an error: it is a 200 with rows [] and nothing charged.
search_not_found · 404The search_id does not resolve.
unknown_column · 400A requested column or sort key is unknown or hidden. Named in a column field.
invalid_request · 400A filter whose content is wrong (no col, an unknown or hidden column, an unknown conn, a bad value), a sort entry with no col, or a dir that is not asc/desc.
feature_disabled · 403The key's account is a team member whose owner has switched downloading off (feature: "downloading").
server_error · 500Reading the page failed on our side. Nothing is delivered or charged.
422page outside 1–200,000, limit outside 1–100, include_total that is not a boolean (send true or false), a filter or sort entry that is not a JSON object, columns that is not a list of strings, more than 200 columns, 64 filters or 8 sort keys.

Plus the key and account refusals every call can get — see Errors. A 422 is a schema error with a different body shape.

Lookup: fetch shops by domain

POSThttps://go.myleadfox.com/api/v1/lookup

Fetches the rows for up to 100 domains you already have. Three lists come back and they mean different things: rows were found and charged; missing are not in the MyLeadFox dataset (free, and they will still be missing tomorrow); withheld_for_credits are in the dataset but past your balance (free — top up and ask again). If you send columns, always include domain in it — without it every domain is reported as missing.

Key scope
download
Spends
download credits (leads)
Cost
1 download credit (lead) per domain found; misses are free

Request body fields

FieldTypeRequired / defaultLimitWhat it does
domainsarray of strings, OR one comma-separated stringrequired in practice — an empty list is a 400 invalid_requestat most 100 domains, counted after cleaning and de-duplication (more: 400 too_many_domains)Both shapes are accepted: ["a.com", "b.com"] or "a.com, b.com". Each entry is cleaned first: spaces trimmed, lower-cased, a leading http:// or https:// and a leading www. removed, trailing dots, slashes and spaces removed — so "https://www.Gymshark.com/" becomes "gymshark.com". Blank entries are skipped. A non-blank entry that is not a valid domain is a 400 invalid_request naming it in a domain field. Duplicates are removed, keeping your order. A value that is not a list of strings or one string (a number, an object, a list of numbers) is a 422.
columnsarray of strings, or nulloptional — defaults to null (every column)at most 200 names (more: 422)Which columns each row carries. null, [] or ["all"] means all 100 of them (the full list is in the Columns section). Any unknown or hidden name is a 400 unknown_column naming it. Duplicates are collapsed and your order is kept. It changes the payload, not the price. ⚠ Always include domain in columns. Without it every domain is reported as missing, even the ones that are in the dataset — and nothing is charged or delivered. (Omitting columns, or sending ["all"], includes domain.)

Ignored here: search, filters, search_id, page and limit are not /lookup fields and are ignored.

Example

Request
curl -X POST https://go.myleadfox.com/api/v1/lookup \
  -H 'Authorization: Bearer mlf_live_YOUR_KEY' \
  -H 'Content-Type: application/json' \
  -d '{
  "domains": "gymshark.com, coffeeinn.am, this-one-does-not-exist.invalid",
  "columns": ["domain", "name", "country", "products"]
}'
Response — HTTP 200
{
  "requested": 3,
  "found": 2,
  "missing": [
    "this-one-does-not-exist.invalid"
  ],
  "withheld_for_credits": [],
  "truncated_by_credits": false,
  "columns": [
    "domain",
    "name",
    "country",
    "products"
  ],
  "rows": [
    {
      "domain": "gymshark.com",
      "name": "Gymshark US",
      "products": 9113,
      "country": "US"
    },
    "... 1 more, trimmed for the documentation"
  ],
  "credits": {
    "search_remaining": 4997,
    "download_remaining": 38,
    "enrich_remaining": 500,
    "charged": 2,
    "charged_unit": "leads"
  }
}

Every response field

FieldTypeMeaning
requestedintegerHow many domains you sent, after cleaning and de-duplication.
foundintegerHow many rows were delivered — and how many leads were charged.
missingarray of stringsYour domains (cleaned) that are not in the dataset. Free, and asking again will not change the answer — unless you left domain out of columns: then EVERY domain lands here (Always include domain in columns. Without it every domain is reported as missing, even the ones that are in the dataset — and nothing is charged or delivered.)
withheld_for_creditsarray of stringsDomains in the dataset that your balance did not cover. Free. Top up and send them again.
truncated_by_creditsbooleantrue when withheld_for_credits is not empty.
columnsarray of stringsThe columns each row carries, in your order.
rowsarray of objectsOne object per domain delivered, in the order you sent them. Keys are column names, values are raw; key order inside a row is not guaranteed — read values by name. A value that was never measured is an empty string "", never null and never 0.
creditsobjectThe credits block — see "What each call uses". Here charged equals found and charged_unit is "leads".

Good to know

  • ⚠ Always include domain in columns. Without it every domain is reported as missing, even the ones that are in the dataset — and nothing is charged or delivered.
  • If your download balance is 0, the call is a 402 (needed = the number of domains you sent, even if none of them would have been found). If it is above 0 but smaller than the number found, the found domains are delivered in YOUR request order up to the balance; the rest are listed in withheld_for_credits (free) and truncated_by_credits is true. Top up and ask for the withheld ones again.
  • Every successful /rows and /lookup call writes one entry to Download History in the app — one entry per call, so 15 pages make 15 entries, grouped by their search_id. Each entry shows the time, the leads charged and the last 6 characters of the key that made the call.
  • The response body is kept for 100 days and can be downloaded again there, as JSON, at no charge and as often as you like. The saved copy is the response as it was delivered, without its credits block — not a re-run of the query.
  • The example below ran with the test account's download balance set back to 40, so 38 are left after charging 2.

More real responses

Too many domains

101 domains (d0.example.com to d100.example.com) in one call. Refused before any query; free.

Request
curl -X POST https://go.myleadfox.com/api/v1/lookup \
  -H 'Authorization: Bearer mlf_live_YOUR_KEY' \
  -H 'Content-Type: application/json' \
  -d '{
  "domains": ["d0.example.com","d1.example.com","d2.example.com","d3.example.com","d4.example.com","d5.example.com","d6.example.com","d7.example.com","d8.example.com","d9.example.com","d10.example.com","d11.example.com","d12.example.com","d13.example.com","d14.example.com","d15.example.com","d16.example.com","d17.example.com","d18.example.com","d19.example.com","d20.example.com","d21.example.com","d22.example.com","d23.example.com","d24.example.com","d25.example.com","d26.example.com","d27.example.com","d28.example.com","d29.example.com","d30.example.com","d31.example.com","d32.example.com","d33.example.com","d34.example.com","d35.example.com","d36.example.com","d37.example.com","d38.example.com","d39.example.com","d40.example.com","d41.example.com","d42.example.com","d43.example.com","d44.example.com","d45.example.com","d46.example.com","d47.example.com","d48.example.com","d49.example.com","d50.example.com","d51.example.com","d52.example.com","d53.example.com","d54.example.com","d55.example.com","d56.example.com","d57.example.com","d58.example.com","d59.example.com","d60.example.com","d61.example.com","d62.example.com","d63.example.com","d64.example.com","d65.example.com","d66.example.com","d67.example.com","d68.example.com","d69.example.com","d70.example.com","d71.example.com","d72.example.com","d73.example.com","d74.example.com","d75.example.com","d76.example.com","d77.example.com","d78.example.com","d79.example.com","d80.example.com","d81.example.com","d82.example.com","d83.example.com","d84.example.com","d85.example.com","d86.example.com","d87.example.com","d88.example.com","d89.example.com","d90.example.com","d91.example.com","d92.example.com","d93.example.com","d94.example.com","d95.example.com","d96.example.com","d97.example.com","d98.example.com","d99.example.com","d100.example.com"]
}'
Response — HTTP 400
{
  "error": "too_many_domains",
  "message": "At most 100 domains per call; you sent 101.",
  "limit": 100,
  "given": 101,
  "credits": {
    "search_remaining": 4997,
    "download_remaining": 38,
    "enrich_remaining": 500,
    "charged": 0,
    "charged_unit": "none"
  }
}

What it refuses

ResponseWhen
too_many_domains · 400More than 100 domains after cleaning and de-duplication. Carries limit and given.
invalid_request · 400No domains ("send at least one domain"), or an entry that is not a valid domain (named in a domain field).
unknown_column · 400A requested column is unknown or hidden.
insufficient_credits · 402Download balance is 0 (needed = the number of domains sent, even if none would have been found), or another call spent the balance at the same moment. A balance smaller than the number found is NOT refused: the first ones found, in your order, are delivered and the rest go to withheld_for_credits.
feature_disabled · 403The key's account is a team member whose owner has switched downloading off.
server_error · 500Reading the rows failed on our side. Nothing is delivered or charged.
422domains that is not a list of strings or one string, columns that is not a list of strings, or more than 200 columns.

Plus the key and account refusals every call can get — see Errors. A 422 is a schema error with a different body shape.

Enrich: find contacts

POSThttps://go.myleadfox.com/api/v1/enrich

Finds the people behind up to 100 domains that are in the MyLeadFox dataset. Each domain is checked in real time while the call runs, so the call can take a while — over a minute for 100 domains. Every domain comes back with exactly one of seven outcomes, and only enriched is charged.

Key scope
enrich
Spends
enrichment credits
Cost
1 enrichment credit per domain enriched; the other six outcomes are free

Request body fields

FieldTypeRequired / defaultLimitWhat it does
domainsarray of strings, OR one comma-separated stringrequired in practice — an empty list is a 400 invalid_requestat most 100 domains, counted after cleaning and de-duplication (more: 400 too_many_domains)Both shapes are accepted: ["a.com", "b.com"] or "a.com, b.com". Each entry is cleaned first: spaces trimmed, lower-cased, a leading http:// or https:// and a leading www. removed, trailing dots, slashes and spaces removed — so "https://www.Gymshark.com/" becomes "gymshark.com". Blank entries are skipped. A non-blank entry that is not a valid domain is a 400 invalid_request naming it in a domain field. Duplicates are removed, keeping your order. A value that is not a list of strings or one string (a number, an object, a list of numbers) is a 422.

Ignored here: domains is the only field. columns, search, filters and anything else are ignored.

Example

Request
curl -X POST https://go.myleadfox.com/api/v1/enrich \
  -H 'Authorization: Bearer mlf_live_YOUR_KEY' \
  -H 'Content-Type: application/json' \
  -d '{
  "domains": ["not-in-the-dataset.example"]
}'
Response — HTTP 200 — outcome not_in_grid
{
  "requested": 1,
  "charged": 0,
  "counts": {
    "not_in_grid": 1
  },
  "results": [
    {
      "domain": "not-in-the-dataset.example",
      "outcome": "not_in_grid",
      "charged": false,
      "people": [],
      "people_count": 0,
      "message": "This domain is not in the MyLeadFox dataset, so it cannot be enriched."
    }
  ],
  "credits": {
    "search_remaining": 4997,
    "download_remaining": 38,
    "enrich_remaining": 500,
    "charged": 0,
    "charged_unit": "enrichments"
  }
}

Every response field

FieldTypeMeaning
requestedintegerHow many domains you sent, after cleaning and de-duplication.
chargedintegerHow many domains were charged (the entries with charged true).
countsobjectHow many domains got each outcome, e.g. {"enriched": 3, "already": 2}. Only outcomes that happened are listed.
resultsarray of objectsOne entry per domain, in the order you sent them. Fields of an entry are listed below.
results[].domainstringThe domain, cleaned.
results[].outcomestringOne of the seven outcomes in the table below. Read it before the people list: it tells you whether an empty list means "nobody exists" or "not checked".
results[].chargedbooleantrue for enriched. On no_contact_found and error the credit is taken and then refunded, so charged is false — except in the rare case that the refund itself fails: then charged is true on that entry, and it counts in the top-level charged and in credits.charged. already, not_in_grid, not_attempted and skipped_no_credit are always false.
results[].peoplearray of objectsUp to the 5 best-ranked contacts found for the domain (best first), one object per person — the keys of a person are in the next table. Empty for every outcome except enriched and already (and already can be empty when the paid answer was nobody).
results[].people_countintegerHow many people were found for the domain IN TOTAL. It can be larger than the people list, which carries only the top 5: people_count 8 with 5 people listed is normal.
results[].valid_email_countintegerHow many of ALL the people found (not only the 5 listed) have a valid email. Present on enriched, already, no_contact_found and error.
results[].companystring or null — only on alreadyThe company name from the stored result. Present on already whenever a stored result exists for the domain; null when no company name was found (including when the earlier answer was nobody). Absent when no stored result exists.
results[].messagestringA plain sentence, on not_in_grid, not_attempted and error.
creditsobjectThe credits block — see "What each call uses". Here charged_unit is "enrichments", even when nothing was charged.

Each person in results[].people

Every person object carries exactly these 13 keys, for enriched and already alike. The list holds at most 5 people per domain, best first; people_count and valid_email_count count everyone found.

FieldTypeMeaning
full_namestring or nullThe person's full name, title-cased.
first_namestring or nullFirst name, title-cased.
last_namestring or nullLast name, title-cased.
titlestring or nullJob title, as found.
current_employerstring or nullThe company the person works for, as found.
emailstring or nullA work email, lower-cased — filled ONLY when it was checked and found valid. null otherwise.
email_statusstring"valid" (email is filled), "invalid_or_unknown" (an address was found but could not be confirmed valid, so email stays null), or "none" (no email was found or checked for this person).
linkedin_urlstring or nullThe person's LinkedIn profile address.
locationstring or nullWhere the person is, usually a two-letter country code.
seniority_tierinteger, 1 to 5Seniority read from the job title: 1 is the most senior. Matches seniority_label.
seniority_labelstringThe tier in words: "founder/C-suite" (1), "VP/Head/Director" (2), "Manager/Lead" (3), "Senior IC" (4), "Other/unknown" (5).
domain_rankintegerThis person's rank among everyone found for the domain: 1 is the best contact. People with a valid email rank first, then by how likely they are to be the right point of contact.
in_top5integerAlways 1 in this list (1 = one of the 5 best-ranked people). Only top-5 people are returned.

Good to know

  • Each domain is checked in real time while the call runs, so a batch of 100 can take over a minute. Raise your tool's timeout to its maximum, or send smaller batches. Domains the call had no time for come back not_attempted, free: send them again.
  • already means YOUR account enriched the domain less than 150 days ago. A domain another customer enriched is charged to you; a result older than 150 days is charged again.
  • Resending a batch never charges twice for one domain: every domain the first call finished as enriched or no_contact_found comes back as already, free — even if you never saw the first response. Domains that came back error, or that were cut off when the call died, were refunded; on a resend they are checked again and charged only if contacts are found.
  • If your enrichment balance runs out partway, the call still succeeds and the domains it could not afford come back skipped_no_credit. Domains are checked several at a time, so those are not necessarily the last ones in your list (unlike /lookup, which is strictly in request order).
  • /enrich calls are not saved in Download History. Store the results on your side.

The seven outcomes

outcomeWhat it meansCharged?What to do
enrichedChecked in real time and at least one person was found. people carries up to the 5 best-ranked contacts; people_count and valid_email_count count everyone found.Yes: 1 enrichment creditUse the contacts.
alreadyThis account enriched the domain less than 150 days ago. The stored result comes back. people can be empty if that earlier answer was nobody. A domain another customer enriched is not "already" for you.NoUse it. Resending a batch never charges twice for one domain: every domain the first call finished as enriched or no_contact_found comes back as already, free — even if you never saw the first response. Domains that came back error, or that were cut off when the call died, were refunded; on a resend they are checked again and charged only if contacts are found.
not_in_gridThe domain is not in the MyLeadFox dataset, so it cannot be enriched.NoDo not resend.
no_contact_foundIn the dataset and checked, but there was nobody to return. The charge is refunded, and a resend within 150 days returns already, free.No (taken, then refunded — see results[].charged)Do not retry.
errorThe check failed, ran out of time for this domain, or hit a capacity limit on our side. Carries a message.No (taken, then refunded — see results[].charged)Retry later. If errors persist at volume, contact support.
not_attemptedThe call reached its time budget before this domain was checked. Message: "Not attempted — this call reached its time budget. Send these domains again."NoSend these domains again, or send smaller batches.
skipped_no_creditYour enrichment balance ran out during the batch. people is [] and people_count 0. Several domains are checked at once, so the skipped ones are not necessarily the last in your list.NoTop up, then send them again.

More real responses

Outcome already

A domain this account enriched before, less than 150 days ago. Free. Here the earlier paid answer was nobody, so people is empty.

Request
curl -X POST https://go.myleadfox.com/api/v1/enrich \
  -H 'Authorization: Bearer mlf_live_YOUR_KEY' \
  -H 'Content-Type: application/json' \
  -d '{
  "domains": ["gymshark.com"]
}'
Response — HTTP 200
{
  "requested": 1,
  "charged": 0,
  "counts": {
    "already": 1
  },
  "results": [
    {
      "domain": "gymshark.com",
      "outcome": "already",
      "charged": false,
      "people": [],
      "people_count": 0,
      "valid_email_count": 0
    }
  ],
  "credits": {
    "search_remaining": 4997,
    "download_remaining": 38,
    "enrich_remaining": 500,
    "charged": 0,
    "charged_unit": "enrichments"
  }
}

Shape (not a real response)

Shape of an enriched entry

Not a sampled response: our docs never run a paid enrichment. This is one entry of results when contacts are found, with every key the code returns. The values are placeholders — a real entry has up to 5 people.

one entry in results
{
  "domain": "example-shop.com",
  "outcome": "enriched",
  "charged": true,
  "people": [
    {
      "full_name": "<full name>",
      "first_name": "<first name>",
      "last_name": "<last name>",
      "title": "<job title>",
      "current_employer": "<company>",
      "email": "<work email>",
      "email_status": "valid",
      "linkedin_url": "<LinkedIn profile address>",
      "location": "<country code>",
      "seniority_tier": 1,
      "seniority_label": "founder/C-suite",
      "domain_rank": 1,
      "in_top5": 1
    }
  ],
  "people_count": 8,
  "valid_email_count": 3
}

What it refuses

ResponseWhen
invalid_request · 400No domains, or an entry that is not a valid domain.
too_many_domains · 400More than 100 domains after cleaning and de-duplication.
insufficient_credits · 402Pool "enrichment". Only when at least one domain needs a fresh check and the enrichment balance is 0 (needed = how many need one). A batch that is all already / not_in_grid succeeds even at 0. If the balance runs out partway, the call succeeds and the rest come back skipped_no_credit.
feature_disabled · 403The key's account is a team member whose owner has switched enrichment off (feature: "enrichment").
422domains that is not a list of strings or one string.

Plus the key and account refusals every call can get — see Errors. A 422 is a schema error with a different body shape.

Filters

A filter is a JSON object with a kind, a column (col) and the operator fields for that kind. The filters list goes in the body of /search or /rows. Everything you can do in the app's grid can be written this way.

Copy-paste filters

Shops in the United States
{"filters": [{"kind": "dropdown", "col": "country", "values": ["US"]}]}
Shops with an email address
{"filters": [{"kind": "dropdown", "col": "has_email", "values": ["yes"]}]}
Shops on Shopify
{"filters": [{"kind": "dropdown", "col": "actual_platform", "values": ["Shopify"]}]}
Clothing shops, every subcategory included (Google category)
{"filters": [{"kind": "tags", "col": "google_category", "mode": "branch", "values": ["Apparel & Accessories > Clothing"]}]}
All three together (US, with email, on Shopify)
{"filters": [
  {"kind": "dropdown", "col": "country", "values": ["US"]},
  {"kind": "dropdown", "col": "has_email", "values": ["yes"]},
  {"kind": "dropdown", "col": "actual_platform", "values": ["Shopify"]}
]}

Dropdown values are matched exactly as stored: US, yes, Shopify.

The five kinds

kindFieldsExample
textmode (contains, starts, ends, exact, empty, not_empty, regex) and value — or parts for regex{"kind":"text","col":"name","mode":"contains","value":"coffee"}
numericop (between, gte, lte, eq, neq, empty, not_empty), min and/or max, optional includeNull{"kind":"numeric","col":"products","op":"gte","min":500}
dropdownvalues: a list; a row matches if its value is any of them{"kind":"dropdown","col":"country","values":["US","GB"]}
tagsmode (any, all, none; branch on google_category) and values{"kind":"tags","col":"categories","mode":"any","values":["Shopping"]}
datefrom and/or to, as YYYY-MM-DD strings; to is inclusive{"kind":"date","col":"registered","from":"2024-01-01"}

Which kind each column takes is shown beside its name in the Columns section.

Combining filters

Request body (filters)
{"filters": [
{"kind": "dropdown", "col": "country", "values": ["US"], "conn": "or"},
{"kind": "dropdown", "col": "country", "values": ["GB"], "conn": "or"},
{"kind": "numeric",  "col": "products", "op": "gte", "min": 50},
{"kind": "text",     "col": "tagline",  "mode": "contains", "value": "coffee", "not": true}
]}
  • conn is and (default) or or. The result is (every and matches) AND (at least one or matches).
  • not: true inverts a single filter.
  • A filter carrying both wins as an alternative. {"conn": "or", "not": true} is a NEGATIVE ALTERNATIVE — one of the things that may be true — not a requirement. The connector decides which group a filter joins; not only decides what that filter means.
  • The whole expression is: (or OR or …) AND NOT(…) AND (and) AND (and) AND search. Alternatives first, exclusions next, requirements last. That order is how it reads, never what it returns — every group is ANDed to the others.
  • A single or is a requirement. One alternative with nothing to be an alternative TO is simply a condition that must hold.
  • An unknown conn is a 400, never a silent fall back to and. A typo that quietly widened your query would return rows you did not ask for and charge you for them.
  • Position is not meaning. Reordering the list cannot change the result.
  • search is not a filter and has no connector: it always ANDs onto the whole expression, so it can never be widened by someone else’s or.

And, or and not inside one filter

A regex text filter can carry a list of parts instead of one value. This is a second, independent boolean layer: it combines TERMS within a single column, while conn above combines whole FILTERS.

One filter (regex parts)
{"kind": "text", "col": "tagline", "mode": "regex", "parts": [
  {"term": "coffee"},
  {"term": "roaster", "conn": "or"},
  {"term": "wholesale", "conn": "not"},
  {"term": "beans"}
]}
  • "parts" is a list of {"term": "<text>", "conn": "or" | "and" | "not"}. Each term is a case-insensitive "contains" test on the column.
  • "conn" sits on a term and links it to the NEXT term. Inside parts it defaults to "or" when left out — the opposite of the filter level, where conn defaults to "and".
  • "or" ends the current group: the next term starts a new alternative group.
  • "and" keeps the next term in the same group. "not" also keeps the next term in the same group, but negates it: the column must NOT contain it. Any other conn value, null included, acts as "and". The conn on the last term has no effect.
  • Terms inside a group are ANDed; the groups are ORed. So "and" binds tighter than "or".
  • Reading the example: coffee has no conn, so "or" follows it; roaster says "or"; wholesale says "not", so beans joins its group, negated. The filter reads: tagline contains coffee OR contains roaster OR (contains wholesale AND NOT beans).
  • More readings, on the domain column: [{"term": "coffee", "conn": "and"}, {"term": "roast"}] = coffee AND roast. [{"term": "coffee"}, {"term": "tea"}] = coffee OR tea (the default). [{"term": "coffee", "conn": "not"}, {"term": "shop"}] = coffee AND NOT shop — the regex row of the operator table, measured.
  • At most 25 terms per filter, each at most 200 characters. Past either bound the call is refused with a 422 before it runs.
  • A regex filter can still carry a filter-level "conn": "or". The two layers never interfere: parts decides what this column means; the filter-level conn decides how that verdict joins the other filters.

A raw pattern is also accepted with value instead of parts. Patterns are compiled by the same engine that will run the query, so anything it cannot compile is refused with the engine’s own reason rather than a generic error — lookbehind and other Perl extensions are not supported.

Every operator, with the count it matched

Each example below was run against the real dataset when these docs were generated (12-Oct-2026); the count is what that exact example returned. The dataset changes every day, so your count will differ.

kindoperatorexamplematches
textcontains{"kind": "text", "col": "name", "mode": "contains", "value": "coffee"}18,766
textstarts{"kind": "text", "col": "domain", "mode": "starts", "value": "shop"}140,335
textends{"kind": "text", "col": "domain", "mode": "ends", "value": ".shop"}83,831
textexact{"kind": "text", "col": "domain", "mode": "exact", "value": "gymshark.com"}1
textempty{"kind": "text", "col": "tagline", "mode": "empty"}4,087,868
textnot_empty{"kind": "text", "col": "tagline", "mode": "not_empty"}4,832,067
textregex{"kind": "text", "col": "domain", "mode": "regex", "parts": [{"term": "coffee", "conn": "not"}, {"term": "shop"}]}24,284
numericbetween{"kind": "numeric", "col": "products", "min": 10, "max": 100, "op": "between"}1,539,598
numericgte{"kind": "numeric", "col": "products", "op": "gte", "min": 500}504,984
numericlte{"kind": "numeric", "col": "products", "op": "lte", "max": 5}816,032
numericeq{"kind": "numeric", "col": "products", "op": "eq", "min": 1}223,909
numericneq{"kind": "numeric", "col": "products", "op": "neq", "min": 0}3,696,424
numericempty{"kind": "numeric", "col": "products", "op": "empty"}5,030,260
numericnot_empty{"kind": "numeric", "col": "products", "op": "not_empty"}3,889,675
dropdownvalues{"kind": "dropdown", "col": "country", "values": ["US", "GB"]}1,645,791
tagsany{"kind": "tags", "col": "categories", "mode": "any", "values": ["Shopping", "Shopping & Retail"]}136,474
tagsall{"kind": "tags", "col": "categories", "mode": "all", "values": ["Apparel & Accessories > Clothing", "Apparel & Accessories > Clothing > T-Shirts"]}273
tagsnone{"kind": "tags", "col": "categories", "mode": "none", "values": ["Apparel & Accessories > Clothing > T-Shirts"]}8,754,419
datefrom/to{"kind": "date", "col": "registered", "from": "2020-01-01", "to": "2024-12-31"}2,588,667

Date bounds and includeNull

These options were not part of the measured run, so they have no count. What each one does:

kindoptionexampleWhat it does
datefrom only{"kind": "date", "col": "registered", "from": "2024-01-01"}Open-ended: on or after 1 January 2024.
dateto only{"kind": "date", "col": "registered", "to": "2024-12-31"}On or before 31 December 2024 — to is INCLUSIVE, so the last day counts.
datefrom + to{"kind": "date", "col": "registered", "from": "2024-01-01", "to": "2024-12-31"}The whole of 2024, both days included.
numericincludeNull{"kind": "numeric", "col": "products", "op": "gte", "min": 50, "includeNull": true}Also matches shops where the value was never measured. Without it, products gte 50 leaves out every shop whose products were never counted.
tagsbranch{"kind": "tags", "col": "google_category", "mode": "branch", "values": ["Apparel & Accessories > Clothing"]}google_category only: the category you name AND every category below it, so Clothing also finds Clothing > Dresses. On any other column, branch is a 400.

{"search": "coffee"} matches a substring, case-insensitively, across six columns: domain, name, tagline, description_preview, emails, categories. Deliberately a substring match, so coffee finds coffeehouse.

Sorting

Up to 8 keys, e.g. {"sort": [{"col": "products", "dir": "desc"}]}. An unknown column is a 400 unknown_column, unlike the app which ignores it, because a machine that misspells a column would otherwise receive a correct-looking 200 sorted by something else entirely.

Columns (100)

The columns field of /rows and /lookup accepts any name below, or ["all"] for every one. A name that is not on this list is refused with 400 unknown_column: nothing is silently dropped. On /lookup: Always include domain in columns. Without it every domain is reported as missing, even the ones that are in the dataset — and nothing is charged or delivered.

A missing value is not a zero.

  • In /rows and /lookup, a value that was never measured comes back as an empty string "" — for every column type: text, numbers and dates alike. A row never contains null. One exception: country holds the value "Unknown" (never "") when the country is not known — a text filter with mode empty / not_empty on country treats "Unknown" as blank.
  • A number column therefore holds either a number or "". The API never turns a missing value into 0, so 0 is a real measured zero and "" means "not measured".
  • For a text column, "" means there is no value: never measured and measured-as-empty are the same thing. Treat "" as unknown, not as evidence that something is absent.

Two notes specific to est_monthly_visits: it is a third-party estimate of monthly traffic rather than a figure we measure, and it is empty ("" in the API) for a little under half of all shops. Where a shop sits on a subdomain, the estimate is usually the traffic of its parent domain — so an unusually large number on a subdomain may belong to the parent site rather than to the shop.

Beside each name is the filter kind it takes (see Filters). Hover a name for its type.

Identity & status (9)

  • domaintext
  • statusdropdown
  • status_reasondropdown
  • actual_platformdropdown
  • shop_activedropdown
  • nametext
  • taglinetext
  • description_previewtext
  • myshopifytext

Catalogue (5)

  • productsnumeric
  • collectionsnumeric
  • categoriestags
  • google_categorytags
  • shop_collectionstext

Location & market (7)

  • citytext
  • statetext
  • countrydropdown
  • ships_tonumeric
  • agenumeric
  • registereddate
  • currencydropdown

Language (7)

  • primary_langdropdown
  • primary_lang_namedropdown
  • languagetags
  • language_namestags
  • hreflang_countnumeric
  • hreflang_listtags
  • is_multilingualdropdown

Contact (5)

  • emailstext
  • has_emaildropdown
  • phonetext
  • has_phonedropdown
  • contact_urltext

Technology (3)

  • themetext
  • appstags
  • other_techtags

Social (9)

  • has_any_socialdropdown
  • social_countnumeric
  • instagramtext
  • facebooktext
  • tiktoktext
  • twittertext
  • youtubetext
  • pinteresttext
  • linkedintext

Performance (lab) (18)

  • lh_perfnumeric
  • lh_seonumeric
  • lh_a11ynumeric
  • lh_bpnumeric
  • lcpnumeric
  • fcpnumeric
  • sinumeric
  • tbtnumeric
  • clsnumeric
  • lh_perf_desknumeric
  • lh_seo_desknumeric
  • lh_a11y_desknumeric
  • lh_bp_desknumeric
  • lcp_desknumeric
  • fcp_desknumeric
  • si_desknumeric
  • tbt_desknumeric
  • cls_desknumeric

Performance (field / CrUX) (7)

  • crux_lcpnumeric
  • crux_inpnumeric
  • crux_fcpnumeric
  • crux_ttfbnumeric
  • crux_clsnumeric
  • crux_verdictdropdown
  • psi_statusdropdown

SEO (11)

  • est_monthly_visitsnumeric
  • seo_titletext
  • favicontext
  • h1_countnumeric
  • og_titletext
  • og_descriptiontext
  • og_imagetext
  • og_typedropdown
  • og_site_nametext
  • og_urltext
  • seo_completenessnumeric

Record metadata (1)

  • http_codedropdown

Other (18)

  • mail_providerdropdown
  • mail_categorydropdown
  • mail_hosttext
  • ship_intldropdown
  • ship_partnerstags
  • ship_policy_urltext
  • pixelstags
  • videotags
  • crux_has_datadropdown
  • seo_title_lennumeric
  • seo_metatext
  • seo_meta_lennumeric
  • canonicaltext
  • robotstext
  • h1_firsttext
  • sd_typestags
  • data_completenessnumeric
  • has_lighthouse_datadropdown

Errors

Branch on the error code, never on the message: the code is stable, the wording may change.

Errors are never charged. An API refusal has the body {"error": "<code>", "message": "<sentence>", …extra fields}, plus the credits block when the key and account had already been accepted. Three replies come from the web framework before the API runs, and have a different body, {"detail": …}: 405 (wrong method), 404 Not Found (wrong path) and 422 (schema error). The table is in the order the server checks.

CodeHTTPWhenCredits block?What to doExtra fields
— (Method Not Allowed)405Any method but POST on an API address.NoUse POST. Body: {"detail":"Method Not Allowed"}; header Allow: POST.—
— (Not Found)404POST to a path under https://go.myleadfox.com/api/v1 that does not exist (a typo in the endpoint name).NoUse /search, /rows, /lookup or /enrich after https://go.myleadfox.com/api/v1. Body: {"detail":"Not Found"}.—
— (validation)422The body breaks the schema: not JSON, no body, wrong Content-Type, or a value of the wrong TYPE — a filter or sort entry that is not an object, domains that is not a list of strings or one string, columns that is not a list of strings, include_total that cannot be read as a boolean (send true or false), page or limit that cannot be read as a whole number. Also: page outside 1–200,000, limit outside 1–100, more than 64 filters, 8 sort keys or 200 columns, a regex filter with more than 25 parts or a part over 200 characters. Checked before your key.NoRead detail[].loc (which field) and detail[].msg (what is wrong) and fix it.—
missing_api_key401No key: no Authorization: Bearer and no X-API-Key. A Basic or Digest Authorization header counts as no key.NoSend Authorization: Bearer mlf_live_…—
invalid_api_key401The key is unknown, malformed, rotated or revoked, or its account was deleted. All give the same answer.NoCopy the key again, or create a new one.—
ip_not_allowed403The key has an IP allowlist and the calling address is not on it.NoAdd the address to the key's allowlist, or call from an allowed address.—
account_inactive403The key's account, or its team owner's, is closed or must reset its password.NoLog in to the app; contact support if the account is closed.—
no_active_plan402The paying account has no plan or pass running.NoChoose a plan or a pass.—
free_trial_no_api403The paying account is on the trial package, which has no API access.NoChoose a plan or a pass.—
account_frozen402A subscription payment failed. Applies from the next call.NoSettle the invoice in the app.since, invoice_id (when known)
insufficient_scope403The key lacks the scope (permission) this call needs: search, download or enrich.NoCreate a key with that scope ticked under Scopes.required_scope
rate_limited429Over the per-key units, already 2 calls in flight on the key, or the server is busy.YesWait the number of seconds in the Retry-After header, then retry.reason (per_key, too_many_concurrent, server_busy, fair_share), retry_after
invalid_request400The CONTENT of a well-typed body is wrong: a filter with no col, an unknown or hidden filter column, an unknown conn, a bad regex or filter value; a sort entry with no col, or a dir that is not asc/desc; an empty domain list; a domain entry that is not a valid domain. (A value of the wrong TYPE is a 422 instead.)YesFix the request; the message says what is wrong.domain (for a bad domain)
unknown_column400A requested column or sort key is unknown or hidden.YesUse a name from the Columns list.column
too_many_domains400More than 100 domains after cleaning and de-duplication (/lookup, /enrich).YesSplit into batches of 100 or fewer.limit, given
search_not_found404The search_id does not resolve.YesCopy the id from a grid URL again, or run /search.search_id
page_out_of_range404A /rows page past the last one (page 2 or later with no rows).YesStop paging: you have every page.pages, requested
insufficient_credits402The balance this call spends is 0 (see the partial-balance table), or another call spent it at the same moment.YesTop up, or wait for your allowance to renew.pool (search, download, enrichment), needed, remaining
feature_disabled403The key's account is a team member whose owner has switched downloading or enrichment off.YesAsk the team owner to switch it back on.feature (downloading, enrichment)
server_error500Something failed on our side.Yes, if the key had been acceptedRetry. Message: "Something went wrong on our side. You were not charged. Please retry."—
key_management_forbidden403Not an /api/v1 reply. Returned only by the app's API Keys page when a team member tries to create, rotate or revoke a key. Body: {"detail":{"error":"key_management_forbidden","message":…}}.NoAsk the account owner for a key.—

What an error body looks like

An API refusal after the key is accepted (402) — with the credits block
{
  "error": "insufficient_credits",
  "message": "Not enough download credits: this call needs 1 and 0 remain.",
  "pool": "download",
  "needed": 1,
  "remaining": 0,
  "credits": {
    "search_remaining": 4997,
    "download_remaining": 0,
    "enrich_remaining": 500,
    "charged": 0,
    "charged_unit": "none"
  }
}
A key or account refusal (401) — no credits block
{
  "error": "invalid_api_key",
  "message": "That API key is not valid."
}
A rate-limit refusal (429) — shape
HTTP 429
Retry-After: <whole seconds to wait>

{
  "error": "rate_limited",
  "message": "Rate limit exceeded. Retry after the interval in the `Retry-After` header.",
  "reason": "per_key",
  "retry_after": <the same number>,
  "credits": { … }
}
Wrong method (405)
HTTP 405 Method Not Allowed
Allow: POST

{"detail": "Method Not Allowed"}
Schema error (422) — page 0
{"detail": [{"type": "greater_than_equal", "loc": ["body", "page"], "msg": "Input should be greater than or equal to 1", "input": 0, "ctx": {"ge": 1}}]}
Schema error (422) — no body
{"detail": [{"type": "missing", "loc": ["body"], "msg": "Field required", "input": null}]}

In a 422, loc says where the problem is (["body", "page"]) and msg says what is wrong. The exact wording of msg comes from the framework and can change between versions; branch on the status code.

Limits

Limits are per key, and the same on every plan. A call over a limit is refused with 429 and costs nothing.

LimitValue
Units per key60 per rolling 60-second window
Calls in flight per key2 at the same time
PlansThe same on every plan

What each call counts against the limit

Each call uses a number of units out of the key’s 60. Filters alone always use 1. A call with global text search uses more: that means a non-empty search text, or a search_id whose saved search has search text. Calls of different kinds add up within the same 60.

CallUnitsMost per minute on one key
POST /search — filters only160
POST /search — with global text search154
POST /rows — filters only (with or without include_total)160
POST /rows — with global text search87
POST /rows — with global text search and include_total: true154
POST /lookup160
POST /enrich160

Units are not credits. Units only pace how fast a key may call; credits are what a call spends.

When you hit a limit

  • The reply is 429 rate_limited with a Retry-After header: the whole number of seconds to wait (at least 1, at most 60). The body repeats it as retry_after.
  • reason says which limit: per_key (the key used its units), too_many_concurrent (2 calls already in flight on the key; Retry-After is 1), or server_busy / fair_share (the service is busy — wait and retry).
  • Wait the number of seconds given, then send the same call again.

Speed

  • Filtered calls are fast, including deep pages.
  • A global text search is slower on its first call and quicker on the pages after it.
  • /enrich checks each domain live while the call runs and can take over a minute for 100 domains. Raise your tool’s timeout to its maximum, or send smaller batches. Domains the call had no time for come back not_attempted, free: send them again.

Try it (the playground)

Compose a filter expression, pick columns, and send a real call with your own key — or copy the generated curl to your terminal. Real calls spend real credits.

Counts how many shops match a filter set and returns a search_id for it. It never returns rows. Use it to size a list before spending download credits, then page the rows with /rows and the search_id.

Held in memory for this page only, never saved to browser storage, never sent anywhere except the API. Reloading clears it.

Filters

Request

curl -X POST https://go.myleadfox.com/api/v1/search \
  -H 'Authorization: Bearer mlf_live_YOUR_KEY' \
  -H 'Content-Type: application/json' \
  -d '{
  "filters": [
    {
      "kind": "dropdown",
      "col": "country",
      "values": [
        "US"
      ]
    }
  ]
}'

Questions

Which address do I call?

https://go.myleadfox.com/api/v1 followed by the endpoint: /search, /rows, /lookup or /enrich. For example https://go.myleadfox.com/api/v1/search. myleadfox.com and www.myleadfox.com are the website: they do not serve the API, and a call sent there gets a web page back, not JSON.

Is it GET or POST?

Always POST, with the parameters in a JSON body and the header Content-Type: application/json. A GET to an API address returns 405 Method Not Allowed. Parameters put in the URL are not read.

How do I authenticate?

Send your key as a bearer token: Authorization: Bearer mlf_live_…. An X-API-Key: mlf_live_… header is accepted instead, for tools where a custom header is easier to set. Any other Authorization scheme, such as Basic, counts as no key at all (401 missing_api_key). The key, your plan and your account are re-checked on every call, so a revoked key or a lapsed plan stops working on the very next request.

Can I call it from Zapier, Make, n8n or my own server?

It is an ordinary HTTPS API on go.myleadfox.com (port 443): every call is a POST with two headers and a JSON body. In an automation tool, use its generic HTTP request module with the settings shown under "Your first call" on the API documentation page. If a call ever gets a challenge page or any other reply that is not JSON, contact support and include the Ray ID shown on that page so we can look it up.

Am I ever charged for an error?

No. A refused call (any 4xx, including 402, 403 and 404), a failure on our side (500) and a rate-limit refusal (429) cost no credits. When a /search count fails on our side, the search credit already taken is refunded. A /rows page past the last one (404 page_out_of_range) is free too.

Do I need to allowlist anything?

No. Nothing needs to be added on our side before you call the API. Each key can optionally be locked to your own IP addresses (the IP allowlist on the API Keys page); it is off by default. If you turn it on, calls from any other address are refused with 403 ip_not_allowed.

Are the API balances separate from the app?

There is no separate API allowance: the API spends the same balances as the app. There are three, and they are separate from each other: search credits (spent by /search), download credits, also called leads (spent by /rows and /lookup), and enrichment credits (spent by /enrich). Every successful call reports all three in the credits block of its JSON body.

Can I get a CSV from the API?

No. The API returns JSON only; a format field is ignored. Your tool or a short script turns the rows array into spreadsheet rows (the columns array gives the column order). Every /rows and /lookup call is also kept in your Download History in the app, as JSON, for 100 days, and can be downloaded again there at no cost (API entries cannot be sent to Google Sheets). For a CSV file or Google Sheets, use the export in the app itself.

Does my lifetime deal include the API?

Yes. A lifetime deal account can use the API with one key at a time (other plans can hold up to three).

Is the API included with a pass and with Starter and Pro?

Yes. Starter and Pro include the API at no extra cost, and so does a 24-hour or 72-hour pass while it runs: calls spend the pass allowance. A key on an account with no plan or pass running is refused with 402 no_active_plan.

What are the rate limits?

60 units per minute per key, and at most 2 calls in flight per key, on every plan. Most calls use 1 unit. A call with global text search uses more: 15 for /search, 8 for a /rows page, 15 for a /rows page with include_total. Going over returns 429 rate_limited with a Retry-After header saying how many seconds to wait.

Does the API expose the whole filter surface, or a reduced subset?

The whole surface: five filter kinds (text, numeric, dropdown, tags, date), and/or connectors, per-filter negation with not: true, and/or/not inside a regex filter, global text search, and sorting on up to eight keys. Anything you can build in the app's grid you can send in a request, and any grid link's id works as a search_id.

How does paging work?

Pages are numbered and always 100 rows: page 1 is rows 1 to 100, page 2 is rows 101 to 200. Any page can be asked for directly. limit (1 to 100) caps how many rows of that page come back and you are charged only for rows delivered. Keep asking for the next page until you reach the last one; a page past the end returns 404 page_out_of_range, free, with the real page count.

How fast is it?

Filtered calls are fast, including deep pages. A global text search is slower on its first call and quicker on the pages after it. Enrichment checks each domain live while the call runs and can take over a minute for 100 domains: raise your tool's timeout to its maximum, or send smaller batches.

What happens if my balance runs out mid-page?

On /rows you receive as many rows as your download balance covers and truncated_by_credits comes back true. That flag is also true on a short last page that came back complete, so read credits.download_remaining too: if it is 0 (or null) the page may be cut — top up and ask for the same page again, which delivers and charges the whole page again, including the rows you already received; if it is above 0, nothing is missing and that was the last page, so do not ask for it again. On /lookup the domains past your balance come back in withheld_for_credits, free. On /enrich the domains past your balance come back as skipped_no_credit, free. Top up and ask again for what was left.

A call timed out on my side. Is it safe to retry?

There is no idempotency key: a retry that reaches the server is a new call and is charged again. For /rows and /lookup, first look in Download History in the app: if the call is listed (match it by time and leads charged), it was delivered and charged, and you can download that response again there for free instead of paying twice. For /enrich: Resending a batch never charges twice for one domain: every domain the first call finished as enriched or no_contact_found comes back as already, free — even if you never saw the first response. Domains that came back error, or that were cut off when the call died, were refunded; on a resend they are checked again and charged only if contacts are found. A JSON refusal from the API (4xx, 429) and a JSON 500 cost nothing, so retrying those is always free. A reply that is not JSON (a gateway error or challenge page) did not come from the API and may follow a call that was delivered and charged: never retry it blindly — check Download History first, exactly as after a timeout.

No account yet? Get started — the API comes with every paid plan and pass. Already have one? Create your key below.

Create your API key

The API is included on Starter, Pro, the passes and lifetime deals, and spends the same balances as the app.