<!-- Leads — https://docs.warmerly.com/leads -->

# 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"],
  "contactEmail": "info@northwindtraders.com",
  "allEmails": ["info@northwindtraders.com", "sales@northwindtraders.com"],
  "crawledAt": "2026-08-10T04:22:00.000Z"
}
```

`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 (2-letter) | No | ISO country code, e.g. `GB`. |
| `category` | string | No | One or more categories, comma-separated (e.g. `Restaurant,CafeOrCoffeeShop`). |
| `provider` | string | No | Email provider segment, e.g. `google`, `microsoft`. |
| `tech` | string | 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. |
| `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 | No | One coarse size band, e.g. `11-50`. |
| `foundedFrom` / `foundedTo` | integer | 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`. |

```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",
      "companyName": "Northwind Traders",
      "country": "GB",
      "contactEmail": "info@northwindtraders.com",
      "techStack": ["shopify", "klaviyo"]
    }
  ],
  "nextCursor": "b1e6a2c4-9f3d-4a7e-8c1b-2d5f9a3e7c6b",
  "countLabel": "312",
  "coverage": {
    "sampleSize": 312,
    "exact": true,
    "email": 312,
    "phone": 190,
    "companyNumber": 141,
    "contactForm": 88,
    "linkedin": 97,
    "country": 305
  }
}
```

`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 these are counts; when it is `false` more rows match
than were measured and the figures are a `sampleSize`-row sample, not totals.
The whole table is never aggregated.

`countLabel` is an exact count below 1,000 matches, and the literal string `"1000+"`
above that, matching results are never counted with an unbounded `COUNT(*)` over the
full table. `nextCursor` is present whenever there may be more rows; keep paging with it
until a response returns fewer rows than `limit`.

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

## Match count

```
GET /leads/count
```

`countLabel` on a search response tops out at `"1000+"`, because counting inside a search
request would make the search slow for everyone. When you need a real 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](#search-companies), minus paging.

```bash
curl "https://app.warmerly.com/api/v1/leads/count?country=GB&hasEmail=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` is the same field-completeness breakdown search returns, measured over at most
1,000 matching rows (`exact: false` there means it is a sample, not totals).

## Database size

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

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

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `domains` | string[] (1-10,000) | One of `domains`/`selectAllMatching` | Explicit list of domains to export. |
| `filters` | object | No | Same filter shape as [search](#search-companies) (`q`, `country`, `category`, `provider`, `tech`, `channel`, `hasPhone`, and the firmographic filters). Booleans are JSON booleans here, not the strings the query string uses. Used with `selectAllMatching`. 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 monthly export allowance does. The `domains` list is limited to 10,000 entries per
request. [Push to campaign](#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`. The response also carries an
`X-Lead-Count` header with the exact row count in the file (CORS-exposed, so browser
`fetch()` can read it), this can be lower than what you selected, because suppression
and the "has an email" filter are re-applied at export time. 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
```

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

Errors: `400 bad_request` (`no_selection` if neither `domains` nor `selectAllMatching`
is set, `invalid_export_request` for a malformed body), `402 payment_required` if the
request would exceed your monthly export quota:

```json
{
  "error": {
    "code": "payment_required",
    "message": "Lead export limit reached: 480/500 used this month.",
    "details": null
  }
}
```

## Push to a campaign

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

Same selection shape as [export](#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) | One of `domains`/`selectAllMatching` | Explicit list of domains to add. |
| `filters` | object | No | Same filter shape as [search](#search-companies). Used with `selectAllMatching`. |
| `selectAllMatching` | `true` | One of `domains`/`selectAllMatching` | Add every row matching `filters`. |

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

`skipped` counts rows that were dropped because they had no contact email or were
already in the campaign's audience (deduplicated by lowercased email), those don't
count against your quota. Errors mirror [export](#export-to-csv)'s `400`/`402` cases,
plus `404 not_found` for an unknown `campaignId` and `402 billing_locked` if the
campaign's workspace has a billing issue.

## LinkedIn people search

```
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. |
| `cursor` | string | Next page, from the previous response's `cursor`. |
| `locationIds` | comma-separated integers | LinkedIn location facet IDs. |
| `industryIds` | comma-separated integers | LinkedIn industry facet IDs. |
| `companyIds` | comma-separated integers | LinkedIn company facet IDs. |
| `networkDistance` | comma-separated integers | 1st / 2nd / 3rd degree. |
| `profileLanguage` | comma-separated strings | Profile language codes. |

The facet filters take LinkedIn's own numeric IDs, not names.
`GET /linkedin/search/parameters?type=LOCATION|INDUSTRY|COMPANY&keywords=…` is the typeahead
that resolves a name into those IDs, returning `{ "items": [{ "id": "…", "title": "…" }] }`.

```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",
      "email": null,
      "phone": null
    }
  ],
  "cursor": "eyJwYWdlIjoyfQ"
}
```

**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` and `remaining`.

`email` and `phone` are populated only when the profile lists them publicly and they are
visible to your account, usually they are `null`, which is what
[`POST /campaigns/{id}/leads/find-emails`](https://docs.warmerly.com/campaigns) and the
[Email Finder](https://docs.warmerly.com/email-finder) are for.

## Export quota

```
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). Exceeding it returns
`429 too_many_requests`. See [Errors & Rate Limits](https://docs.warmerly.com/errors) for the shared error
envelope and your key's general per-minute limit.
