---
name: freshleads
description: Find brand-new businesses the day they appear online, filter them into a lead list, and push each day's new leads to a webhook. Use when the user wants fresh leads, new companies, newly registered domains, new Product Hunt or App Store launches, an ICP-filtered prospect list, a daily lead feed, cold-outreach targets, or asks about FreshLeads, freshleads.cc, their lead filters, or their lead webhook.
version: 1.0.0
homepage: https://freshleads.cc
api: https://freshleads.cc/api/v1
openapi: https://freshleads.cc/api/v1/openapi.json
---

# FreshLeads

FreshLeads watches the internet for businesses that did not exist yesterday: new
domains, new Product Hunt launches, new apps, new Reddit posts. It reads each
site, extracts the contacts, scores it against the user's description of their
ideal customer, and keeps the matches.

You can do four things with this API:

1. **Filters** — describe the businesses the user wants.
2. **Leads** — read, search, tag, keep and export the matches.
3. **Webhooks** — get each day's new leads pushed to a server, as a file.
4. **Deliveries** — see what was pushed, and fetch the file again.

Base URL: `https://freshleads.cc/api/v1`
Machine-readable spec: `https://freshleads.cc/api/v1/openapi.json`

---

## 1. Get a key

Every call needs `Authorization: Bearer fl_live_…`.

If the user has not given you a key, get one. It takes three calls and one code
from the user's inbox.

```bash
# 1. FreshLeads emails a 6-digit code to the user.
curl -s https://freshleads.cc/api/auth/request \
  -H 'content-type: application/json' \
  -d '{"email":"user@example.com"}'

# 2. Ask the user for the code. Trade it for a session (valid 30 days).
SESSION=$(curl -s https://freshleads.cc/api/auth/verify \
  -H 'content-type: application/json' \
  -d '{"email":"user@example.com","code":"123456"}' | jq -r .session)

# 3. Trade the session for a key. THIS is the credential you keep.
curl -s -X POST https://freshleads.cc/api/v1/api-keys \
  -H "authorization: Bearer $SESSION" \
  -H 'content-type: application/json' \
  -d '{"name":"my agent","scopes":["leads:read","filters:read","filters:write","webhooks:read","webhooks:write"]}'
```

The answer carries `data.api_key.secret`. **It is shown once.** Store it. Never
print it into a log, a commit or a chat transcript.

A key cannot create another key. That is on purpose: a stolen key cannot make
itself permanent.

### Scopes

| Scope | What it opens |
|---|---|
| `account:read` | `GET /me` |
| `filters:read` | Read filters |
| `filters:write` | Create, change and delete filters |
| `leads:read` | Read and export leads |
| `leads:write` | Set the state, the tags and the note of a lead |
| `webhooks:read` | Read subscriptions and deliveries |
| `webhooks:write` | Create, change, test and delete subscriptions |

Ask for the fewest scopes the task needs. A key without a scope gets `403
insufficient_scope`, and the message names the scope it wanted.

---

## 2. The rules that hold everywhere

**Success** is `{"data": …}`. A list adds `{"meta": …}`.

```json
{ "data": { "leads": [ … ] },
  "meta": { "count": 50, "total": 812, "limit": 50, "has_more": true, "next_cursor": "eyJvIjo1MH0" } }
```

**Failure** is `{"error": {"code", "message"}}`. Branch on `code`; show
`message` to the user.

```json
{ "error": { "code": "plan_limit", "message": "Your plan allows 1 filter(s)." } }
```

**Paging.** Pass `limit` (1–200, default 50). Then pass `meta.next_cursor` back
as `cursor` until it is `null`. The cursor is opaque. Never build one, never
parse one.

```bash
cursor=""
while :; do
  page=$(curl -s -H "authorization: Bearer $KEY" \
    "https://freshleads.cc/api/v1/leads?limit=200&cursor=$cursor")
  echo "$page" | jq -c '.data.leads[]'
  cursor=$(echo "$page" | jq -r '.meta.next_cursor // empty')
  [ -z "$cursor" ] && break
done
```

**Methods.** `GET` reads. `POST` creates. `PATCH` changes some fields. `PUT`
replaces one value. `DELETE` removes, and answers `204` with no body. A wrong
method gets `405` and an `Allow` header that names the right ones.

**Ids.** A lead's id is its domain: `acme.com`. A filter's is `f_…`, a
subscription's `wh_…`, a delivery's `dl_…`, a key's `k_…`.

**Rate limit.** About 20 requests a second, account-wide. Over it you get `429`.
Back off and retry; the data is not going anywhere.

---

## 3. Quickstart

```bash
KEY=fl_live_…
API=https://freshleads.cc/api/v1

# What plan is this account on, and what may it do?
curl -s -H "authorization: Bearer $KEY" $API/me | jq .data.account.limits

# Say what a good lead looks like.
curl -s -X POST $API/filters -H "authorization: Bearer $KEY" \
  -H 'content-type: application/json' -d '{
    "name": "US dental clinics",
    "spec": {
      "ai": { "icp": "Newly opened dental clinics and orthodontic practices in the United States. Not dental software, not suppliers, not consultants.", "min_score": 70 },
      "fields": { "countries": ["US"], "require": ["email"] }
    }
  }' | jq .data.filter

# Read today's matches.
curl -s -H "authorization: Bearer $KEY" "$API/leads?days=1&limit=20" | jq '.data.leads[] | {domain, name, score: .ai.score, email: .contacts.emails[0]}'

# Get every day's new leads pushed to your server.
curl -s -X POST $API/webhooks -H "authorization: Bearer $KEY" \
  -H 'content-type: application/json' \
  -d '{"url":"https://example.com/hooks/freshleads","filter_id":"*","format":"jsonl","hour_utc":13}'
```

---

## 4. Endpoints

| Method & path | Scope | What it does |
|---|---|---|
| `GET /meta` | — | Scopes, plans, sources, webhook options. Read it once at the start. |
| `GET /status` | — | How many businesses the engine found today. |
| `GET /openapi.json` | — | The full machine-readable spec. |
| `GET /me` | `account:read` | The account, its plan and its limits. |
| `GET /api-keys` | session | Every key. Secrets are never returned. |
| `POST /api-keys` | session | Make a key. |
| `DELETE /api-keys/{id}` | session | Revoke a key at once. |
| `GET /filters` | `filters:read` | Every filter. |
| `POST /filters` | `filters:write` | Add a filter. |
| `GET /filters/{id}` | `filters:read` | One filter. |
| `PATCH /filters/{id}` | `filters:write` | Change some fields. |
| `DELETE /filters/{id}` | `filters:write` | Delete a filter. |
| `POST /filters/{id}/keywords` | `filters:write` | Rebuild the keyword pre-filter from the description. |
| `GET /leads` | `leads:read` | The matches. Add `format=csv` for a spreadsheet. |
| `GET /leads/{domain}` | `leads:read` | One lead, with its tags, note and drafts. |
| `PATCH /leads/{domain}` | `leads:write` | Set its tags or its note. |
| `PUT /leads/{domain}/state` | `leads:write` | Keep it, skip it, or reset it. |
| `GET /leads/{domain}/similar` | `leads:read` | The leads most like this one. |
| `POST /leads/bulk-tag` | `leads:write` | Tag or re-state up to 200 leads at once. |
| `GET /tags` | `leads:read` | The user's tags and their counts. |
| `GET /webhooks` | `webhooks:read` | Every subscription. |
| `POST /webhooks` | `webhooks:write` | Subscribe to the daily push. |
| `GET /webhooks/{id}` | `webhooks:read` | One subscription. |
| `PATCH /webhooks/{id}` | `webhooks:write` | Change one. |
| `DELETE /webhooks/{id}` | `webhooks:write` | Unsubscribe. |
| `POST /webhooks/{id}/test` | `webhooks:write` | Send one real delivery now. |
| `POST /webhooks/{id}/secret` | `webhooks:write` | Replace the signing secret. |
| `GET /deliveries` | `webhooks:read` | What was pushed in the last 7 days. |
| `GET /deliveries/{id}` | `webhooks:read` | One delivery and its attempts. |
| `GET /deliveries/{id}/download` | token or key | Fetch the lead file. |
| `POST /deliveries/{id}/redeliver` | `webhooks:write` | Send it again, with fresh links. |

---

## 5. Filters: how to write a good one

A filter is one object. The field that decides almost everything is
`spec.ai.icp`: a plain-English description of the business the user wants.

```json
{
  "name": "US dental clinics",
  "spec": {
    "ai": { "icp": "Newly opened dental clinics and orthodontic practices in the United States. Not dental software, not suppliers, not consultants.", "min_score": 70 },
    "fields": {
      "countries": ["US"],
      "exclude_countries": [],
      "require": ["email"],
      "require_mode": "any",
      "sources": ["new-domain", "producthunt", "appstore", "reddit"]
    }
  }
}
```

**Write the `icp` like a brief for a new assistant.** Say what the business
*is*, who it serves, and — this matters most — what it is *not*. The commonest
mistake is a description that also matches everyone who *sells to* that
business. "Dental clinics" pulls in dental software, dental supply and dental
marketing agencies. "Not dental software, not suppliers, not consultants" fixes
it.

**Set `min_score` between 60 and 80.** Below 50 you get noise. Above 85 you get
a handful of leads a week.

**Add `fields` only to narrow.** `countries` takes ISO-2 codes, or a region
token: `@global`, `@high_income`, `@low_income`. `require` takes contact
channels — `email`, `phone`, `linkedin`, `instagram`, `tiktok`, `twitter`,
`facebook`, `youtube`, `github`, `pinterest` — and `require_mode` is `any` or
`all`.

**After a change, `version` goes up and `engine_status` is `pending`.** The
engine picks the change up within about a minute and replays the last 7 days
against it. Leads matched by the older version stop showing. So:

- Do not poll for `engine_status: "live"` in a tight loop. Wait 60 seconds.
- A lead list can shrink right after a filter edit. That is correct behaviour.

The user's plan caps how many filters they may have. `POST /filters` answers
`403 plan_limit` with the number.

---

## 6. The daily webhook

This is the main way to use FreshLeads without polling.

Once a day, at the hour you choose, FreshLeads collects that filter's new leads
from the last 24 hours, writes them to one file, and POSTs your server a small
body carrying the links to that file.

**The leads are not in the body.** A good day can be tens of thousands of
leads; that would break most receivers. The body stays a few hundred bytes on
every day of the year.

### Subscribe

```bash
curl -s -X POST https://freshleads.cc/api/v1/webhooks \
  -H "authorization: Bearer $KEY" -H 'content-type: application/json' -d '{
    "url": "https://example.com/hooks/freshleads",
    "filter_id": "f_1a2b3c4d",
    "format": "jsonl",
    "hour_utc": 13,
    "send_empty": false
  }'
```

| Field | Default | Meaning |
|---|---|---|
| `url` | required | Your receiver. `https` only, and a public host. |
| `filter_id` | `"*"` | One filter, or `"*"` for every filter on the account. |
| `format` | `"jsonl"` | `jsonl` is one lead per line. `json` is one object with a `leads` array. |
| `hour_utc` | `13` | The hour we push, 0–23, UTC. |
| `send_empty` | `false` | Send a delivery even on a day with no new leads. |
| `active` | `true` | Turn the subscription off without deleting it. |

The answer carries `data.webhook.secret` (`whsec_…`). **It is shown once.**
Store it: it is what proves a body came from us. Lost it? `POST
/webhooks/{id}/secret` issues a new one, and the old one dies immediately.

Webhooks need a paid plan. On the free plan `POST /webhooks` answers `403
plan_limit`.

### The body you receive

```json
{
  "event": "leads.daily",
  "delivery_id": "dl_9f8e7d6c",
  "subscription_id": "wh_1a2b3c4d",
  "filter_id": "f_1a2b3c4d",
  "date": "2026-09-20",
  "sent_at": "2026-09-20T13:20:04.881Z",
  "lead_count": 412,
  "truncated": false,
  "format": "jsonl",
  "bytes": 1048576,
  "download_url": "https://firehose-leads-data-….s3.amazonaws.com/deliveries/…?X-Amz-Signature=…",
  "download_url_expires_at": "2026-09-27T13:20:04.881Z",
  "download_url_signed_by": "delivery_signer",
  "fallback_url": "https://freshleads.cc/api/v1/deliveries/dl_9f8e7d6c/download?token=…",
  "fallback_url_expires_at": "2026-09-27T13:20:04.881Z",
  "retention_days": 7,
  "sample": [
    { "domain": "brightsmile.dental", "name": "BrightSmile Dental", "website": "https://brightsmile.dental", "country": "US", "ai_score": 88 }
  ]
}
```

Headers:

| Header | Value |
|---|---|
| `x-freshleads-signature` | `t=<unix seconds>,v1=<hex>` |
| `x-freshleads-timestamp` | The same `t`, on its own |
| `x-freshleads-event` | `leads.daily` |
| `x-freshleads-delivery` | The `delivery_id`, for your own idempotency |

### Verify the signature — do this before anything else

The signed string is `${timestamp}.${rawBody}`. Use the **raw** body bytes, not
a re-serialized object: a re-serialize changes the key order and the signature
will not match.

Node:

```js
import { createHmac, timingSafeEqual } from "node:crypto";

export function verify(rawBody, header, secret, toleranceSeconds = 300) {
  const parts = Object.fromEntries(String(header).split(",").map((p) => p.split("=")));
  const t = Number(parts.t);
  if (!Number.isFinite(t)) return false;
  if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false; // replay
  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
  const given = Buffer.from(parts.v1 || "", "hex");
  return expected.length === given.length && timingSafeEqual(expected, given);
}
```

Python:

```python
import hmac, hashlib, time

def verify(raw_body: bytes, header: str, secret: str, tolerance=300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    t = int(parts["t"])
    if abs(time.time() - t) > tolerance:      # replay
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts["v1"])
```

Reject a body that fails either check. Reject a `delivery_id` you have already
processed.

### Fetch the file

```bash
# jsonl: one lead per line
curl -sL "$download_url" | jq -c '{domain, name, email: .contacts.emails[0], score: .ai.score}'
```

Two links come with every delivery, because a presigned S3 URL cannot outlive
the credential that signed it:

- **`download_url`** is a plain S3 link. Fetch it with a bare `GET`; no header,
  no credential. When `download_url_signed_by` is `"delivery_signer"` it lasts
  the full 7 days. When it is `"lambda_role"` it lasts about an hour — the
  account has not set its signer key up yet — so use the fallback after that.
- **`fallback_url`** is on our own API. It answers `302` to a fresh short S3
  link, so it works for the whole 7 days whatever signed the first one. **Follow
  redirects** (`curl -L`, `fetch` does it by default, `requests` does it by
  default). The `token` in the query is the whole credential; treat that URL as
  a secret.

Prefer `download_url` when you fetch straight away. Store `fallback_url` when
you fetch later.

**After 7 days the file is deleted and both links stop working.** Copy what you
need into your own store on the day it arrives. The leads themselves stay
readable through `GET /leads` for 7 days, and for good once the user keeps,
tags or notes them.

### When your server does not answer

- **2xx** — accepted, done.
- **5xx, timeout or network error** — we try 3 times in all, about 12 seconds
  apart. Then the delivery is marked `failed`.
- **4xx** — we treat it as "never send this again" and do not retry. So do not
  answer 4xx for a problem on your side; answer 5xx.
- After **20 failures in a row** the subscription is set `active: false`. Fix
  the receiver, then `PATCH /webhooks/{id} {"active": true}`.

Nothing is lost when a delivery fails. `GET /deliveries` lists the last 7 days
with their status, and `POST /deliveries/{id}/redeliver` sends it again with
fresh links. Answer fast and process in the background: we wait 10 seconds.

### Test it now

```bash
curl -s -X POST https://freshleads.cc/api/v1/webhooks/wh_1a2b3c4d/test \
  -H "authorization: Bearer $KEY" | jq '{ok, response_status: .data.response_status, lead_count: .data.lead_count}'
```

This is a real delivery, built from real leads, with `"test": true` added to the
body. The answer tells you exactly what your server replied, so you can debug
without waiting for tomorrow.

---

## 7. Leads

```bash
curl -s -H "authorization: Bearer $KEY" \
  "$API/leads?days=7&states=new&min_ai=70&countries=US,CA&channels=email&sort=ai&limit=50"
```

| Query | Meaning |
|---|---|
| `days` | 1–7. Default 7. |
| `q` | Free text over the name, the domain and the description. |
| `filter_ids` | Comma-separated filter ids. |
| `states` | `new`, `kept`, `skipped`, or `all`. Default `new`. |
| `countries`, `exclude_countries` | Comma-separated ISO-2 codes. |
| `channels` + `channels_mode` | Contact channels the lead must have. |
| `min_ai` | Lowest AI score, 0–100. |
| `sort` | `ai`, `recent` or `score`. |
| `format` | `json` or `csv`. |

A lead:

```json
{
  "id": "brightsmile.dental",
  "domain": "brightsmile.dental",
  "website": "https://brightsmile.dental",
  "name": "BrightSmile Dental",
  "description": "Family dentistry in Austin, Texas.",
  "category": "healthcare",
  "country": "US",
  "contacts": { "emails": ["hello@brightsmile.dental"], "phones": ["+1512…"], "socials": [ … ] },
  "ai": { "score": 88, "reason": "A new dental clinic in the United States with a booking page." },
  "sources": ["new-domain"],
  "first_seen_at": "2026-09-20T02:11:00.000Z",
  "matched_at": "2026-09-20T02:44:12.000Z",
  "filter_ids": ["f_1a2b3c4d"],
  "state": "new",
  "tags": [],
  "note": ""
}
```

Two things to watch:

- **`country` is `"??"` when we could not tell.** That is not the same as a
  missing country, and a filter keeps those by default.
- **On the free plan every contact is masked** and the lead carries
  `"masked": true`. Do not try to un-mask it; tell the user their plan hides
  contacts.

Work through a list:

```bash
# Keep one (it is then saved for good, past the 7 days).
curl -s -X PUT "$API/leads/brightsmile.dental/state" -H "authorization: Bearer $KEY" \
  -H 'content-type: application/json' -d '{"state":"kept"}'

# Tag and note it.
curl -s -X PATCH "$API/leads/brightsmile.dental" -H "authorization: Bearer $KEY" \
  -H 'content-type: application/json' -d '{"tags":["contacted"],"note":"Emailed 2026-09-20."}'

# Or do 200 at once.
curl -s -X POST "$API/leads/bulk-tag" -H "authorization: Bearer $KEY" \
  -H 'content-type: application/json' \
  -d '{"domains":["a.com","b.com"],"add":["contacted"],"status":"status:kept"}'
```

`bulk-tag` answers one row per domain. A row with `"ok": false` is a lead this
account cannot see — check it, do not retry it.

---

## 8. Errors

| Status | `error.code` | Do this |
|---|---|---|
| 400 | `bad_filter`, `bad_state`, `bad_url`, `bad_cursor`, `bad_json`, … | Fix the request. The message says what is wrong. |
| 401 | `unauthorized` | The key is missing, wrong or revoked. Do not retry; ask for a key. |
| 403 | `insufficient_scope` | The key lacks a scope. The message names it. Make a new key. |
| 403 | `plan_limit` | The plan lacks the feature or the room. Tell the user; do not retry. |
| 404 | `not_found` | The object is gone, or past its 7 days. |
| 405 | `method_not_allowed` | Read the `Allow` header. |
| 409 | `username_taken`, `no_leads`, `filter_gone` | The message says what conflicts. |
| 413 | `too_large` | The body is over 100 KB. |
| 429 | `too_many_requests` | Back off, then retry. A request stopped at the edge answers `{"message":"Too Many Requests"}` instead, with no `error` object. Treat any 429 the same way. |
| 500 | `server_error` | Our fault. Retry once, then stop. |

Retry `429` and `500` with backoff. Never retry a `4xx` that is not `429`.

---

## 9. Plans and limits

`GET /me` returns `data.account.limits` for this account, and `GET /meta`
returns the table for every plan. Read one of them before you promise the user
anything: the numbers below can change.

| | Free | Pro | Scaling |
|---|---|---|---|
| Filters | 1 | 10 | 100 |
| Leads a day | 10 (100 a month) | every match | every match |
| Contacts | masked | full | full |
| CSV export | no | yes | yes |
| Webhooks | no | yes | yes |

Other limits: 20 API keys and 20 webhook subscriptions per account; 200 rows per
page; 200 leads per `bulk-tag`; 50,000 leads per delivery file (over that, the
body carries `"truncated": true`); 100 KB per request body.

---

## 10. Recipes

**Daily feed into a CRM.** Subscribe with `format: "jsonl"`. On each POST:
verify the signature, check `delivery_id` against what you have seen, answer
`200` at once, then fetch `download_url` in the background and stream the lines
into the CRM.

**One-off list.** `GET /leads?format=csv&days=7&min_ai=75` and hand the user the
file. Needs a paid plan.

**Tune a filter that returns noise.** Read 20 leads with a low `ai.score` and
look at their `ai.reason`. The reason usually names the confusion — almost
always a business that *sells to* the target, not the target. Add that to the
`icp` as a "not" sentence, `PATCH` the filter, wait 60 seconds, read again.

**Find more like a good one.** `GET /leads/{domain}/similar`.

**Move a webhook to a different filter.** `PATCH /webhooks/{id}
{"filter_id": "f_…"}`. The secret does not change and the receiver needs no
edit.

---

## 11. What not to do

- Do not print an API key, a webhook secret or a `fallback_url` into a log, a
  commit, an issue or a chat transcript. They are credentials.
- Do not poll `GET /leads` every minute. Leads arrive in batches through the
  day. Use a webhook, or read once an hour.
- Do not build or parse a `cursor`.
- Do not re-serialize a webhook body before you check its signature.
- Do not assume a delivery file exists after 7 days. Copy it on arrival.
- Do not create a second filter for a small change. `PATCH` the one you have.
- Do not retry a `403 plan_limit`. Tell the user what their plan is missing.
