Developers

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 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/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 paramDefaultNotes
limit251–100
cursorPass 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
}
FieldRequiredNotes
queriesyes1–5 entries, each { "location": "...", "category": "..." }
filtersnoAny of min_rating, no_website, not_mobile_friendly, outdated_website — same semantics as the in-app search
max_resultsyes1–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):

KeyMeaning
no_websiteNo website found for the business
no_french_versionCanadian site with no French version
low_review_countFewer than 10 Google reviews
stale_copyrightFooter copyright year is years out of date
no_mobile_viewportNo viewport meta tag — the site isn't built for mobile
legacy_site_techLegacy tech (old jQuery, table layouts, Flash, deprecated tags)
not_mobile_friendlySite isn't mobile-friendly (collapsed flag, no specific signal)
outdated_websiteSite 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" } }
StatuscodeMeaning
400invalid_requestMalformed body, bad locale, a lead id that isn't a uuid, or queries/max_results out of range on /v1/searches
401invalid_api_keyKey missing, malformed, unknown, or revoked
402insufficient_creditsBuy credits in the app, then retry. On /v1/searches this means the scan found new places your balance can't cover — nothing was saved
403lead_not_unlockedNot reachable from any documented v1 call today — see note below
404lead_not_foundNo such lead in your workspace
404place_not_foundNot reachable from any documented v1 call today — see note below
404not_foundNo route matches this path/method under /v1
413request_too_largeRequest body over 16 KB. Bodies here are small — a search plan or a single optional field
422insufficient_audit_dataNothing verified about this lead to ground a draft in — expect this regularly, see below
429rate_limitedOver the rate limit for this route; honor Retry-After
500internal_errorOur problem — safe to retry
502generation_failedThe model call failed. Nothing was charged; retry
503rate_limit_unavailableRate-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.

Create an API key

Keys are workspace-scoped and created from the app — click the key icon in the header, name it, and copy the plaintext shown once.

Open the app