# Leads

> API reference for the Warmerly lead database: search millions of crawled companies, export matching rows to CSV, and push them straight into a campaign.

Source: https://warmerly.com/docs/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

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

## Search companies

```text
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`.

```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](https://warmerly.com/docs/leads#the-company-object). The response holds rows only: the number of matches and how complete the records are come from [`GET /leads/count`](https://warmerly.com/docs/leads#match-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](https://warmerly.com/docs/leads#rate-limits--errors) below.

## 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](https://warmerly.com/docs/leads#search-companies), 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 }
}
```

| 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](https://warmerly.com/docs/leads#rate-limits--errors)). Invalid parameters return `400 bad_request` (`invalid_filters`).

## 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" }
```

| 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

```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:

| 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](https://warmerly.com/docs/leads#search-companies) (`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](https://warmerly.com/docs/leads#push-to-a-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.

## Push to a campaign

```text
POST /leads/to-campaign
```

Same selection shape as [export](https://warmerly.com/docs/leads#export-to-csv), 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](https://warmerly.com/docs/leads#export-to-csv). 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. |

```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](https://warmerly.com/docs/leads#export-to-csv)), `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

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

```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`](https://warmerly.com/docs/campaigns) and the [Email Finder](https://warmerly.com/docs/email-finder) are for.

## 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 }
```

## 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](https://warmerly.com/docs/errors) for the shared error envelope and your key's general per-minute limit.

## Each part of this reference has its own page (9)

- [The company object](https://warmerly.com/docs/leads/the-company-object)
- [Search companies](https://warmerly.com/docs/leads/search-companies)
- [Match count](https://warmerly.com/docs/leads/match-count)
- [Database size](https://warmerly.com/docs/leads/database-size)
- [Export to CSV](https://warmerly.com/docs/leads/export-to-csv)
- [Push to a campaign](https://warmerly.com/docs/leads/push-to-a-campaign)
- [LinkedIn people search](https://warmerly.com/docs/leads/linkedin-people-search)
- [Export quota](https://warmerly.com/docs/leads/export-quota)
- [Rate limits & errors](https://warmerly.com/docs/leads/rate-limits-and-errors)

## More in API reference

- [Email Finder](https://warmerly.com/docs/email-finder): API reference for the Warmerly Email Finder: find and verify a person's or company's email from a domain,…
- [Inbox](https://warmerly.com/docs/inbox): API reference for the Warmerly Inbox: list contacts, pull a unified email/LinkedIn conversation timeline,…
- [Suppression](https://warmerly.com/docs/suppression): API reference for the Warmerly suppression list: add addresses that must never be emailed in any campaign,…
