Source: https://www.birdsift.com/docs/api

# BirdSift API

List the leads in your workspace, run a bounded search, and draft outreach for a saved lead — from your own scripts or automations.

Base URL: the same origin as the app's API (`https://api.birdsift.com` in production,
`http://localhost:8080` in development).

Reference: [`GET /v1/docs`](https://api.birdsift.com/v1/docs) returns this document as markdown (no auth). Machine-readable OpenAPI: [`GET /openapi.json`](https://api.birdsift.com/openapi.json). Human-readable version: [birdsift.com/docs/api](https://www.birdsift.com/docs/api).

## Scope

v1 does five things: list the leads in your workspace, look up one lead or your credit balance,
run a bounded search and save what it finds, and draft an outreach email for a saved lead.

What it does **not** do, and why:

- **No natural-language search.** `POST /v1/searches` takes structured `queries` — a location and a
  category — not a sentence. The in-app planner that turns English into a search plan is a
  separate, more expensive step (an LLM call) that this endpoint skips entirely.
- **No bulk drafting.** One lead per `outreach-draft` call. There is no batch endpoint and no array
  form for that route. A search can return up to 25 results in one call, but drafting is still one
  lead at a time.
- **No sending.** BirdSift drafts email; it has never sent any. You get subject and body back and
  send them yourself, from your own mailbox.
- **No async.** Every call is a plain request/response. No jobs, no polling, no webhooks. A search
  can take up to ~45 seconds if it needs to check live websites (see below) — that's synchronous
  latency, not a job you poll for.

## Authentication

Create a key from the app: click the key icon in the header, name it, copy it. The plaintext is
shown once and cannot be recovered — if you lose it, revoke it and make another.

```
Authorization: Bearer bsk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```

### Key handling

- Keys are **server-side credentials**. Never put one in browser JavaScript, a mobile app, or a
  public repo — anything a user can read, an attacker can read. Cross-origin browser requests to
  `/v1` are rejected for this reason.
- A key belongs to your **workspace**, not to you personally. It reads that workspace's leads and
  spends its shared credit balance, and any member can create or revoke one.
- Keys **do not expire**, and they keep working if the person who created them leaves the
  workspace — an automation shouldn't break because of a staffing change. The flip side is that
  offboarding means revoking deliberately. The keys list shows who created each key and when it was
  last used, so you can tell them apart before revoking.
- Revoke immediately if a key may have leaked. Revocation takes effect on the next request.

### Rate limit

30 requests per minute per key for read routes (`GET /v1/leads`, `GET /v1/leads/{id}`,
`GET /v1/credits`) and `POST /v1/leads/{id}/outreach-draft`.
`POST /v1/searches` has its own, lower bucket (5/minute) — one search call is roughly 60 calls to
Google's Places API on our side, so it can't share a budget with a cheap `GET`. Both limits are
configurable server-side and may change; if you're hardcoding a retry delay, use the `Retry-After`
header rather than a fixed number. Exceeding either returns `429` with `Retry-After: 60`. This is
separate from credits: it caps burst rate, while credits cap total usage.

## `GET /v1/leads`

Your workspace's saved leads, newest first. **Free — costs no credits.**

| Query param | Default | Notes |
|---|---|---|
| `limit` | `25` | 1–100 |
| `cursor` | — | Pass the previous response's `next_cursor` for the next page. Format: `{added_at}\|{lead_id}` |

```bash
curl -s https://api.birdsift.com/v1/leads?limit=2 \
  -H "Authorization: Bearer $BIRDSIFT_API_KEY"
```

```json
{
  "leads": [
    {
      "id": "3f2b1c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
      "place_id": "ChIJ...",
      "name": "Acme Plumbing",
      "category": "plumber",
      "address": "123 Main St, Montréal, QC",
      "website": "https://acmeplumbing.example",
      "email": "info@acmeplumbing.example",
      "outreach_status": "new",
      "added_at": "2026-08-09T14:22:03.118Z"
    }
  ],
  "next_cursor": "2026-08-09T14:22:03.118Z|3f2b1c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d"
}
```

`next_cursor` is `null` on the last page. It combines `added_at` and lead `id` so pagination stays stable when two leads share a timestamp. Pass it back verbatim as the `cursor` query param.

`email` is `null` unless you've already resolved it in the app.

## `GET /v1/leads/{id}`

One saved lead, with the website-quality flags `GET /v1/leads` doesn't carry. **Free.**

`{id}` is a lead `id` from `GET /v1/leads` or a `POST /v1/searches` result.

```bash
curl -s https://api.birdsift.com/v1/leads/3f2b1c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d \
  -H "Authorization: Bearer $BIRDSIFT_API_KEY"
```

```json
{
  "id": "3f2b1c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
  "place_id": "ChIJ...",
  "name": "Acme Plumbing",
  "category": "plumber",
  "address": "123 Main St, Montréal, QC",
  "website": null,
  "email": null,
  "outreach_status": "new",
  "added_at": "2026-08-09T14:22:03.118Z",
  "no_website_flag": true,
  "not_mobile_friendly_flag": null,
  "outdated_website_flag": null,
  "last_media_year": 2019
}
```

The three `_flag` fields are `null` when that check was never run for this lead (most commonly
`not_mobile_friendly_flag` / `outdated_website_flag`, which only run when a search asked for them —
see `POST /v1/searches` below). `last_media_year` is the year the newest photo on the homepage was
uploaded; `null` means no dated media path was readable, which is not the same as recent — most
sites never expose one. `404 lead_not_found` if `{id}` isn't a lead in your workspace.

## `GET /v1/credits`

Your workspace's current credit balance. **Free.** Check this before `POST /v1/searches` or
`outreach-draft` if you'd rather see the number than learn it from a `402` mid-request.

```bash
curl -s https://api.birdsift.com/v1/credits \
  -H "Authorization: Bearer $BIRDSIFT_API_KEY"
```

```json
{ "balance": 87 }
```

## `POST /v1/searches`

Runs a bounded search and saves what it finds as leads in your workspace — each result comes back
with a lead `id` you can pass straight to `outreach-draft`.

Body:

```json
{
  "queries": [{ "location": "Montréal, QC", "category": "plumber" }],
  "filters": { "no_website": true },
  "max_results": 10
}
```

| Field | Required | Notes |
|---|---|---|
| `queries` | yes | 1–5 entries, each `{ "location": "...", "category": "..." }`. A free-trial workspace (never bought credits) can send at most 2 — more returns `400 invalid_request` |
| `filters` | no | Any of `min_rating`, `no_website`, `not_mobile_friendly`, `outdated_website`, `stale_content` — same semantics as the in-app search |
| `max_results` | yes | 1–25 |

**Costs up to 1 credit per result returned**, charged once after the scan completes — not per
candidate scanned, only per result that made it through your filters. Charged against the
`search_result` ledger: re-running a search you've already paid for costs nothing for places
already charged in this workspace (including at zero balance), and only genuinely new results
cost credits. If the scan finds new places you can't afford, you get a `402` after the scan and
**nothing is saved**. To fail fast without waiting on Places, keep your balance at least
`max_results` before calling.

```bash
curl -s -X POST https://api.birdsift.com/v1/searches \
  -H "Authorization: Bearer $BIRDSIFT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"queries":[{"location":"Montréal, QC","category":"plumber"}],"filters":{"no_website":true},"max_results":10}'
```

Response:

```json
{
  "results": [
    {
      "id": "3f2b1c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
      "place_id": "ChIJ...",
      "name": "Acme Plumbing",
      "category": "plumber",
      "address": "123 Main St, Montréal, QC",
      "website": null,
      "has_website": false,
      "is_mobile_friendly": null,
      "is_outdated": null
    }
  ],
  "credits_spent": 1,
  "truncated": false
}
```

`truncated: true` means there may be more matching businesses than what's returned — either
`max_results` was hit before every query finished scanning, a single query delivered its per-query
cap (60), or a `not_mobile_friendly`/`outdated_website`/`stale_content` filter needed to check a
live website and ran out of budget to do so before finishing. It's not an error; a narrower search (fewer `queries`,
a tighter category) surfaces more in one call.

**Latency:** with `not_mobile_friendly`, `outdated_website` or `stale_content` set, this checks
live websites for candidates with no cached analysis, and can take up to ~30–45 seconds. Without
any of them it's typically sub-second per query.

`stale_content` is the narrowest filter here: it needs a site that files its media by date, so it
is silent on most stacks and returns far fewer results than the others. A search that combines it
with a tight category may come back nearly empty — that is the filter working, not a failure. Set your client timeout accordingly rather than assuming a fast
response.

## `POST /v1/leads/{id}/outreach-draft`

Drafts one email for one saved lead. **Costs 1 credit** — the same as the in-app button — charged
only once a draft is actually produced. Nothing is charged if the request fails.

`{id}` is a lead `id` from `GET /v1/leads`. The lead must be **saved**; unsaved search results
aren't addressable over the API.

Body (optional):

```json
{ "locale": "en" }
```

`locale` is `"en"` or `"fr"`. Omit it to use your workspace's language setting.

```bash
curl -s -X POST \
  https://api.birdsift.com/v1/leads/3f2b1c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d/outreach-draft \
  -H "Authorization: Bearer $BIRDSIFT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"locale":"en"}'
```

```js
const res = await fetch(
  `https://api.birdsift.com/v1/leads/${leadId}/outreach-draft`,
  {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.BIRDSIFT_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ locale: "en" }),
  }
);

if (!res.ok) {
  const { error } = await res.json();
  throw new Error(`${error.code}: ${error.message}`);
}

const { subject, body, facts_used } = await res.json();
```

Response:

```json
{
  "subject": "Your site's copyright still says 2019",
  "body": "Noticed the footer on acmeplumbing.example still reads © 2019...",
  "facts_used": ["stale_copyright", "low_review_count"]
}
```

### `facts_used`

Every draft is grounded in facts BirdSift already verified about the business — the model is given
those facts and nothing else, so it can't invent details. `facts_used` tells you which ones the
email actually references (at most two):

| Key | Meaning |
|---|---|
| `no_website` | No website found for the business |
| `no_french_version` | Canadian site with no French version |
| `low_review_count` | Fewer than 10 Google reviews |
| `stale_copyright` | Footer copyright year is years out of date |
| `no_mobile_viewport` | No viewport meta tag — the site isn't built for mobile |
| `legacy_site_tech` | Legacy tech (old jQuery, table layouts, Flash, deprecated tags) |
| `not_mobile_friendly` | Site isn't mobile-friendly (collapsed flag, no specific signal) |
| `outdated_website` | Site looks outdated (collapsed flag, no specific signal) |

New keys may be added over time — treat this as an open set and don't fail on an unknown value.

## Errors

All errors share one shape:

```json
{ "error": { "code": "insufficient_audit_data", "message": "Not enough audit data to draft outreach for this lead" } }
```

| Status | `code` | Meaning |
|---|---|---|
| 400 | `invalid_request` | Malformed body, bad `locale`, a lead id that isn't a uuid, or `queries`/`max_results` out of range on `/v1/searches` (including more than 2 `queries` on a free-trial workspace) |
| 401 | `invalid_api_key` | Key missing, malformed, unknown, or revoked |
| 402 | `insufficient_credits` | Buy credits in the app, then retry. On `/v1/searches` this means the scan found new places your balance can't cover — nothing was saved |
| 403 | `lead_not_unlocked` | Not reachable from any documented v1 call today — see note below |
| 404 | `lead_not_found` | No such lead in your workspace |
| 404 | `place_not_found` | Not reachable from any documented v1 call today — see note below |
| 404 | `not_found` | No route matches this path/method under `/v1` |
| 413 | `request_too_large` | Request body over 16 KB. Bodies here are small — a search plan or a single optional field |
| 422 | `insufficient_audit_data` | Nothing verified about this lead to ground a draft in — expect this regularly, see below |
| 429 | `rate_limited` | Over the rate limit for this route; honor `Retry-After` |
| 500 | `internal_error` | Our problem — safe to retry |
| 502 | `generation_failed` | The model call failed. Nothing was charged; retry |
| 503 | `rate_limit_unavailable` | Rate-limit check failed temporarily — safe to retry |

`lead_not_unlocked` and `place_not_found` come from the same internal draft pipeline `/leads/draft`
uses in the app for an *unsaved* search result. `outreach-draft` only ever addresses a lead by its
saved-lead id, so neither is currently reachable — they're listed because the route maps whatever
that pipeline returns without filtering it, so a future change to either surface could make one
appear. Treat them as `lead_not_found`-equivalent if you ever see one.

### 422 is normal

BirdSift refuses to draft rather than write something generic. A lead with a modern, French-enabled
site and plenty of reviews has no hook to open with, so it gets a `422`. The app hides this by
disabling the button; over the API you'll see it as a response. Treat it as "skip this lead," not as
a failure.

### Retries

There's no idempotency key in v1. If a request times out after the draft was produced, retrying
drafts again and charges again. Retry freely on `401`, `429`, and `502` (nothing is charged on
those); be deliberate about retrying a timeout.

`POST /v1/searches` charges *before* it saves leads. A `402` means nothing was written. A timeout
after a successful charge but before you got the response is safe to retry: already-charged places
won't be charged again, and the lead upsert is idempotent.