# Biz Collect API - full agent playbook > Complete integration guide for LLM agents and scripts. Turn a location plus keywords into enriched, structured local-business leads delivered as clean JSON. This document is self-contained: an agent can integrate end-to-end from it alone. The machine-readable contract is the OpenAPI 3.1 spec at https://kindly-lyrebird-376.convex.site/openapi.json; the concise index is https://kindly-lyrebird-376.convex.site/llms.txt. ## Base URL and conventions - All REST endpoints are under `https://kindly-lyrebird-376.convex.site/api/v1`. The API is served from the Convex HTTP origin (a `.convex.site` host). - Requests and responses are JSON (`Content-Type: application/json; charset=utf-8`), except file exports. - Encode request bodies as UTF-8. Accented place names and keywords (`Zürich`, `Genève`, `Müller`) are fully supported, but a body sent in a legacy encoding (e.g. Windows CP1252) corrupts them and is rejected with `400 invalid_request` before any credits are spent. Most HTTP clients do this by default; if you hand-build the body on Windows, write it out as UTF-8 explicitly. - Errors use a stable envelope: `{ "error": { "code": "", "message": "" } }`. - Authentication is a Bearer API key on every `/api/v1/*` request: `Authorization: Bearer biz_live_...`. Mint keys at https://bizcollect.dev/developers/api-keys. ## Recommended flow: single synchronous call (wait: true) This is the preferred pattern for tool-calling agents. ONE request creates the job, blocks server-side until it finishes, and returns the full results - no polling loop to manage. Request: ``` POST https://kindly-lyrebird-376.convex.site/api/v1/search Authorization: Bearer biz_live_... Content-Type: application/json { "location": "Winterthur", "keywords": ["bakery", "cafe"], "radius_km": 10, "result_pages": 1, "scrape_emails": true, "wait": true, "wait_timeout_seconds": 300 } ``` Successful response (HTTP 200) - the SAME envelope as GET /api/v1/jobs/{job_id}, plus `wait_timed_out`: ```json { "job_id": "j97...", "status": "completed", "progress": 100, "results_count": 42, "emails_scraped_count": 31, "contacts_resolved_count": 18, "robots_skipped_count": 2, "rendered_sites_count": 3, "render_credits_charged": 3, "scrape_emails": true, "scrape_mode": "respectful", "render_fallback": true, "resolve_contacts": true, "contact_pages": ["impressum", "kontakt", "contact", "team", "ueber-uns", "about", "mitarbeiter", "people"], "gender_inference": true, "location": "Winterthur", "resolved_location": "Winterthur, Bezirk Winterthur, Zürich, Switzerland", "geocode_confidence": 0.62, "geocode_country_code": "ch", "lat": 47.5, "lon": 8.72, "radius_km": 10, "result_pages": 1, "language_code": "en", "keywords": ["bakery", "cafe"], "coverage": "standard", "tile_count": 1, "coverage_truncated": false, "source": "api", "created_at": 1730000000000, "started_at": 1730000000500, "completed_at": 1730000040000, "error": null, "businesses": [ { "id": "p1...", "name": "Beck Brunner", "address": "Marktgasse 1, 8400 Winterthur", "phone": "+41 52 212 00 00", "website": "https://example.ch", "website_is_directory": false, "emails": ["julian@example.ch", "info@example.ch"], "email_details": [ { "email": "julian@example.ch", "confidence": "high", "has_mx": true, "is_role_account": false, "contact": { "first_name": "Julian", "last_name": "Schaffner", "full_name": "Julian Schaffner", "title": "Geschäftsführer", "gender": "m", "salutation_de": "Herr", "honorifics": { "de": "Herr", "fr": "Monsieur", "it": "Signor", "en": "Mr" }, "name_confidence": "high", "name_source": "impressum", "evidence_url": "https://example.ch/impressum" } }, { "email": "info@example.ch", "confidence": "high", "has_mx": true, "is_role_account": true, "contact": null } ], "people": [ { "first_name": "Julian", "last_name": "Schaffner", "full_name": "Julian Schaffner", "name_prefix": null, "title": "Geschäftsführer", "gender": "m", "salutation_de": "Herr", "honorifics": { "de": "Herr", "fr": "Monsieur", "it": "Signor", "en": "Mr" }, "emails": ["julian@example.ch"], "source_urls": ["https://example.ch/impressum"], "name_confidence": "high", "name_source": "impressum" } ], "social_links": [{ "platform": "instagram", "url": "https://instagram.com/example" }], "outgoing_links": ["https://instagram.com/example"], "lat": 47.5, "lng": 8.72, "rating": 4.6, "user_rating_count": 213, "business_status": "OPERATIONAL", "primary_type": "bakery", "types": ["bakery", "store"], "regular_opening_hours": null, "reviews": [], "review_summary": null } ], "wait_timed_out": false } ``` Outcomes: - Completed in time: HTTP 200, `status: "completed"`, `wait_timed_out: false`, full `businesses` array. - Job failed: HTTP 200, `status: "failed"`, `error` set, `wait_timed_out: false`. A job-level failure is NOT an HTTP 5xx - the call succeeded and the charged credits were auto-refunded. - Timed out: HTTP 202, the accepted body plus `wait_timed_out: true`. The job is still running; fall back to polling `poll_url`. Tune the block with `wait_timeout_seconds` (10-540, default 300). Sending it without `wait: true` is a 400. Response field notes: - `social_links` is an array of `{ "platform": "...", "url": "..." }` OBJECTS, not bare URL strings. Iterate and read each entry's `.url` / `.platform`. (`outgoing_links` IS a plain array of URL strings.) - `resolved_location` echoes where the geocoder actually placed your `location` (with `geocode_confidence` 0-1 and `geocode_country_code`). Check it to confirm the search ran where you meant before trusting results. - `website_is_directory` is `true` when `website` is a directory/aggregator listing (e.g. local.ch) rather than the firm's own site. Those sites are not scraped for emails; the `phone` is still valid. - `email_details[].contact` is the named person an email belongs to (with German salutation + localized honorifics), or null; `email_details[].is_role_account` flags generic inboxes; `businesses[].people[]` is the full named roster. See the Contact resolution section. ## Alternative flow: async create then poll Omit `wait` (or set it false). POST /api/v1/search returns 202 immediately: ```json { "job_id": "j97...", "status": "queued", "poll_url": "/api/v1/jobs/j97...", "scrape_emails": true, "scrape_mode": "respectful", "render_fallback": true, "location": "Winterthur", "radius_km": 10, "result_pages": 1, "language_code": "en", "keywords": ["bakery", "cafe"], "coverage": "standard", "tile_count": 1, "coverage_truncated": false, "credits_charged": 40, "credits_remaining": 160 } ``` Then GET `https://kindly-lyrebird-376.convex.site/api/v1/jobs/{job_id}` and re-read until `status` is `completed` or `failed`. `progress` is 0-100. Polling is free and not subject to credit charges (only the per-key request rate limit applies). ## Alternative flow: webhooks (no polling) Subscribe once, then create searches and let us notify you on completion. ``` POST https://kindly-lyrebird-376.convex.site/api/v1/webhooks Authorization: Bearer biz_live_... Content-Type: application/json { "url": "https://your-app.example/hooks/biz", "events": ["job.completed", "job.failed"] } ``` The response returns a `secret` (`whsec_...`) ONCE - store it. Each delivery is a signed POST whose body mirrors the GET /api/v1/jobs/{job_id} envelope (`{ type, created_at, data }`). Verify with the Standard Webhooks / Svix scheme: `new Webhook(secret).verify(rawBody, headers)` using the `webhook-id`, `webhook-timestamp`, and `webhook-signature` headers. Non-2xx responses are retried with exponential backoff (~0s, 1m, 10m, 1h, 6h; up to 5 attempts). ## POST /api/v1/search parameters | Parameter | Type | Range / values | Default | Notes | | --- | --- | --- | --- | --- | | location | string | up to 200 chars | (required) | City, region, or place name. Geocoded via OpenStreetMap Nominatim. | | keywords | string[] | 1-20 items, each up to 80 chars | (required) | Searched separately inside the resolved area. | | radius_km | number | 1-50 | 10 | Search radius from the resolved location. | | result_pages | integer | 1-3 | 1 | Google Places pages per keyword; each page is up to 20 raw results. Cost multiplier. | | language_code | string | BCP-47 (`en`, `de`, `pt-BR`) | en | Localized Google fields and translated review text. | | scrape_emails | boolean | true / false | true | Crawl each website for emails, socials, outgoing links. Adds ~30-90s. | | scrape_mode | string | `respectful` / `aggressive` | respectful | `respectful` honors robots.txt (RFC 9309); `aggressive` ignores it. Only matters when scrape_emails is true. | | render_fallback | boolean | true / false | true | Headless re-render (Browserless) of JS-built sites that yield no email. +1 credit per rendered site, charged at completion. | | resolve_contacts | boolean | true / false | true when scrape_emails | Resolve named, salutation-ready contacts from each site (per-email `contact` + `is_role_account`, business-level `people[]`). Pure + heuristic, no LLM, no extra network, NO extra credits. Only matters when scrape_emails is true. | | contact_pages | string[] | up to 20 slugs | impressum, kontakt, contact, team, ueber-uns, about, mitarbeiter, people | Prioritized contact-page slugs the resolution crawl biases toward. The extra-page fetch budget stays capped, so this only reorders discovery. | | gender_inference | boolean | true / false | true | Whether contact resolution consults a first-name -> gender dataset for `gender`/`salutation_de` when no explicit honorific is printed. Gender stays biased to `unknown`. | | coverage | string | `standard` / `exhaustive` | standard | `exhaustive` tiles the radius into a grid to beat Google's 60-result cap. Multiplies cost by tile_count. | | max_credits | integer | >= 20 | (none) | Spend cap for exhaustive searches; truncates the tile list to fit (sets `coverage_truncated: true`). Ignored for standard. | | title | string | up to 200 chars | auto | Label shown in the dashboard. | | wait | boolean | true / false | false | Synchronous mode - block until the job finishes and return full results in one call. | | wait_timeout_seconds | integer | 10-540 | 300 | Max block time in synchronous mode. Only valid with `wait: true`. | Header: `Idempotency-Key` (optional). If a prior request from the same workspace used the same key, the existing job is returned instead of creating a new one (safe retries). With `wait: true`, a replay of a finished job returns its results immediately and a replay of a running job re-enters the wait. ## Error codes | HTTP | code | Meaning | | --- | --- | --- | | 400 | invalid_json | Request body is not valid JSON. | | 400 | invalid_request | A parameter failed validation (also when `max_credits` cannot afford one tile). | | 400 | location_not_found | The `location` string could not be geocoded. | | 401 | missing_authorization | No `Authorization: Bearer` header was sent. | | 401 | invalid_api_key | Key is unknown, revoked, malformed, or no longer authorized for its workspace. | | 402 | insufficient_credits | Balance too low for this search. | | 403 | workspace_read_only | The workspace is scheduled for deletion and is read-only. | | 404 | not_found | Job or webhook not found (or not in your workspace). | | 409 | job_not_completed | Export was requested before the job reached `completed`. | | 429 | rate_limited | Per-key request rate limit exceeded; inspect `Retry-After` and `X-RateLimit-*`. | | 429 | daily_credit_limit_reached | The workspace hit its daily credit spend limit. | ## Credits, cost math, and the exhaustive example - Standard search cost = `result_pages x unique keyword queries x 20` credits. - Exhaustive search cost = `tile_count x result_pages x unique keyword queries x 20` credits. - Render fallback adds +1 credit per rendered site, debited at completion (a failed job is never charged for renders). See `rendered_sites_count` and `render_credits_charged`. Worked exhaustive example: `coverage: "exhaustive"`, `result_pages: 2`, `keywords: ["plumber"]` (1 unique query), and the grid resolves to `tile_count: 30`. Cost = `30 x 2 x 1 x 20` = 1200 credits. Set `max_credits: 800` and the tile list is truncated (keeping the most central tiles) to fit, returning `coverage_truncated: true`. The 64-tile-per-keyword hard cap also applies. `max_credits` below 20 (the cost of one page for one keyword) is rejected with 400. Account starting balance: 200 signup credits plus +20/day on login (free tier). Paid plans (live via Stripe): Pro $19/mo yearly ($23/mo monthly), 6000 credits/mo; Business $76/mo yearly ($92/mo monthly), 30000 credits/mo; top-ups $0.01/credit. ## Email confidence semantics Every scraped email is verified (syntax + MX over DNS-over-HTTPS, free and always on; NO SMTP mailbox probe). `email_details` is aligned with `emails` (same order, best contact first). Each entry has a `confidence` tier and `has_mx`: | confidence | Meaning | | --- | --- | | high | Valid syntax + domain has MX + a monitored inbox (info@, contact@, office@, mail@, hello@, kontakt@) + on-domain to the business OR a known free provider. | | medium | Valid syntax + MX, but a department/role address (sales@, support@, admin@, ...) OR off-domain. | | low | Valid syntax but no MX (A/AAAA implicit-MX only), or a system/no-reply address (noreply@, postmaster@, abuse@, webmaster@). | | unknown | MX lookup failed on both resolvers; deliverability could not be determined (not penalized). | `has_mx`: true = domain has MX records; false = no MX but an A/AAAA record exists (implicit-MX fallback); null = DNS lookup inconclusive. `high` reflects domain-level deliverability and address quality, never a guaranteed live mailbox. ## Contact resolution (named people + salutations) When `resolve_contacts` is on (default whenever `scrape_emails` is true), each business is enriched with named, salutation-ready outreach contacts - turned purely heuristically from the scraped emails plus the page HTML the email scrape already fetched. No LLM, no extra network calls, and NO extra credits. - `email_details[].is_role_account`: `true` for a generic inbox (info@, kontakt@, office@, sales@, no-reply@, ...). These get `contact: null` unless a real name is printed right next to that exact address. - `email_details[].contact`: the named person the address belongs to, or `null`. Fields (snake_case): `first_name`, `last_name`, `full_name`, `title` (role, or null), `gender` (`m`/`f`/`unknown`, biased to unknown), `salutation_de` (`Herr`/`Frau`/null), `honorifics` (localized `de`/`fr`/`it`/`en` words, or null), `name_confidence` (`high`/`medium`/`low`), `name_source` (`email_next_to_name` / `structured_markup` / `impressum` / `team_page` / `local_part+company` / `local_part_only`), and `evidence_url` (the page the name was found on). - `businesses[].people[]`: the deduped roster of everyone the site named (Impressum / team / about), INCLUDING people not matched to a scraped email - so a business with only an info@ address can still surface its Geschäftsführer. Same per-person fields as `contact` plus `name_prefix` (Dr./Prof./...), `emails[]`, and `source_urls[]`. - `contacts_resolved_count` (job level) mirrors `emails_scraped_count`: how many scraped emails resolved to a named person. Gender is deliberately conservative (a wrong honorific is worse than a neutral one): `m`/`f` is only assigned from an explicit printed honorific or, when `gender_inference` is on, a high-confidence first-name lookup; otherwise `unknown` with `salutation_de: null`. Set `resolve_contacts: false` to omit all of this (contacts stay null, `people` empty); `contact_pages` reorders which pages the crawl prioritizes (within the existing per-site page cap). ## Export endpoint usage GET `https://kindly-lyrebird-376.convex.site/api/v1/jobs/{job_id}/export?format=csv|xlsx|json` returns a downloadable file (same Bearer auth and workspace scoping as the job endpoint). The job must be `completed` - otherwise `409 job_not_completed`. - `csv` (default): RFC 4180, UTF-8 BOM, CRLF (opens cleanly in Excel). - `xlsx`: a real OOXML spreadsheet. - `json`: `{ job, businesses }` where each business is the same shape as the `businesses` array on GET /api/v1/jobs/{job_id}. Responses carry `Content-Disposition: attachment; filename="-."`. CSV/XLSX columns: Name, Address, Phone, Website, Best Email, Best Email Confidence, All Emails, Facebook, Instagram, LinkedIn, Twitter / X, YouTube, TikTok, Other Social, Latitude, Longitude, Rating, User Rating Count, Business Status, Primary Type, Types, Opening Hours, Open Now, Outgoing Links, Review Summary, Reviews, Render Used. ## Rate limits (detail) | Plan | Requests / minute / key | | --- | --- | | free | 30 | | pro | 120 | | business | 300 | Every response carries `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` (unix seconds). On 429 `rate_limited` a `Retry-After` header gives the seconds to wait. A synchronous `wait: true` search counts as ONE request. `GET /openapi.json` and the llms files are unlimited. ## Code snippets ### curl (single-call flow) ```bash curl -s -X POST https://kindly-lyrebird-376.convex.site/api/v1/search \ -H "Authorization: Bearer biz_live_..." \ -H "Content-Type: application/json" \ -d '{"location":"Winterthur","keywords":["bakery"],"wait":true}' ``` ### Python ```python import requests resp = requests.post( "https://kindly-lyrebird-376.convex.site/api/v1/search", headers={"Authorization": "Bearer biz_live_..."}, json={"location": "Winterthur", "keywords": ["bakery"], "wait": True}, timeout=320, ) data = resp.json() for biz in data.get("businesses", []): print(biz["name"], biz["emails"]) ``` ### TypeScript ```typescript const resp = await fetch("https://kindly-lyrebird-376.convex.site/api/v1/search", { method: "POST", headers: { Authorization: "Bearer biz_live_...", "Content-Type": "application/json", }, body: JSON.stringify({ location: "Winterthur", keywords: ["bakery"], wait: true }), }); const data = await resp.json(); for (const biz of data.businesses ?? []) { console.log(biz.name, biz.emails); } ``` ## Account and discovery - GET `https://kindly-lyrebird-376.convex.site/api/v1/account` returns your workspace, API key, credit balance, plan, daily spend limit, and current-month usage. - OpenAPI 3.1 spec: https://kindly-lyrebird-376.convex.site/openapi.json. Interactive docs: https://bizcollect.dev/docs. Concise index: https://kindly-lyrebird-376.convex.site/llms.txt. ## Data rights (GDPR) Privacy and data-rights flows - a complete access/portability export, plus account and workspace erasure with a grace window - run from the authenticated web dashboard at https://bizcollect.dev/settings/account, not the public REST API. The service complies with GDPR, UK GDPR, Swiss FADP, and applicable U.S. state privacy laws (https://bizcollect.dev/legal/privacy).