Less boo. More booked. Halloween offer: 30 days free on any paid plan. Claim 30 days freeEvery new account gets one free mailbox for warmup and replies. No domain to buy. See how it works

Skip to content

Leads

API reference12 min read9 sections

The Leads API searches Warmerly's crawled company database and lets you export matches to CSV or push them straight into a campaign's audience. It's scoped to your user account (not a project), search results and export quota are shared across every project you own. Suppressed companies are always excluded, at search time and again at export/push time.

All endpoints below require X-Api-Key and do not read X-Project-Id.

API reference

#The company object

json
{
  "id": "b1e6a2c4-9f3d-4a7e-8c1b-2d5f9a3e7c6b",
  "domain": "northwindtraders.com",
  "companyName": "Northwind Traders",
  "legalName": "Northwind Traders Ltd",
  "description": "Wholesale importer of specialty foods.",
  "websiteUrl": "https://northwindtraders.com",
  "country": "GB",
  "city": "Manchester",
  "region": "Greater Manchester",
  "streetAddress": "14 Dock Street",
  "postalCode": "M1 2AB",
  "phone": "+441611234567",
  "orgType": "Wholesaler",
  "industry": null,
  "techStack": ["shopify", "klaviyo"],
  "emailProvider": "google",
  "linkedinUrl": "https://www.linkedin.com/company/northwind-traders",
  "twitterUrl": null,
  "socialUrls": ["https://www.facebook.com/northwindtraders"],
  "inferredCategoryId": null,
  "companyNumber": null,
  "companyNumberJurisdiction": null,
  "vatNumber": null,
  "employeeCount": null,
  "employeeBand": null,
  "hiringSignal": false,
  "careersUrl": null,
  "contactFormUrl": null,
  "openingHours": null,
  "foundedYear": null,
  "currencies": null,
  "contactEmail": "info@northwindtraders.com",
  "allEmails": ["info@northwindtraders.com", "sales@northwindtraders.com"],
  "crawledAt": "2026-08-10T04:22:00.000Z"
}

Most fields can be null when the company does not publish them. inferredCategoryId is a display-only guess made from the company's own text when it declares no business type, and is null whenever the guess is not confident. It is not a declared fact and is never exported.

contactEmail is the first of allEmails, kept separate because export and push-to-campaign both key off a single address per company. Deliverability signals (MX/SPF/DMARC) are deliberately not exposed on this object, they're Warmerly's own outreach data, not customer-facing.

API reference

#Search companies

text
GET /leads/search
Query paramTypeRequiredDescription
qstring (max 200)NoFull-text search across company name, description, and other indexed fields.
countrystring (exactly 2 letters)NoISO country code, e.g. GB.
categorystring (max 400)NoOne or more categories, comma-separated (e.g. Restaurant,CafeOrCoffeeShop). Matches a company's declared business type or industry exactly.
providerstring (max 100)NoEmail provider segment, e.g. google, microsoft.
techstring (max 300)NoOne or more technologies, comma-separated (e.g. shopify,klaviyo), matches companies using any of them.
channel"email" \| "form" \| "linkedin"NoWhich contact channel a company must have. Defaults to email: a company with no published contact address is never returned unless you ask for a different channel. form returns companies publishing a contact form, linkedin those with a company page. There is no value that removes the requirement.
hasPhone"true"NoOnly companies publishing a telephone number. The flag filters (this one and the next four) accept "true" or "false"; "false" narrows nothing, and any other value returns 400 invalid_filters.
hasCompanyNumber"true"NoOnly companies publishing a Companies House registration number, in practice, UK-registered.
hasVatNumber"true"NoOnly companies publishing a VAT number.
hiring"true"NoOnly companies linking a careers page or stating they are hiring.
hasContactForm"true"NoOnly companies publishing a contact form URL.
citystring (max 120)NoTown or city, matched through the same text index as q.
employeeBandstring (max 20)NoOne coarse size band, e.g. 11-50. Exact match, not a range.
foundedFrom / foundedTointeger (1200-2100)NoInclusive founded-year range.
cursorstring (UUID)NoPass the previous response's nextCursor to fetch the next page.
limitinteger (1-100)NoPage size. Default 50.

Parameters that fail validation return 400 bad_request with the message invalid_filters.

bash
curl "https://app.warmerly.com/api/v1/leads/search?country=GB&category=Restaurant&tech=shopify&limit=25" \
  -H "X-Api-Key: wmv_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
json
{
  "rows": [
    { "id": "b1e6a2c4-9f3d-4a7e-8c1b-2d5f9a3e7c6b", "domain": "northwindtraders.com", "...": "every other field of the company object" }
  ],
  "nextCursor": "b1e6a2c4-9f3d-4a7e-8c1b-2d5f9a3e7c6b"
}

Each row is the full company object. The response holds rows only: the number of matches and how complete the records are come from GET /leads/count. nextCursor is null on the last page; keep paging with it until it is null. With channel=form or channel=linkedin a row's contactEmail can be null.

This endpoint has its own per-user throttle, separate from your API key's per-minute rate limit, see Rate limits below.

API reference

#Match count

text
GET /leads/count

A search response does not include a match count, because counting inside a search request would make the search slow for everyone. When you need a number for a segment, to size an export, or to show "312 matches" in a UI, ask for it separately. Takes exactly the same filter params as search, minus paging.

bash
curl "https://app.warmerly.com/api/v1/leads/count?country=GB&hiring=true" -H "X-Api-Key: wmv_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
json
{
  "count": { "value": 118520, "exact": true, "capped": false, "wasCountable": true },
  "coverage": { "sampleSize": 1000, "exact": false, "email": 1000, "phone": 612, "companyNumber": 455, "contactForm": 288, "linkedin": 331, "country": 1000 }
}
FieldDescription
count.valueThe number to show.
count.exacttrue when the rows were actually counted, false when the figure is a planner estimate. Word these differently: estimates on this table run low, so presenting one as a count understates most segments several-fold.
count.cappedOnly with exact: true. The count stopped at a hard row ceiling, so value is a floor and the real total is larger.
count.exact: falseExpected on broad segments. A narrow filter gets counted, a country-wide one gets estimated, and an estimate is the intended answer rather than a failure.

coverage reports how many of the matching companies carry each high-value field, so you can see how complete a segment is before you export it. It is measured over at most 1,000 matching rows: when exact is true the sample was the entire result set and the figures are counts; when it is false more rows match than were measured and the figures are a sampleSize-row sample, not totals.

GET /leads/count has its own per-user throttle of 120 requests per 5 minutes, separate from the search throttle (see Rate limits). Invalid parameters return 400 bad_request (invalid_filters).

API reference

#Database size

text
GET /leads/totals

How many companies the lead database currently holds. The crawl adds rows continuously, so these figures move.

json
{ "emailable": 4677084, "total": 13914447, "approximate": true, "asOf": "2026-08-21T15:41:02.000Z" }
FieldDescription
emailableCompanies with a published contact email that are not suppressed, the population GET /leads/search draws from by default.
totalAll company records held, contactable or not.
approximateAlways true.
asOfWhen the snapshot was taken.

Both figures are estimates, derived from the database's own planner statistics rather than by counting rows, and may be a few minutes behind on a table this size. Treat them as "about N", never as an exact count. Either figure can be null if the statistic is unavailable.

API reference

#Export to CSV

text
POST /leads/export

Exports matching companies as a CSV file (download, not JSON). Debits your monthly lead export quota by the number of rows actually delivered, not the number you asked for. The debit happens as each batch of rows is written, so a download you cancel part way is charged for the rows already sent.

Select rows with one of two shapes in the request body:

FieldTypeRequiredDescription
domainsstring[] (1-10,000, each up to 253 characters)One of domains/selectAllMatchingExplicit list of domains to export.
filtersobjectNoSame filter shape as search (q, country, city, category, provider, tech, channel, hasPhone, and the firmographic filters). Booleans are JSON booleans here, not the strings the query string uses, and tech is an array of up to 20 strings. Used with selectAllMatching; with an explicit domains list only channel is read from it. The channel default applies here too: an export never contains a company you cannot contact by email unless you named another channel.
selectAllMatchingtrueOne of domains/selectAllMatchingExport every row matching filters, re-resolved server-side.

The CSV export streams and has no per-request row cap: it stops when the rows run out or your spendable export allowance does (the monthly remainder plus any credit-bought block), so "select all matching" with a small allowance returns a shorter file rather than an error. The domains list is limited to 10,000 entries per request. Push to campaign is capped at 10,000 rows per request.

bash
curl -X POST https://app.warmerly.com/api/v1/leads/export \
  -H "X-Api-Key: wmv_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{ "filters": { "country": "GB", "tech": ["shopify"] }, "selectAllMatching": true }' \
  -o leads.csv

Returns 200 with content-type: text/csv, and the file starts with a UTF-8 byte-order mark. Because the file is streamed there is no row-count header. Count the rows you receive: the count can be lower than what you selected, because suppression and the contact-channel requirement are re-applied at export time. When the budget for the request is finite, the response carries an X-Lead-Budget header (CORS-exposed) with the most rows the file can hold. CSV columns, in order:

text
domain, company_name, legal_name, website_url, country, city, category, tech_stack,
email, all_emails, phone, street_address, region, postal_code, linkedin_url,
twitter_url, other_social, mail_provider, description, last_checked,
company_number, company_number_jurisdiction, vat_number, employee_count,
employee_band, hiring, careers_url, contact_form_url, founded_year,
opening_hours, currencies

New columns may be appended in future; existing columns won't be reordered or removed.

Errors: 400 bad_request with the message invalid_export_request for a malformed body, including a body that sets neither domains nor selectAllMatching. 402 quota_exhausted if your export allowance is already used up when the request starts:

json
{
  "error": {
    "code": "quota_exhausted",
    "message": "You've used 500 of 500 lead exports this month. Upgrade your plan to continue.",
    "details": {
      "label": "lead exports",
      "used": 500,
      "limit": 500,
      "topUpUrl": null,
      "upgradeUrl": "/settings/billing"
    }
  }
}

The message varies: when credits can cover the shortfall it offers them, and details.topUp and details.topUpUrl are set.

API reference

#Push to a campaign

text
POST /leads/to-campaign

Same selection shape as export, plus a target campaign. It adds matching companies directly to a campaign's audience instead of downloading a file and debits the same monthly export quota as CSV export (the two share one meter).

FieldTypeRequiredDescription
campaignIdstring (UUID)YesThe campaign to add leads to.
domainsstring[] (1-10,000, each up to 253 characters)One of domains/selectAllMatchingExplicit list of domains to add.
filtersobjectNoSame filter shape as export. Used with selectAllMatching; with domains only channel is read.
selectAllMatchingtrueOne of domains/selectAllMatchingAdd every row matching filters, up to 10,000 rows per request.
bash
curl -X POST https://app.warmerly.com/api/v1/leads/to-campaign \
  -H "X-Api-Key: wmv_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "campaignId": "3a6f9e2e-4b7a-4e9a-9b1d-6e3a2f8c1a10",
    "domains": ["northwindtraders.com"]
  }'
json
{ "added": 1, "skipped": 0, "rejected": 0, "noEmail": 0 }

added is the number of leads written. skipped counts rows already in the campaign's audience (deduplicated by lowercased email). rejected counts addresses that Warmerly's free checks proved invalid, which are never written. noEmail counts companies selected that have no contact email (for example under channel=form); a campaign lead is an email address, so they cannot be added. Only added counts against your export quota. Only the first 10,000 rows of a selection are used. If none of the rows has an email, the response is { "added": 0, "skipped": 0, "noEmail": N }.

Errors: 400 bad_request (invalid_push_request for a malformed body, including no selection; too_many_leads), 402 quota_exhausted (same shape as export), 402 billing_locked if the campaign's workspace has a billing issue, 402 campaign_lead_limit if the campaign would go over its lead ceiling, 403 forbidden for a campaign in a workspace you are not a member of, and 404 not_found for an unknown campaignId.

API reference
text
GET /linkedin/search
GET /linkedin/search/parameters

A different database to the company search above: this queries LinkedIn through your own connected LinkedIn account, so it needs a paid LinkedIn slot and a connected account (400 no_linkedin_account otherwise).

Query paramTypeDescription
querystringRequired. Keywords. A missing query returns 400 bad_request.
cursorstringNext page, from the previous response's cursor.
locationIdscomma-separatedLocation facet values.
industryIdscomma-separatedIndustry facet values.
companyIdscomma-separatedCurrent company facet values.
networkDistancecomma-separated integers1st / 2nd / 3rd degree.
profileLanguagecomma-separated stringsProfile language codes.

Each page holds up to 15 people. What the facet filters take depends on how your LinkedIn account is connected. On an account connected through Unipile they are LinkedIn's own numeric IDs. On an account connected through LinkupAPI they are plain text names (for example London), only the first value of each of locationIds, industryIds and companyIds is used, and profileLanguage is not applied. GET /linkedin/search/parameters?type=LOCATION|INDUSTRY|COMPANY&keywords=… is the typeahead that returns { "items": [{ "id": "…", "title": "…" }] }. A missing or invalid type returns 400 bad_request (invalid_type); an empty keywords returns an empty items list. On a LinkupAPI account the single suggestion is the text you typed.

json
{
  "items": [
    {
      "id": "ACoAAA…",
      "public_identifier": "samexample",
      "name": "Sam Example",
      "headline": "Head of Growth at Example Ltd",
      "location": "London, United Kingdom",
      "company_name": "Example Ltd",
      "profile_url": "https://www.linkedin.com/in/samexample",
      "network_distance": "DISTANCE_2"
    }
  ],
  "cursor": "15"
}

Fields a profile does not have (headline, location, company_name, email, phone and so on) are omitted rather than set to null. cursor is omitted on the last page.

One LinkedIn lookup is charged per search page, not per profile returned, paging with cursor issues a fresh upstream call, which is the unit of real cost. A search that fails or is rejected costs nothing: the allowance is debited only after results come back. Out of allowance returns 402 payment_required with used, limit, remaining, label and upgradeUrl in details; this check runs before the query check. If the search itself fails you get 400 bad_request (search_failed), and 400 no_linkedin_account also covers a session that has expired.

email and phone are present only when the profile lists them publicly and they are visible to your account, usually they are absent, which is what POST /campaigns/{id}/leads/find-emails and the Email Finder are for.

API reference

#Export quota

text
GET /leads/quota

Returns your current lead-export usage for the billing period. A pure read, checking it never debits quota.

bash
curl https://app.warmerly.com/api/v1/leads/quota \
  -H "X-Api-Key: wmv_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
json
{ "used": 480, "limit": 500, "remaining": 20 }
API reference

#Rate limits & errors

GET /leads/search has its own throttle on top of your API key's per-minute limit: 120 requests per 5 minutes, per user (not per key or IP), and GET /leads/count has the same limit on its own counter. Exceeding either returns 429 too_many_requests. See Errors & Rate Limits for the shared error envelope and your key's general per-minute limit.

Was this page helpful?