Leads
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.
#The company object
{
"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.
#Search companies
GET /leads/search| Query param | Type | Required | Description |
|---|---|---|---|
q | string (max 200) | No | Full-text search across company name, description, and other indexed fields. |
country | string (exactly 2 letters) | No | ISO country code, e.g. GB. |
category | string (max 400) | No | One or more categories, comma-separated (e.g. Restaurant,CafeOrCoffeeShop). Matches a company's declared business type or industry exactly. |
provider | string (max 100) | No | Email provider segment, e.g. google, microsoft. |
tech | string (max 300) | No | One or more technologies, comma-separated (e.g. shopify,klaviyo), matches companies using any of them. |
channel | "email" \| "form" \| "linkedin" | No | Which 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" | No | Only 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" | No | Only companies publishing a Companies House registration number, in practice, UK-registered. |
hasVatNumber | "true" | No | Only companies publishing a VAT number. |
hiring | "true" | No | Only companies linking a careers page or stating they are hiring. |
hasContactForm | "true" | No | Only companies publishing a contact form URL. |
city | string (max 120) | No | Town or city, matched through the same text index as q. |
employeeBand | string (max 20) | No | One coarse size band, e.g. 11-50. Exact match, not a range. |
foundedFrom / foundedTo | integer (1200-2100) | No | Inclusive founded-year range. |
cursor | string (UUID) | No | Pass the previous response's nextCursor to fetch the next page. |
limit | integer (1-100) | No | Page size. Default 50. |
Parameters that fail validation return 400 bad_request with the message invalid_filters.
curl "https://app.warmerly.com/api/v1/leads/search?country=GB&category=Restaurant&tech=shopify&limit=25" \
-H "X-Api-Key: wmv_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"{
"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.
#Match count
GET /leads/countA 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.
curl "https://app.warmerly.com/api/v1/leads/count?country=GB&hiring=true" -H "X-Api-Key: wmv_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"{
"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 }
}| Field | Description |
|---|---|
count.value | The number to show. |
count.exact | true 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.capped | Only with exact: true. The count stopped at a hard row ceiling, so value is a floor and the real total is larger. |
count.exact: false | Expected 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).
#Database size
GET /leads/totalsHow many companies the lead database currently holds. The crawl adds rows continuously, so these figures move.
{ "emailable": 4677084, "total": 13914447, "approximate": true, "asOf": "2026-08-21T15:41:02.000Z" }| Field | Description |
|---|---|
emailable | Companies with a published contact email that are not suppressed, the population GET /leads/search draws from by default. |
total | All company records held, contactable or not. |
approximate | Always true. |
asOf | When 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.
#Export to CSV
POST /leads/exportExports 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:
| Field | Type | Required | Description |
|---|---|---|---|
domains | string[] (1-10,000, each up to 253 characters) | One of domains/selectAllMatching | Explicit list of domains to export. |
filters | object | No | Same 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. |
selectAllMatching | true | One of domains/selectAllMatching | Export 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.
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.csvReturns 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:
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, currenciesNew 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:
{
"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.
#Push to a campaign
POST /leads/to-campaignSame 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).
| Field | Type | Required | Description |
|---|---|---|---|
campaignId | string (UUID) | Yes | The campaign to add leads to. |
domains | string[] (1-10,000, each up to 253 characters) | One of domains/selectAllMatching | Explicit list of domains to add. |
filters | object | No | Same filter shape as export. Used with selectAllMatching; with domains only channel is read. |
selectAllMatching | true | One of domains/selectAllMatching | Add every row matching filters, up to 10,000 rows per request. |
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"]
}'{ "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.
#LinkedIn people search
GET /linkedin/search
GET /linkedin/search/parametersA 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 param | Type | Description |
|---|---|---|
query | string | Required. Keywords. A missing query returns 400 bad_request. |
cursor | string | Next page, from the previous response's cursor. |
locationIds | comma-separated | Location facet values. |
industryIds | comma-separated | Industry facet values. |
companyIds | comma-separated | Current company facet values. |
networkDistance | comma-separated integers | 1st / 2nd / 3rd degree. |
profileLanguage | comma-separated strings | Profile 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.
{
"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.
#Export quota
GET /leads/quotaReturns your current lead-export usage for the billing period. A pure read, checking it never debits quota.
curl https://app.warmerly.com/api/v1/leads/quota \
-H "X-Api-Key: wmv_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"{ "used": 480, "limit": 500, "remaining": 20 }#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.