# BirdSift API v1

Draft outreach for leads you've already saved, 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). Human-readable version: [birdsift.com/docs/api](https://www.birdsift.com/docs/api).

## Scope

v1 does three things: list the leads in your workspace, 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 `GET /v1/leads` 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.

## `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": "..." }` |
| `filters` | no | Any of `min_rating`, `no_website`, `not_mobile_friendly`, `outdated_website` — 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, or a `not_mobile_friendly`/
`outdated_website` 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` or `outdated_website` set, this checks live websites for
candidates with no cached analysis, and can take up to ~30–45 seconds. Without either filter it's
typically sub-second per query. 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` |
| 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.
