API BirdSift
Listez les leads de votre espace de travail, lancez une recherche bornée et générez un brouillon de prospection pour un lead sauvegardé — depuis vos propres scripts ou automatisations.
La référence ci-dessous est en anglais — les noms d'endpoints et de paramètres ne se traduisent pas.
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 returns this document as markdown (no auth). Human-readable version: 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/searchestakes structuredqueries— 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-draftcall. 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
/v1are 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} |
curl -s https://api.birdsift.com/v1/leads?limit=2 \
-H "Authorization: Bearer $BIRDSIFT_API_KEY"
{
"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:
{
"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.
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:
{
"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):
{ "locale": "en" }
locale is "en" or "fr". Omit it to use your workspace's language setting.
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"}'
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:
{
"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:
{ "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.