# MyLeadFox API > Pull shop lists, look up domains and find contacts from your own tools. > Base URL: https://go.myleadfox.com/api/v1 The web version of this document, with a live playground: https://myleadfox.com/api-docs ## API A JSON-over-HTTPS API for the same data, filters and credits as the web app. Full human-readable reference with a live playground: https://myleadfox.com/api-docs ### The rules that apply to every call - Base URL: https://go.myleadfox.com/api/v1 — this is the ONLY address the API answers on. The four endpoints are: POST https://go.myleadfox.com/api/v1/search POST https://go.myleadfox.com/api/v1/rows POST https://go.myleadfox.com/api/v1/lookup POST https://go.myleadfox.com/api/v1/enrich - https://myleadfox.com (and its www address) is the marketing WEBSITE. It does not serve the API; a call sent there does not reach it. Only the human-readable documentation lives on the website: https://myleadfox.com/api-docs - Every endpoint is POST. Never GET. A GET (or any other method) is HTTP 405 with the body {"detail": "Method Not Allowed"}. - Every parameter goes in a JSON request body. Never put parameters in the query string — they are not read. Send a body even when it is empty: {}. - Send these two request headers on every call: Authorization: Bearer mlf_live_YOUR_KEY Content-Type: application/json "X-API-Key: mlf_live_YOUR_KEY" is accepted instead of the Authorization line. Any other Authorization scheme (Basic, Digest) counts as no key at all: 401 missing_api_key. - Every response is JSON. The API returns JSON only: there is no CSV or other format, and a "format" field is ignored. (CSV and Google Sheets export are features of the web app at https://go.myleadfox.com, not of the API.) - Unknown body fields are SILENTLY IGNORED and the default applies. A misspelled field ("pgae": 3) gives no error, so check field names against this reference. - The body is checked against the schema first (a value of the wrong type, a value out of range, invalid JSON, a missing body, a Content-Type other than application/json): those are HTTP 422 with FastAPI's {"detail": [...]} body, before your key is even looked at. Then come, in order: the key and account checks, the scope, the rate limit, and then the call itself — which is where credits are charged (each endpoint below says exactly when; /search, for example, takes its credit before counting and refunds it if the count fails). - Errors are never charged: a refused call (4xx), a failure on our side (500) and a rate-limit refusal (429) cost nothing. ### Common mistakes, and what you will see | Mistake | What you get | Fix | | --- | --- | --- | | Calling the website (myleadfox.com or www.myleadfox.com) instead of go.myleadfox.com | You 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 GET | 405 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 /search | Ignored. /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_…. | ### Calling it from an automation tool (Zapier, Make, n8n or any HTTP module) Use the tool's generic HTTP request module (not a form or webhook trigger) with these settings — this example is the first call, counting the shops in the United States (1 search credit): | Setting | Value | | --- | --- | | Method | POST | | URL | https://go.myleadfox.com/api/v1/search | | Header 1 | Authorization: Bearer mlf_live_YOUR_KEY | | Header 2 | Content-Type: application/json | | Body type | Raw / JSON (not form fields) | | Body | {"filters": [{"kind": "dropdown", "col": "country", "values": ["US"]}]} | | Parse the response as | JSON | ### Getting a key, and who can use the API - Create keys in the app at https://go.myleadfox.com/api-keys ("API Keys" in the app menu), signed in as the account owner. A key looks like mlf_live_ followed by 43 characters. The full key is shown ONCE, when it is created or rotated; afterwards only its last 6 characters are shown, to everyone. - Keys at a time: up to 3 active keys per account, 1 on a lifetime deal account (see the table). - Each key has scopes (the app's form labels them "Scopes"; they are permissions) — all three on by default; a key needs at least one. A call that needs a scope the key lacks is 403 insufficient_scope with "required_scope". | Scope | Allows | Spends | | --- | --- | --- | | search | POST /search — count a filter set and get a search_id. | search credits | | download | POST /rows and POST /lookup — receive shop rows. | download credits (leads) | | enrich | POST /enrich — find contacts for domains. | enrichment credits | - Optional IP allowlist per key (single IPs or CIDR ranges); off by default. A call from an address not on it is 403 ip_not_allowed. - Rotating a key kills the old secret immediately and keeps the name, scopes and allowlist. Revoking a key stops it on the next call. Keys belong to the account; team members can use the owner's key but only the owner can create, rotate or revoke keys. - Never put a key in client-side code (a web page, a mobile app). Call the API from a server or a script. - A key keeps working only while the plan does: access is re-checked on every call, against the account that pays (the team owner's, for a team member). Who gets the API — at no extra cost; there is no separate API allowance, a key spends the same search, download and enrichment credits as the web app: | Account | API access | Keys at a time | | --- | --- | --- | | Starter (monthly or yearly) | Yes | Up to 3 | | Pro (monthly or yearly) | Yes | Up to 3 | | 24-hour or 72-hour pass | Yes, while the pass runs. Calls spend the pass allowance. | Up to 3 | | Lifetime deal | Yes | 1 | | Older plans no longer sold | Yes | Up to 3 | | Trial package from before paid sign-up | No — 402 no_active_plan (or 403 free_trial_no_api) | — | | No plan or pass running | No — 402 no_active_plan | — | | Subscription payment failed | Not until the invoice is settled — 402 account_frozen | — | Plans and prices: https://myleadfox.com/pricing ### Credits: what each call uses (three separate balances) | Call | Scope | Balance | Cost | Free | | --- | --- | --- | --- | --- | | POST /search | search | search credits | 1 per call, also when nothing matches | A count that fails on our side (500) is refunded. | | POST /rows | download | download credits (leads) | 1 per row delivered | Page 1 of an empty result (0 rows, 0 charged). A page past the end (404). | | POST /lookup | download | download credits (leads) | 1 per domain found and delivered | Domains in missing and in withheld_for_credits. | | POST /enrich | enrich | enrichment credits | 1 per domain with outcome enriched | The other six outcomes: already, not_in_grid, no_contact_found, error, not_attempted, skipped_no_credit. | When a balance is smaller than the call: | Call | Balance at 0 | Balance smaller than the call | | --- | --- | --- | | /search | 402 insufficient_credits (needed 1). | — | | /rows | 402 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. | | /lookup | 402 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. | | /enrich | 402 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. | Every successful response carries a "credits" object in its JSON body (this one is from a /rows call): ```json "credits": { "search_remaining": 4993, "download_remaining": 199900, "enrich_remaining": 500, "charged": 100, "charged_unit": "leads" } ``` - search_remaining — Search credits left after this call. - download_remaining — Download credits (leads) left after this call. - enrich_remaining — Enrichment credits left after this call. - charged — How 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. - The credits block is also present on refusals that happen after your key and account were accepted: 429 rate_limited, 402 insufficient_credits, 400 invalid_request / unknown_column / too_many_domains, 404 search_not_found / page_out_of_range, 403 feature_disabled and a 500 server_error. On those, charged is 0 and charged_unit is "none". - It is ABSENT on refusals raised while checking the key and account (401 missing_api_key / invalid_api_key; 403 ip_not_allowed / insufficient_scope / account_inactive / free_trial_no_api; 402 no_active_plan / account_frozen), and on 422, 405 and unknown-path 404 bodies. ### The four endpoints at a glance | Endpoint | Does | Scope | Charged | Rate-limit weight | | --- | --- | --- | --- | --- | | POST https://go.myleadfox.com/api/v1/search | Search: count a filter set | search | 1 search credit per call, including when nothing matches | filters only: 1; with global text search: 15 | | POST https://go.myleadfox.com/api/v1/rows | Rows: one page of shops | download | 1 download credit (lead) per row delivered | filters only (with or without include_total): 1; with global text search: 8; with global text search and include_total: true: 15 | | POST https://go.myleadfox.com/api/v1/lookup | Lookup: fetch shops by domain | download | 1 download credit (lead) per domain found; misses are free | every call: 1 | | POST https://go.myleadfox.com/api/v1/enrich | Enrich: find contacts | enrich | 1 enrichment credit per domain enriched; the other six outcomes are free | every call: 1 | The usual flow: /search to size a segment (1 search credit), then /rows page by page with the returned search_id; /lookup when you already have domains; /enrich for contacts. ### POST https://go.myleadfox.com/api/v1/search — Search: count a filter set 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. Scope required on the key: search. Spends: search credits. Cost: 1 search credit per call, including when nothing matches. Rate-limit weight (units per call): filters only: 1; with global text search: 15. Body fields: - filters — array of filter objects. optional — defaults to [] (no filter). Limit: 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. - search — string or null. optional — defaults to null. Limit: no length bound. Global 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). - search_id — string or null. optional — defaults to null. Limit: wins over filters and search when both are sent. A saved search: a 16-character id. It is the id in a grid URL — https://go.myleadfox.com/grid/ — 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. - sort — array of {col, dir} objects. optional — defaults to [] (unsorted). Limit: 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. Ignored here: page, limit, columns and include_total are not /search fields: they are silently ignored here. To get rows, call /rows. When the balance is 0: 402 insufficient_credits (needed 1). Good to know: - An empty body {} is valid: it counts every shop (and costs 1 search credit). - The search credit is taken before the count runs. If the count then fails on our side, the credit is refunded and the reply is 500 server_error. A refusal (400, 404, 422) costs nothing. - A search that matches nothing still costs 1 search credit: the query ran. total and pages are 0 (see the example below). Example request: ```bash 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"] } ] }' ``` Response, HTTP 200 (real): ```json { "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" } } ``` Response fields: - search_id (string) — The id of this search (16 characters). It is a content hash: the same filters and search text always give the same id. Send it to /rows to page the result without re-sending the filters, or open it in the app at https://go.myleadfox.com/grid/. It never expires. - search_url (string) — The same search as a path in the app: /grid/. It is relative — put https://go.myleadfox.com in front of it. - total (integer) — How many shops match: an exact count (not an estimate), counted live at the moment of the call. - pages (integer) — How many 100-row pages that total makes (total ÷ 100, rounded up). 0 when total is 0. - page_size (integer) — Always 100. - filters (array) — The filters that actually ran — the stored ones when you sent a search_id. Compare with what you sent when a count surprises you. - search (string or null) — The global search text that ran, or null. - sort (array) — Your sort, after validation. It is not stored in the search_id. - global_search (boolean) — true when search text was applied (a global text search). - credits (object) — The credits block — see "What each call uses". Here charged is 1 and charged_unit is "searches". More real responses (each with the request that produced it): #### A global text search search text instead of filters. global_search comes back true. Request: ```bash curl -X POST https://go.myleadfox.com/api/v1/search \ -H 'Authorization: Bearer mlf_live_YOUR_KEY' \ -H 'Content-Type: application/json' \ -d '{ "search": "coffee" }' ``` Response, HTTP 200 (real): ```json { "search_id": "50f15173380e2c18", "search_url": "/grid/50f15173380e2c18", "total": 65046, "pages": 651, "page_size": 100, "filters": [], "search": "coffee", "sort": [], "global_search": true, "credits": { "search_remaining": 4998, "download_remaining": 200000, "enrich_remaining": 500, "charged": 1, "charged_unit": "searches" } } ``` #### Zero matches still costs a credit The query ran, so credits.charged is 1. total and pages are 0. Asking /rows for page 1 of this search returns 200 with rows [] and charges nothing; page 2 or later is a 404 page_out_of_range. Request: ```bash 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": "text", "col": "domain", "mode": "exact", "value": "no-such-domain.invalid" } ] }' ``` Response, HTTP 200 (real): ```json { "search_id": "739b3ff5025d5917", "search_url": "/grid/739b3ff5025d5917", "total": 0, "pages": 0, "page_size": 100, "filters": [ { "kind": "text", "col": "domain", "mode": "exact", "value": "no-such-domain.invalid" } ], "search": null, "sort": [], "global_search": false, "credits": { "search_remaining": 4997, "download_remaining": 200000, "enrich_remaining": 500, "charged": 1, "charged_unit": "searches" } } ``` Refusals specific to this endpoint (plus the key and account refusals in "Errors"; a 422 has the {"detail": [...]} body): - insufficient_credits · 402 — No search credit left (pool "search", needed 1). Nothing is counted. - search_not_found · 404 — The search_id does not resolve. The response repeats it in a search_id field. - invalid_request · 400 — A 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. - unknown_column · 400 — A sort key names an unknown or hidden column. The name comes back in a column field. - server_error · 500 — The count failed on our side. The search credit is refunded. - 422 — A filter or sort entry that is not a JSON object, more than 64 filters, more than 8 sort keys, or a regex part past its bounds. ### POST https://go.myleadfox.com/api/v1/rows — Rows: one page of shops 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. Scope required on the key: download. Spends: download credits (leads). Cost: 1 download credit (lead) per row delivered. Rate-limit weight (units per call): filters only (with or without include_total): 1; with global text search: 8; with global text search and include_total: true: 15. Body fields: - search_id — string or null. optional — defaults to null. Limit: wins over filters and search when both are sent. A saved search: a 16-character id. It is the id in a grid URL — https://go.myleadfox.com/grid/ — 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. - filters — array of filter objects. optional — defaults to [] (no filter). Limit: 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. - search — string or null. optional — defaults to null. Limit: no length bound. Global 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). - sort — array of {col, dir} objects. optional — defaults to [] (unsorted). Limit: 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. - page — integer. optional — defaults to 1. Limit: 1 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. - limit — integer. optional — defaults to 100. Limit: 1 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. - columns — array of strings, or null. optional — defaults to null (every column). Limit: 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_total — boolean. optional — defaults to false. Limit: none. 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. When the balance is 0: 402 insufficient_credits (needed = limit). Nothing delivered. When the balance is above 0 but smaller than the call: You get as many rows as the balance covers, truncated_by_credits is true, and you pay only for those. 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. Example request: ```bash 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 (real): ```json { "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" } } ``` Response fields: - search_id (string) — The 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. - page (integer) — The page you asked for. - page_size (integer) — Always 100. - limit (integer) — The limit you sent, or 100. - returned (integer) — How many rows are in this response — and how many leads were charged. - columns (array of strings) — The columns each row carries, in your order. Use this list for the order of your spreadsheet columns. - rows (array of objects) — One 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_credits (boolean) — true 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. - total (integer — only with include_total) — How many shops match, counted live. - pages (integer — only with include_total) — How many 100-row pages that makes. - credits (object) — The credits block — see "What each call uses". Here charged equals returned and charged_unit is "leads". More real responses (each with the request that produced it): #### With include_total The same search, one column, limit 1 and include_total true: total and pages come back. Request: ```bash 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 (real): ```json { "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: ```bash 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 (real): ```json { "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: ```bash 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 (real): ```json { "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: ```bash 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 (real): ```json { "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: ```bash 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 (real): ```json { "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" } } ``` Refusals specific to this endpoint (plus the key and account refusals in "Errors"; a 422 has the {"detail": [...]} body): - insufficient_credits · 402 — Download 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 · 404 — The 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 · 404 — The search_id does not resolve. - unknown_column · 400 — A requested column or sort key is unknown or hidden. Named in a column field. - invalid_request · 400 — A 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 · 403 — The key's account is a team member whose owner has switched downloading off (feature: "downloading"). - server_error · 500 — Reading the page failed on our side. Nothing is delivered or charged. - 422 — page 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. ### POST https://go.myleadfox.com/api/v1/lookup — Lookup: fetch shops by domain 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. Scope required on the key: download. Spends: download credits (leads). Cost: 1 download credit (lead) per domain found; misses are free. Rate-limit weight (units per call): every call: 1. Body fields: - domains — array of strings, OR one comma-separated string. required in practice — an empty list is a 400 invalid_request. Limit: at 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. - columns — array of strings, or null. optional — defaults to null (every column). Limit: 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. When the balance is 0: 402 insufficient_credits (needed = number of domains sent). Nothing delivered. When the balance is above 0 but smaller than the call: The first domains found, in your order, are delivered. The rest are listed in withheld_for_credits, free. 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. Example request: ```bash 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 (real): ```json { "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" } } ``` Response fields: - requested (integer) — How many domains you sent, after cleaning and de-duplication. - found (integer) — How many rows were delivered — and how many leads were charged. - missing (array of strings) — Your 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_credits (array of strings) — Domains in the dataset that your balance did not cover. Free. Top up and send them again. - truncated_by_credits (boolean) — true when withheld_for_credits is not empty. - columns (array of strings) — The columns each row carries, in your order. - rows (array of objects) — One 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. - credits (object) — The credits block — see "What each call uses". Here charged equals found and charged_unit is "leads". More real responses (each with the request that produced it): #### Too many domains 101 domains (d0.example.com to d100.example.com) in one call. Refused before any query; free. Request: ```bash 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 (real): ```json { "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" } } ``` Refusals specific to this endpoint (plus the key and account refusals in "Errors"; a 422 has the {"detail": [...]} body): - too_many_domains · 400 — More than 100 domains after cleaning and de-duplication. Carries limit and given. - invalid_request · 400 — No domains ("send at least one domain"), or an entry that is not a valid domain (named in a domain field). - unknown_column · 400 — A requested column is unknown or hidden. - insufficient_credits · 402 — Download 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 · 403 — The key's account is a team member whose owner has switched downloading off. - server_error · 500 — Reading the rows failed on our side. Nothing is delivered or charged. - 422 — domains that is not a list of strings or one string, columns that is not a list of strings, or more than 200 columns. ### POST https://go.myleadfox.com/api/v1/enrich — Enrich: find contacts 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. Scope required on the key: enrich. Spends: enrichment credits. Cost: 1 enrichment credit per domain enriched; the other six outcomes are free. Rate-limit weight (units per call): every call: 1. Body fields: - domains — array of strings, OR one comma-separated string. required in practice — an empty list is a 400 invalid_request. Limit: at 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. When the balance is 0: 402 insufficient_credits, but only if at least one domain needs a fresh check. A batch that is all already / not_in_grid still succeeds. When the balance is above 0 but smaller than the call: 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. 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. Example request: ```bash 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 (real): ```json { "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" } } ``` Response fields: - requested (integer) — How many domains you sent, after cleaning and de-duplication. - charged (integer) — How many domains were charged (the entries with charged true). - counts (object) — How many domains got each outcome, e.g. {"enriched": 3, "already": 2}. Only outcomes that happened are listed. - results (array of objects) — One entry per domain, in the order you sent them. Fields of an entry are listed below. - results[].domain (string) — The domain, cleaned. - results[].outcome (string) — One 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[].charged (boolean) — true 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[].people (array of objects) — Up 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_count (integer) — How 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_count (integer) — How 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[].company (string or null — only on already) — The 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[].message (string) — A plain sentence, on not_in_grid, not_attempted and error. - credits (object) — The 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. - full_name (string or null) — The person's full name, title-cased. - first_name (string or null) — First name, title-cased. - last_name (string or null) — Last name, title-cased. - title (string or null) — Job title, as found. - current_employer (string or null) — The company the person works for, as found. - email (string or null) — A work email, lower-cased — filled ONLY when it was checked and found valid. null otherwise. - email_status (string) — "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_url (string or null) — The person's LinkedIn profile address. - location (string or null) — Where the person is, usually a two-letter country code. - seniority_tier (integer, 1 to 5) — Seniority read from the job title: 1 is the most senior. Matches seniority_label. - seniority_label (string) — The tier in words: "founder/C-suite" (1), "VP/Head/Director" (2), "Manager/Lead" (3), "Senior IC" (4), "Other/unknown" (5). - domain_rank (integer) — This 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_top5 (integer) — Always 1 in this list (1 = one of the 5 best-ranked people). Only top-5 people are returned. The seven outcomes (each entry in "results" has exactly one): | outcome | meaning | charged | what to do | | --- | --- | --- | --- | | enriched | Checked 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 credit | Use the contacts. | | already | This 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. | No | Use 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_grid | The domain is not in the MyLeadFox dataset, so it cannot be enriched. | No | Do not resend. | | no_contact_found | In 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. | | error | The 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_attempted | The call reached its time budget before this domain was checked. Message: "Not attempted — this call reached its time budget. Send these domains again." | No | Send these domains again, or send smaller batches. | | skipped_no_credit | Your 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. | No | Top up, then send them again. | More real responses (each with the request that produced it): #### 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: ```bash 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 (real): ```json { "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. Shape — one entry in results: ```json { "domain": "example-shop.com", "outcome": "enriched", "charged": true, "people": [ { "full_name": "", "first_name": "", "last_name": "", "title": "", "current_employer": "", "email": "", "email_status": "valid", "linkedin_url": "", "location": "", "seniority_tier": 1, "seniority_label": "founder/C-suite", "domain_rank": 1, "in_top5": 1 } ], "people_count": 8, "valid_email_count": 3 } ``` Refusals specific to this endpoint (plus the key and account refusals in "Errors"; a 422 has the {"detail": [...]} body): - invalid_request · 400 — No domains, or an entry that is not a valid domain. - too_many_domains · 400 — More than 100 domains after cleaning and de-duplication. - insufficient_credits · 402 — Pool "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 · 403 — The key's account is a team member whose owner has switched enrichment off (feature: "enrichment"). - 422 — domains that is not a list of strings or one string. ### Filters — the "filters" array of /search and /rows The API exposes the complete filter surface of the app. A filter is a JSON object with a "col" (a column name from "Columns" below) and, usually, a "kind"; the other fields depend on the kind. "col" is required: a filter without it, or with an unknown or unavailable column, is 400 invalid_request. A filter entry that is not a JSON object at all is a 422. "kind" is optional — when omitted, the server uses the column's default kind — but send it: the kind listed for each column in "Columns" is the one the app itself sends. A filter with nothing to filter on (an empty "value", no "min"/"max", an absent "values") is ignored rather than refused, so check your values: an ignored filter widens the result. Kinds and their fields: - text — "mode": "contains" (the default) | "starts" | "ends" | "exact" | "empty" | "not_empty" | "regex"; "value": the text. Matching is case-insensitive; % and _ in your value are matched literally. "exact" is a case-insensitive equality. "empty" / "not_empty" take no value. "regex" takes EITHER "parts" (a list of terms, below) OR "value" (a raw regular expression in RE2 syntax, matched case-insensitively; lookahead, lookbehind and other Perl extensions are not supported, and a pattern that does not compile is 400 invalid_request with the reason). - numeric — "op": "between" (the default) | "gte" | "lte" | "eq" | "neq" | "empty" | "not_empty"; bounds in "min" / "max". between uses min and max (send only one for an open-ended range); gte, eq and neq read "min"; lte reads "max". Add "includeNull": true to ALSO match shops where the value was never measured — without it, "products gte 50" leaves out every shop whose products were never counted. - dropdown — "values": a JSON array of exact values, e.g. ["US", "GB"]. Matches any of them. It must be an array: "values": "US" is 400 invalid_request. - tags — for multi-value text columns (categories, apps and the like). "mode": "any" (the default) | "all" | "none"; "values": a JSON array. any = has at least one, all = has every one, none = has none of them. tags is only valid on text columns. "mode": "branch" works on google_category only: the named Google category AND every category below it ("Apparel & Accessories > Clothing" also finds "... > Clothing > Dresses"); on any other column it is 400. - date — "from" and/or "to" as ISO date strings "YYYY-MM-DD". Both optional; "to" is INCLUSIVE (to "2024-12-31" includes 31 December). Numbers instead of strings are 400 invalid_request. The filterable date column is "registered". Combining filters (the filter level): - Each filter may carry "conn": "and" (the default) or "or". Any other value is 400 invalid_request — never a silent fall-back to "and". - The result is: (every "and" filter matches) AND (at least one "or" filter matches). A single "or" filter with no other "or" is simply a requirement. - "not": true inverts one filter. {"conn": "or", "not": true} is a negative alternative (one of the things that may be true), not a requirement. - Position in the list has no meaning: reordering filters cannot change the result. - The global "search" string is not a filter and has no connector: it is always ANDed onto the whole expression. ```json {"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} ]} ``` = shops in the US or GB, with at least 50 products, whose tagline does not contain "coffee". Terms INSIDE one regex filter ("parts") — a second, separate layer that combines words within ONE column: ```json {"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": "", "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. Every operator, with an example filter and the number of shops that exact example matched when the reference was generated (12-Oct-2026; the dataset changes daily): | kind | operator | example filter | matched | | --- | --- | --- | --- | | text | contains | {"kind": "text", "col": "name", "mode": "contains", "value": "coffee"} | 18,766 | | text | starts | {"kind": "text", "col": "domain", "mode": "starts", "value": "shop"} | 140,335 | | text | ends | {"kind": "text", "col": "domain", "mode": "ends", "value": ".shop"} | 83,831 | | text | exact | {"kind": "text", "col": "domain", "mode": "exact", "value": "gymshark.com"} | 1 | | text | empty | {"kind": "text", "col": "tagline", "mode": "empty"} | 4,087,868 | | text | not_empty | {"kind": "text", "col": "tagline", "mode": "not_empty"} | 4,832,067 | | text | regex | {"kind": "text", "col": "domain", "mode": "regex", "parts": [{"term": "coffee", "conn": "not"}, {"term": "shop"}]} | 24,284 | | numeric | between | {"kind": "numeric", "col": "products", "min": 10, "max": 100, "op": "between"} | 1,539,598 | | numeric | gte | {"kind": "numeric", "col": "products", "op": "gte", "min": 500} | 504,984 | | numeric | lte | {"kind": "numeric", "col": "products", "op": "lte", "max": 5} | 816,032 | | numeric | eq | {"kind": "numeric", "col": "products", "op": "eq", "min": 1} | 223,909 | | numeric | neq | {"kind": "numeric", "col": "products", "op": "neq", "min": 0} | 3,696,424 | | numeric | empty | {"kind": "numeric", "col": "products", "op": "empty"} | 5,030,260 | | numeric | not_empty | {"kind": "numeric", "col": "products", "op": "not_empty"} | 3,889,675 | | dropdown | values | {"kind": "dropdown", "col": "country", "values": ["US", "GB"]} | 1,645,791 | | tags | any | {"kind": "tags", "col": "categories", "mode": "any", "values": ["Shopping", "Shopping & Retail"]} | 136,474 | | tags | all | {"kind": "tags", "col": "categories", "mode": "all", "values": ["Apparel & Accessories > Clothing", "Apparel & Accessories > Clothing > T-Shirts"]} | 273 | | tags | none | {"kind": "tags", "col": "categories", "mode": "none", "values": ["Apparel & Accessories > Clothing > T-Shirts"]} | 8,754,419 | | date | from/to | {"kind": "date", "col": "registered", "from": "2020-01-01", "to": "2024-12-31"} | 2,588,667 | Options that were not part of the measured run (no count): | kind | option | example filter | what it does | | --- | --- | --- | --- | | date | from only | {"kind": "date", "col": "registered", "from": "2024-01-01"} | Open-ended: on or after 1 January 2024. | | date | to only | {"kind": "date", "col": "registered", "to": "2024-12-31"} | On or before 31 December 2024 — to is INCLUSIVE, so the last day counts. | | date | from + to | {"kind": "date", "col": "registered", "from": "2024-01-01", "to": "2024-12-31"} | The whole of 2024, both days included. | | numeric | includeNull | {"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. | | tags | branch | {"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. | Global text search: {"search": "coffee"} matches a case-insensitive substring across domain, name, tagline, description_preview, emails and categories ("coffee" also finds "coffeehouse"). It is heavier to serve than filters, which is why it carries a higher rate-limit weight. Sorting: {"sort": [{"col": "products", "dir": "desc"}, {"col": "domain"}]} — up to 8 keys, most significant first; "dir" is "asc" (default) or "desc". An unknown or unavailable column is 400 unknown_column (the API refuses instead of silently ignoring it). ### Columns — the 100 names accepted in "columns", in "sort" and as a filter "col" Send any of these in "columns" (or ["all"] for every one). Each is shown with the filter "kind" the app itself uses for that column — use the same kind in your filters. 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. Columns the app does not display are not available through the API under any request shape. Asking for one in "columns" or "sort" is 400 unknown_column; using one as a filter "col" is 400 invalid_request. 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. - Identity & status: domain (text), status (dropdown), status_reason (dropdown), actual_platform (dropdown), shop_active (dropdown), name (text), tagline (text), description_preview (text), myshopify (text) - Catalogue: products (numeric), collections (numeric), categories (tags), google_category (tags), shop_collections (text) - Location & market: city (text), state (text), country (dropdown), ships_to (numeric), age (numeric), registered (date), currency (dropdown) - Language: primary_lang (dropdown), primary_lang_name (dropdown), language (tags), language_names (tags), hreflang_count (numeric), hreflang_list (tags), is_multilingual (dropdown) - Contact: emails (text), has_email (dropdown), phone (text), has_phone (dropdown), contact_url (text) - Technology: theme (text), apps (tags), other_tech (tags) - Social: has_any_social (dropdown), social_count (numeric), instagram (text), facebook (text), tiktok (text), twitter (text), youtube (text), pinterest (text), linkedin (text) - Performance (lab): lh_perf (numeric), lh_seo (numeric), lh_a11y (numeric), lh_bp (numeric), lcp (numeric), fcp (numeric), si (numeric), tbt (numeric), cls (numeric), lh_perf_desk (numeric), lh_seo_desk (numeric), lh_a11y_desk (numeric), lh_bp_desk (numeric), lcp_desk (numeric), fcp_desk (numeric), si_desk (numeric), tbt_desk (numeric), cls_desk (numeric) - Performance (field / CrUX): crux_lcp (numeric), crux_inp (numeric), crux_fcp (numeric), crux_ttfb (numeric), crux_cls (numeric), crux_verdict (dropdown), psi_status (dropdown) - SEO: est_monthly_visits (numeric), seo_title (text), favicon (text), h1_count (numeric), og_title (text), og_description (text), og_image (text), og_type (dropdown), og_site_name (text), og_url (text), seo_completeness (numeric) - Record metadata: http_code (dropdown) - Other: mail_provider (dropdown), mail_category (dropdown), mail_host (text), ship_intl (dropdown), ship_partners (tags), ship_policy_url (text), pixels (tags), video (tags), crux_has_data (dropdown), seo_title_len (numeric), seo_meta (text), seo_meta_len (numeric), canonical (text), robots (text), h1_first (text), sd_types (tags), data_completeness (numeric), has_lighthouse_data (dropdown) ### Download History (where delivered rows are kept) - 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. ### Errors Every refusal from the API itself has this body: {"error": "", "message": "", ... extra fields}, plus the credits block where described above. Branch on "error" — the code is stable, the message is not. ```json {"error": "invalid_api_key", "message": "That API key is not valid."} ``` Every refusal, in the order the server checks. Rows whose code starts with "—" come from the web framework: their body is {"detail": ...}, not {"error", "message"}, and they carry no credits block. | error | HTTP | When | Credits block? | What to do | Extra fields | | --- | --- | --- | --- | --- | --- | | — (Method Not Allowed) | 405 | Any method but POST on an API address. | No | Use POST. Body: {"detail":"Method Not Allowed"}; header Allow: POST. | — | | — (Not Found) | 404 | POST to a path under https://go.myleadfox.com/api/v1 that does not exist (a typo in the endpoint name). | No | Use /search, /rows, /lookup or /enrich after https://go.myleadfox.com/api/v1. Body: {"detail":"Not Found"}. | — | | — (validation) | 422 | The 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. | No | Read detail[].loc (which field) and detail[].msg (what is wrong) and fix it. | — | | missing_api_key | 401 | No key: no Authorization: Bearer and no X-API-Key. A Basic or Digest Authorization header counts as no key. | No | Send Authorization: Bearer mlf_live_… | — | | invalid_api_key | 401 | The key is unknown, malformed, rotated or revoked, or its account was deleted. All give the same answer. | No | Copy the key again, or create a new one. | — | | ip_not_allowed | 403 | The key has an IP allowlist and the calling address is not on it. | No | Add the address to the key's allowlist, or call from an allowed address. | — | | account_inactive | 403 | The key's account, or its team owner's, is closed or must reset its password. | No | Log in to the app; contact support if the account is closed. | — | | no_active_plan | 402 | The paying account has no plan or pass running. | No | Choose a plan or a pass. | — | | free_trial_no_api | 403 | The paying account is on the trial package, which has no API access. | No | Choose a plan or a pass. | — | | account_frozen | 402 | A subscription payment failed. Applies from the next call. | No | Settle the invoice in the app. | since, invoice_id (when known) | | insufficient_scope | 403 | The key lacks the scope (permission) this call needs: search, download or enrich. | No | Create a key with that scope ticked under Scopes. | required_scope | | rate_limited | 429 | Over the per-key units, already 2 calls in flight on the key, or the server is busy. | Yes | Wait the number of seconds in the Retry-After header, then retry. | reason (per_key, too_many_concurrent, server_busy, fair_share), retry_after | | invalid_request | 400 | The 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.) | Yes | Fix the request; the message says what is wrong. | domain (for a bad domain) | | unknown_column | 400 | A requested column or sort key is unknown or hidden. | Yes | Use a name from the Columns list. | column | | too_many_domains | 400 | More than 100 domains after cleaning and de-duplication (/lookup, /enrich). | Yes | Split into batches of 100 or fewer. | limit, given | | search_not_found | 404 | The search_id does not resolve. | Yes | Copy the id from a grid URL again, or run /search. | search_id | | page_out_of_range | 404 | A /rows page past the last one (page 2 or later with no rows). | Yes | Stop paging: you have every page. | pages, requested | | insufficient_credits | 402 | The balance this call spends is 0 (see the partial-balance table), or another call spent it at the same moment. | Yes | Top up, or wait for your allowance to renew. | pool (search, download, enrichment), needed, remaining | | feature_disabled | 403 | The key's account is a team member whose owner has switched downloading or enrichment off. | Yes | Ask the team owner to switch it back on. | feature (downloading, enrichment) | | server_error | 500 | Something failed on our side. | Yes, if the key had been accepted | Retry. Message: "Something went wrong on our side. You were not charged. Please retry." | — | | key_management_forbidden | 403 | Not 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":…}}. | No | Ask the account owner for a key. | — | Real 422 bodies (loc says which field, msg what is wrong; the msg wording can change between versions): ```json {"detail": [{"type": "greater_than_equal", "loc": ["body", "page"], "msg": "Input should be greater than or equal to 1", "input": 0, "ctx": {"ge": 1}}]} {"detail": [{"type": "json_invalid", "loc": ["body", 0], "msg": "JSON decode error", "input": {}, "ctx": {"error": "Expecting value"}}]} {"detail": [{"type": "missing", "loc": ["body"], "msg": "Field required", "input": null}]} ``` ### Limits (rate limits) Limits are per KEY and the same on every plan. - Units per key: 60 per rolling 60-second window. - Calls in flight per key: 2 at the same time. - Plans: The same on every plan. What one call uses (its weight, in units): | Call | Weight | Most per minute on one key | | --- | --- | --- | | POST /search — filters only | 1 | 60 | | POST /search — with global text search | 15 | 4 | | POST /rows — filters only (with or without include_total) | 1 | 60 | | POST /rows — with global text search | 8 | 7 | | POST /rows — with global text search and include_total: true | 15 | 4 | | POST /lookup | 1 | 60 | | POST /enrich | 1 | 60 | "Global text search" means the body has a non-empty "search" string, or its search_id points to a saved search that has search text. Filters alone are always weight 1. Mixed calls add up by weight within the same 60 units. Units are not credits: units only pace how fast a key may call; credits are what a call spends. Over a limit: HTTP 429 rate_limited, never charged, with a Retry-After response header giving the whole number of seconds to wait (between 1 and 60). The body carries the same number as "retry_after" and a "reason": - per_key — this key used its units for the window. Wait Retry-After. - too_many_concurrent — this key already has 2 calls in flight. Wait for one to finish. - server_busy / fair_share — the service as a whole is busy. Wait Retry-After and try again. The API adds no response header of its own other than Retry-After on a 429. ### 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. ### Recipe: download a whole result set 1. POST /search with your filters (and search text, if any). Read "total", "pages" and "search_id". 2. For page = 1 to pages: POST /rows with {"search_id": "", "page": , "columns": [...], "sort": [...]}. Keep "limit" at 100. Send the same sort every time. Save each page's rows as soon as they arrive. 3. Stop after the last page, or when you get 404 page_out_of_range (its "pages" field is the real page count). 4. Check "truncated_by_credits" on every page: - 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. 5. On 429, wait the Retry-After number of seconds and repeat the same call. On a JSON 500 from the API ("error": "server_error"; never charged), wait a little and retry the same page a few times. On 402 with "error": "insufficient_credits", stop: the balance is 0 and nothing was delivered or charged — top up, then carry on from that same page. Any other refusal (for example 402 no_active_plan or account_frozen) will not be fixed by a top-up or a retry: read its "error" code in "Errors". On any reply that is NOT JSON (a gateway error or challenge page, whatever its status), do not retry: it did not come from the API and may follow a call that was delivered and charged — treat it like a timeout (step 6) and check Download History first. 6. If a /rows or /lookup call times out on YOUR side, do not simply retry: there is no idempotency key, so a retry is charged again. Check Download History in the app first — if the call is there, it was delivered and charged, and you can download it again there for free. 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. 7. With global text search, each /rows page has weight 8, so a key can fetch at most 7 such pages a minute; filters-only pages have weight 1. ### Recipe: 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.