MyLeadFox

Developer API

The complete filter surface, not a reduced subset

Five filter kinds, 19 operators, and/or connectors, per-filter negation, global search and eight sort keys. Anything you can build in the interface you can express in a request.

Quickstart

Base URL https://go.myleadfox.com/api/v1. Authenticate with a bearer token; an X-API-Key header is accepted for tools that expose a custom-header field more easily than an Authorization builder.

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"]}]}'

Never ship a key in client-side code.

Anything in a browser bundle is public however it is obfuscated. Call the API from your server. Entitlement is re-checked on every call, so a revoked key stops working on the very next request.

Playground

Build a real request

Compose a filter expression, pick columns, and take the generated cURL straight to your terminal.

Counts and a summary for a filter set. It never returns rows. Returns a search_id you can page through afterwards, so you size a segment before spending download credits on it.

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"
      ]
    }
  ]
}'

Reference

Four endpoints

POST/api/v1/rows

Rows, one page of shops

The only endpoint that returns row data. Any page is directly addressable: page 500 needs no walk through 1–499. If your balance is smaller than the page you receive as many rows as you can afford and truncated_by_credits is true: a partial balance is spent, never stranded.

Scope download1 lead per row DELIVERED, never rounded up to a page

Body fields

FieldTypeRequiredLimitMeaning
search_idstringoptionalwins over `filters` when both are sentPage a search the interface or /search already minted, without re-sending its filters.
filtersarray of objectsoptionalat most 64 filtersThe same filter surface as /search — see that endpoint and the operator table below.
searchstringoptionalno length boundGlobal substring search across the same six columns as /search — domain, name, tagline, description_preview, emails and categories.
sortarray of objectsoptionalat most 8 keysOrdering, most significant first. Stable across pages, so paging cannot repeat or skip a row.
pageintegerdefaults to 11 to 200,000Any page is directly addressable — page 500 costs the same as page 1 and needs no walk through the 499 before it.
limitintegerdefaults to 1001 to 100Rows on this page. Ask for fewer and you are charged fewer: you pay per row DELIVERED, never rounded up to a page.
columnsarray of stringsoptional — every column when omittedat most 200 namesRestrict the response to the columns you need. It changes the payload, not the price.
include_totalbooleandefaults to false—Off: no count runs and the response omits total, at the ordinary metering weight. On: a live exact total at the heavier weight. The cost of knowing is yours to choose.

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"]
}'

200 response

{
  "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"
    }
  ],
  "truncated_by_credits": false,
  "credits": {
    "search_remaining": 4997,
    "download_remaining": 199997,
    "enrich_remaining": 500,
    "charged": 3,
    "charged_unit": "leads"
  }
}

What comes back

KeyTypeMeaning
rowsarray of objectsThe page of shops. Keys are column ids — the same ids the filters use.
page / page_sizeintegerWhich page this is, and how many rows it holds.
totalinteger or absentPresent only when you asked for it with include_total.
search_idstringThe slug for this filter set, for the next page.
creditsobjectBalances after the call, with charged in LEADS — one per row delivered.

What this endpoint refuses

ResponseWhen
insufficient_credits · 402Not enough leads for the rows requested. The response names the pool, what was needed and what remains. Lower limit and the call succeeds.
search_not_found · 404The search_id does not exist. Re-run /search to mint a new one.
page_out_of_range · 404Past the last page. The real page count travels WITH the refusal, as pages and requested, so a paging loop can stop on it rather than guess.
invalid_request · 400page above 200,000, limit above 100, more than 200 column names, or a filter set past its bounds.

No error is ever charged. The full catalogue is further down this page.

POST/api/v1/lookup

Lookup: fetch shops by domain

Fetch rows for up to 100 domains per call. Three lists come back and they mean different things: rows were found and charged; missing are not in MyLeadFox and will still be missing tomorrow; withheld_for_credits are in MyLeadFox but past your balance: free, so top up and ask again.

Scope downloadcharged per domain FOUND: misses are free and named

Body fields

FieldTypeRequiredLimitMeaning
domainsarray of strings OR a comma-separated stringrequiredat most 100 domains per callBoth shapes are accepted deliberately: a JSON array is what a machine sends, "a.com, b.com" is what a person pastes. Unknown domains are simply absent from the response — they are not an error and they are not charged.
columnsarray of stringsoptional — every column when omittedat most 200 namesRestrict the response to the columns you need.

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", "not-a-real-shop.example"],
  "columns": ["domain", "name", "country", "products"]
}'

200 response

{
  "returned": 1,
  "rows": [
    {
      "domain": "gymshark.com",
      "name": "Gymshark",
      "country": "GB",
      "products": 1284
    }
  ],
  "missing": ["not-a-real-shop.example"],
  "withheld_for_credits": [],
  "credits": {
    "download_remaining": 199996,
    "charged": 1,
    "charged_unit": "leads"
  }
}

What comes back

KeyTypeMeaning
rowsarray of objectsOne object per domain FOUND, in the order you asked. A domain we do not hold is omitted.
found / requestedintegerHow many of your domains we hold. The difference is what was not found, and what was not charged.
creditsobjectBalances after the call. charged equals found, never requested.

What this endpoint refuses

ResponseWhen
too_many_domains · 400More than 100 domains in one call. The ceiling exists so a response never exceeds about 2 MB.
invalid_request · 400An empty list, or a domain longer than 253 characters.
insufficient_credits · 402Fewer leads left than domains found. Nothing is returned and nothing is charged.

No error is ever charged. The full catalogue is further down this page.

POST/api/v1/enrich

Enrich: find contacts

Find contacts for domains already in the dataset. Every domain returns in exactly one of four buckets and only the first charges. Enrichment is synchronous: each domain is checked in real time as the request runs, so keep batches small.

Scope enrichonly the `enriched` outcome charges: the other five are free

Body fields

FieldTypeRequiredLimitMeaning
domainsarray of strings OR a comma-separated stringrequiredat most 100 domains per callThe shops to find contacts for. Same two accepted shapes as /lookup. Each domain lands in one of the outcomes below, and only one of them charges.

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"]
}'

200 response

{
  "results": [
    {
      "domain": "gymshark.com",
      "outcome": "enriched",
      "emails": ["hello@gymshark.com"],
      "phone": null,
      "mailbox_provider": "Google Workspace",
      "mailbox_category": "business"
    }
  ],
  "credits": {
    "enrich_remaining": 499,
    "charged": 1,
    "charged_unit": "enrichments"
  }
}

What comes back

KeyTypeMeaning
resultsarray of objectsOne entry per domain, each carrying its outcome and any contacts found.
outcomestringWhich of the buckets below this domain fell into. Read this before the contacts — it says whether an empty result means "none exist" or "we did not look".
creditsobjectBalances after the call. charged counts only the domains that actually produced a contact.

What this endpoint refuses

ResponseWhen
insufficient_credits · 402No enrichment credit left. Nothing is attempted and nothing is charged.
too_many_domains · 400More than 100 domains in one call.
feature_disabled · 403You are a team member whose owner has turned enrichment off for you.

No error is ever charged. The full catalogue is further down this page.

The six outcomes: only the first charges

outcomeMeaningCharged
enrichedWe checked in real time and found contacts.Yes: 1 credit
alreadyYou enriched this domain recently and still hold it; contacts come from cache.No
not_in_gridThe domain is not in the MyLeadFox dataset, so we will not enrich it.No
no_contact_foundIt is in the dataset, we checked, and there was nobody to return.No
skipped_no_creditYour balance ran out mid-batch.No
not_attemptedThe call reached its time budget: send those domains again.No

Filters

Every operator, with live match counts

Each row below was executed against the real dataset. The count is what it returned.

Combining 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.

{"kind": "text", "col": "tagline", "mode": "regex", "parts": [
  {"term": "coffee"},
  {"term": "roaster", "conn": "or"},
  {"term": "wholesale", "conn": "not"},
  {"term": "beans"}
]}
  • Terms run together with AND by default.
  • "conn": "or" on a term starts a NEW alternative group. Everything after it belongs to that group until the next or.
  • "conn": "not" negates the term that FOLLOWS it. Above, that is beans — so the filter reads: (tagline mentions coffee) OR (mentions roaster and NOT beans).
  • At most 25 terms, each at most 200 characters.
  • A regex filter can still be an or at the filter level. The two layers never interfere: one decides what this column means, the other decides how that verdict joins the rest.

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.

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"]}136,474
tagsall{"kind":"tags","col":"categories","mode":"all","values":["Apparel & Accessories > Clothing"]}273
tagsnone{"kind":"tags","col":"categories","mode":"none","values":["Apparel & Accessories > Clothing > T-Shirts"]}8,754,419
datefrom{"kind":"date","col":"registered","from":"2024-01-01"}open-ended
dateto{"kind":"date","col":"registered","to":"2024-12-31"}inclusive
datefrom + to{"kind":"date","col":"registered","from":"2024-01-01","to":"2024-12-31"}the whole year
numericincludeNull{"kind":"numeric","col":"products","op":"gte","min":50,"includeNull":true}adds never-measured rows
datefrom/to{"kind":"date","col":"registered","from":"2020-01-01","to":"2024-12-31"}2,427,890

Global text search

{"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. 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.

Limits

Published, and returned on every response

Breaching a limit returns 429 with a Retry-After header, and is never charged.

Rate limits

Per key
60 weighted units / minute
Per key, concurrent
2 requests

What a call costs

Not every call costs the same unit. A plain filtered query is the cheapest; a global text search costs more. You never have to guess: every response carries your remaining allowance in its headers, and a 429 carries Retry-After.

Typical speed

Filtered page (dropdown filters)
Fast
Filtered page, deep (page 100)
Fast
Global text search: first call
Slower
Global text search: later pages
Quick

Give your client a generous timeout rather than a tight one.

Pagination: 100 rows per page maximum, and any page is directly addressable: page 500 needs no walk through 1–499. Ask for fewer with limit and you are charged fewer.

Errors

Seventeen codes, none of them charged

Branch on the error code. It is stable; the message is not.

{
  "error": "insufficient_credits",
  "message": "Not enough download credits for this page.",
  "pool": "download",
  "needed": 100,
  "remaining": 42
}
CodeHTTPMeaning
missing_api_key401No key was sent.
invalid_api_key401Unknown, malformed, rotated or revoked: deliberately indistinguishable.
ip_not_allowed403The key has an IP allowlist and you are not on it.
insufficient_scope403The key lacks the scope this call needs.
no_active_plan402The account has no plan or pass running. Choose one to use the API.
account_inactive403The account is closed or locked.
account_frozen402A subscription payment failed. Settle the invoice.
rate_limited429Too fast, or the server is busy. Honour Retry-After.
invalid_request400The request body is wrong.
unknown_column400You asked for a column that does not exist.
search_not_found404No saved search has that id.
page_out_of_range404That page does not exist; `pages` tells you the real count.
too_many_domains400Over the per-call domain ceiling.
insufficient_credits402Not enough credits in that pool.
feature_disabled403Your team owner turned this off for your account.
key_management_forbidden403Only the account owner manages keys.
server_error500Our fault. You were not charged. Retry.

Extra fields worth branching on

CodeExtra fields
rate_limitedretry_after (seconds), reason (per_key · server_busy · fair_share · too_many_concurrent)
page_out_of_rangepages, requested
too_many_domainslimit, given
insufficient_creditspool, needed, remaining
unknown_columncolumn
insufficient_scoperequired_scope
account_frozensince, invoice_id

No error is ever charged.

A mistake in your request is a 4xx and costs nothing. A failure on our side is a 500 and costs nothing. A rate-limit breach is a 429 and costs nothing. Abuse is stopped by the rate limits, not by billing you for mistakes.

Columns

All 99 queryable columns

The columns field accepts any name below, or ["all"] for every one. Asking for a name that is not on this list is a 400 unknown_column: nothing is silently dropped.

A missing value is not a zero. Where a column is absent from a row we have never measured it, which is a different fact from having measured it as nothing. The API returns null in that case and never substitutes 0 or an empty string, so the distinction survives all the way to your code. Treat null as unknown, not as evidence of absence.

Two notes specific to est_monthly_visits: it is a third-party estimate of monthly traffic rather than a figure we measure, and it carries null 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.

Identity & status (9)

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

Catalogue (4)

  • productsnumeric
  • collectionsnumeric
  • categoriestags
  • 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

Questions

API frequently asked

How do I authenticate?

Send your key as a bearer token: `Authorization: Bearer mlf_live_…`. An `X-API-Key` header is accepted as an alternative for tools that expose a custom-header field more easily than an Authorization builder. Entitlement is re-checked on every call, so a plan change or an unpaid invoice stops a key working on the very next request.

What are the rate limits, exactly?

60 weighted units per minute per key, and 2 concurrent requests per key. Not every call costs the same unit: a plain filtered query is the cheapest and a global text search costs more. You never have to guess, because every response returns your remaining allowance in its headers. Breaching a limit returns 429 with a Retry-After header and is never charged.

Am I ever charged for an error?

No. A mistake in your request is a 4xx and costs nothing, a failure on our side is a 500 and costs nothing, and a rate-limit breach is a 429 and costs nothing. Abuse is stopped by the rate limits, not by billing you for mistakes.

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

The whole surface. Five filter kinds, nineteen operators, and/or connectors, per-filter negation with not: true, global search, and sorting on up to eight keys: everything the application offers. Anything you can build in the interface you can express in a request.

How does pagination work?

Up to 100 rows per page, and any page is directly addressable: page 500 needs no walk through pages 1 to 499. Ask for fewer with limit and you are charged fewer. A search_id lets you page a result set without re-sending the filters.

How fast is it?

Filtered queries are fast, including deep pages: asking for page 500 costs no more than page 1. A global text search is slower on its first call and quicker on the pages after it, because the first call does the work the rest reuse. Give your client a generous timeout rather than a tight one.

What happens if my balance runs out mid-page?

You receive as many rows as you can afford and truncated_by_credits comes back true. A partial balance is spent, never stranded. On lookup, domains past your balance return in withheld_for_credits: free, and still there when you top up.

Is the API included with a pass?

Yes. Every plan and every pass includes the API, drawing on the same allowance as the web app. A key on an account with no plan or pass running returns no_active_plan with a 402.

Get an API key

Included on Starter and Pro at no extra cost, drawing from the same allowance as the web app.