Lyre Leads API
Integrate AI-powered Google Maps lead generation directly into your applications, AI agents, LangChain pipelines, and automation workflows. Search businesses, retrieve 50+ company-level data points per lead, including verified emails, AI-discovered contacts with personal LinkedIn profiles, and 1,519 tech stack detections, each one judged against the query you searched for. All via a simple REST API.
Quick start
Get your first API call working in under 2 minutes.
Get an API Key
Log in → Account → API Keys → Generate Key. Save it immediately, shown once.
Check Your Balance
Call GET /api/v1/balance with your Bearer key to confirm it works.
Search for Leads
Call POST /api/v1/search with query, city, and country.
Keep what matched
Every enriched row carries relevance and the quote behind it. Filter on "yes".
First API call
curl https://lyreleads.com/api/v1/balance \
-H "Authorization: Bearer lyre_your_api_key_here"
import requests
API_KEY = "lyre_your_api_key_here"
BASE_URL = "https://lyreleads.com"
headers = {"Authorization": f"Bearer {API_KEY}"}
resp = requests.get(f"{BASE_URL}/api/v1/balance", headers=headers)
print(resp.json())
# {'tier': 'Growth', 'leads_balance': 6800, 'leads_monthly_allocation': 7500, ...}
const res = await fetch('https://lyreleads.com/api/v1/balance', {
headers: { 'Authorization': 'Bearer lyre_your_api_key_here' }
});
const data = await res.json();
console.log(data.tokens_balance); // 4500
Authentication
All API v1 requests require an API key sent as a Bearer token in the Authorization header:
Authorization: Bearer lyre_<your-64-hex-key>
API keys are scoped to your account. They inherit your lead balance and tier permissions. You can create up to 10 active keys per account and revoke them at any time from the Account page.
Error handling
The API uses standard HTTP status codes. All error responses are JSON with an error field:
| Status | Meaning | Common cause |
|---|---|---|
| 200 | OK | Request succeeded |
| 400 | Bad Request | Missing required field or invalid parameter |
| 401 | Unauthorized | Missing, invalid, or revoked API key |
| 402 | Payment Required | Insufficient leads, buy more or wait for the monthly reset |
| 403 | Forbidden | Plan upgrade required, or account suspended |
| 404 | Not Found | Resource not found (model, result) |
| 429 | Too Many Requests | Your plan's rate limit for that kind of request; wait the Retry-After seconds |
| 500 | Server Error | Something went wrong on our end |
{
"error": "Insufficient leads.",
"leads_needed": 20,
"leads_balance": 5,
"upgrade_url": "/pricing"
}
Rate limits
Limits are per API key and depend on your plan. Two keys calling from the same cloud IP don't share a budget. Each window is one minute.
| Plan | Leads read a minute | Leads a minute into enrichment, contacts or verification | Leads per export |
|---|---|---|---|
| Growth | 60,000 | 30,000 | Unlimited, 1 file at a time |
| Professional | 120,000 | 60,000 | Unlimited, 1 file at a time |
| Scale | 240,000 | 120,000 | Unlimited, 2 files at a time |
| Agency | 300,000 | 150,000 | Unlimited, 2 files at a time |
Reading covers the free endpoints: leads, people, count, results, areas, previews and webhook management. A page returns up to 500 leads, and the limit is 120 pages a minute on Growth, 240 on Professional, 480 on Scale and 600 on Agency. Enrichment, contacts and verification are metered by the ids you send, added up across requests, so one request with 3,000 ids and three requests with 1,000 each cost the same. Send ids in one array rather than looping: there is also a ceiling of 30 to 150 requests a minute by plan, so a runaway loop can't drain a month of leads before you notice. Your lead balance usually runs out long before either limit. Searches send areas rather than ids, so they only count against the request ceiling, and at most two run at once. Exports have no lead cap; what's limited is how many run at once, because a whole-database export holds the database for seconds.
Every response carries RateLimit and RateLimit-Policy headers, and the spending endpoints also send X-Spend-Leads-Limit and X-Spend-Leads-Remaining. A 429 carries Retry-After and a JSON body saying which limit you hit and when to try again, so n8n, Clay and your own scripts can back off without guessing.
One lead buys one new business, and that business arrives enriched and relevance-checked at no extra cost. Businesses you already hold are never charged twice. Every business a search finds is enrolled in enrichment, which means we re-check it about once a month for as long as you keep it, at no charge. Enriching a lead you already hold costs one token per five businesses, and it doesn't enroll it. Leads you import are only re-checked once you enroll them with POST /api/v1/leads/enroll: two businesses per token, once, with an enrichment right away. Check which is which with the enrolled filter, and get the exact price first, free, from POST /api/v1/enrich/preview. Contact discovery costs 2 tokens per named contact found, nothing where nobody turns up, with SMTP verification included, and standalone email verification is included with re-checks inside 90 days free. Check your balance at any time via GET /api/v1/balance.
MCP server (AI agents)
Lyre Leads ships a Model Context Protocol endpoint, so Claude, ChatGPT, Cursor, Claude Code and any MCP-compatible client can run grid searches over whole areas, enrich the results, find and verify contact emails, and read the relevance verdict on each result directly. It exposes fifteen tools: preview_search, find_areas, estimate_area_search, search_areas, get_search_results, search_leads, list_results, estimate_list, count_saved_leads, enrich_leads, enroll_leads, find_contacts, verify_emails, get_job_status and get_balance, backed by the same routes documented below (same auth, limits, and token costs).
count_saved_leads is free and exact at any size. Agents should size a segment before spending on it, so counting is never billed and never rationed.
Point your agent at lyreleads.com/for-agents first.
It is a working guide written for an AI assistant rather than a human: what each field means,
what a blank value does and does not tell you, where our coverage is weak, and the four mistakes
that produce confidently wrong lead lists. Raw markdown at /for-agents.md.
Building a client on top of this API? lyreleads.com/build carries complete,
implementable specs an AI coding agent can follow to build a local dashboard, territory runner,
outreach queue or change monitor in about one session each.
Connecting is signing in. In Claude or ChatGPT, add https://lyreleads.com/mcp as a custom connector; in Claude Code run claude mcp add --transport http lyre-leads https://lyreleads.com/mcp. Nothing else to configure: the first tool that needs an account opens a sign-in with Google or email, and a new account is the free plan, 500 leads a month with no card. You can see and disconnect connected apps under Account, API Keys. Step-by-step setup for Claude, with prompts to try: lyreleads.com/claude.
Four tools need no account at all: preview_search (the first page of one Google Maps search for any trade and place, as real rows), find_areas, estimate_area_search and estimate_list. The same preview is a plain URL any assistant or script can fetch: /try?q=roofing+contractor&in=Houston,+TX (add &format=json for JSON).
Scripts and clients without sign-in can send an API key as a bearer token instead:
{
"mcpServers": {
"lyre-leads": {
"type": "http",
"url": "https://lyreleads.com/mcp",
"headers": { "Authorization": "Bearer lyre_your_key_here" }
}
}
}
Typical agent flow: find_areas to pin down the places (a shared name like Houston comes back as candidates with ids), estimate_area_search to size it for free, search_areas to run the grid, get_job_status with kind search until it's finished, then get_search_results with {"relevanceYes": true} before you spend anything else on the batch. search_leads is the quick one-city search, answered in the same call.
Endpoints
Lead balance
No request body required.
Response
{
"tier": "Growth",
"leads_balance": 6800,
"leads_monthly_allocation": 7500,
"leads_rollover_cap": 22500,
"leads_reset_date": "2026-09-01T00:00:00.000Z",
"leads_next_reset": "2026-10-01T00:00:00.000Z"
}
Search for leads
Request Body
| Parameter | Type | Description |
|---|---|---|
| queryrequired | string | Business type or keyword (e.g. "plumbers") |
| cityrequired | string | City to search in (e.g. "Berlin"). For disambiguation, include the region: "London, England" or "Springfield, Illinois" |
| countryrequired | string | Country name (e.g. "Germany") |
| pagesoptional | integer 1–5 | Pages of results (20 results/page). Default: 1 |
{
"query": "plumbers",
"city": "Berlin",
"country": "Germany",
"pages": 2
}
{
"results": [
{
"name": "Müller Plumbing GmbH",
"address": "Hauptstraße 42, 10115 Berlin",
"phone": "+49 30 12345678",
"website": "https://mueller-plumbing.de",
"rating": 4.7,
"review_count": 183,
"type": "Plumber"
}
],
"results_count": 40,
"leads_used": 40,
"leads_remaining": 4460,
"query": "plumbers",
"city": "Berlin",
"country": "Germany"
}
Python example
resp = requests.post(
f"{BASE_URL}/api/v1/search",
headers=headers,
json={
"query": "plumbers",
"city": "Berlin",
"country": "Germany",
"pages": 2
}
)
data = resp.json()
for biz in data["results"]:
print(biz["name"], biz.get("website", ", "))
Search whole areas (grid)
POST /api/v1/search above pages outward from one city centre, which is quick for a small town and misses most of a metro. Paging deeper from one point keeps returning the same central neighbourhoods. The grid lays points across each area instead and adds more where it keeps finding new businesses. In Houston, for the same roofing query and the same spend, one centre returned 255 businesses and a 76-point grid returned 1,061. This is the search the Search page runs, over the same handler.
| Query param | Type | Description |
|---|---|---|
| countryrequired | string | For example United States. Leave it out to get the list of countries. |
| qoptional | string | Part of a name, case-insensitive. Most populous first. |
| limitoptional | integer | Default 20, max 100. |
{
"country": "United States",
"areas": [
{ "id": "houston-texas-771e0d", "name": "Houston", "state": "Texas", "population": 2304580, "area_km2": 1659.54 }
],
"count": 1
}
Same body as the search below. Returns seedPoints, alreadyCoveredAreas (areas your account already covers for this query, which a search skips) and a rough range of provider calls. It can't give a price, because you pay per new business found and nobody knows how many exist before looking.
| Parameter | Type | Description |
|---|---|---|
| queryrequired | string | The business you want, for example roofing contractor. |
| countryrequired | string | As listed by GET /api/v1/areas. |
| areasrequired | array | "Houston, Texas", {"name", "state"}, or an area id. |
| modeoptional | grid | city | grid covers the whole area. city pages from the centre and is cheaper for small towns. Default city. |
A name several places share is never guessed. The US list has four places called Houston and thirteen called Springfield. If an area could be more than one of them, the request returns 400 with ambiguous[].candidates, each carrying an id, a label, its population and coordinates. Nothing starts and nothing is charged. Resend with the id you mean. An unknown name returns 400 with unknown[].suggestions.
{ "query": "roofing contractor", "country": "United States",
"areas": ["Houston, Texas", "Dallas, Texas"], "mode": "grid" }
{ "job_id": "srch-…", "mode": "grid",
"status_url": "/api/v1/searches/srch-…",
"results_url": "/api/v1/searches/srch-…/results" }
You pay one token per new business. A listing you already hold costs nothing, though fetching pages carries a small per-page minimum. At most two searches run at once per account, and sending the same search again while it runs returns the running job rather than starting a second. Every new business is enriched, relevance-checked and enrolled in the monthly re-check as it lands.
Returns status, progress, completedCities of totalCities, found, and finished: true once it has stopped for any reason. DELETE /api/v1/searches/{id} stops a running search and keeps what it found.
Pages of up to 500 leads, in the same shape as GET /api/v1/leads, and it takes the same filters, so ?relevanceYes=true&hasVerifiedEmail=true works here too.
job = requests.post(f"{BASE_URL}/api/v1/searches", headers=headers, json={
"query": "roofing contractor", "country": "United States",
"areas": ["Houston, Texas"], "mode": "grid"}).json()
while not requests.get(BASE_URL + job["status_url"], headers=headers).json().get("finished"):
time.sleep(10)
page = requests.get(BASE_URL + job["results_url"] + "?limit=500&relevanceYes=true", headers=headers).json()
for lead in page["results"]:
print(lead["company_name"], lead.get("phone"))
Retrieve past results
| Query param | Type | Description |
|---|---|---|
| limitoptional | integer | Results per page (default 20, max 100) |
| offsetoptional | integer | Pagination offset (default 0) |
| queryoptional | string | Filter by search query (partial match) |
| search_idoptional | integer | Filter by search history ID |
curl "https://lyreleads.com/api/v1/results?limit=50&query=plumbers" \
-H "Authorization: Bearer lyre_..."
{
"results": [ /* array of result objects */ ],
"total": 2540,
"limit": 50,
"offset": 0,
"has_more": true
}
Each result object contains enrichment data including email, facebook_url, instagram_url, linkedin_url, has_facebook_pixel, has_google_analytics, cms_platform, tech_stack (JSON array of every technology detected on the site, from a catalogue of 844 web technologies across 32 categories), plus categorized tech columns: tech_frameworks, tech_analytics, tech_advertising, tech_marketing, tech_ecommerce, tech_customer_tools, tech_infrastructure, and merchant_signals (what the site says: cash only, age gate, GLP-1, no refunds; blank on a lead read before 12 October 2026 means not yet read). Filter on them with merchantSignal, paymentProvider, their exclude twins and the one-click merchantView; every name, group and view is listed at GET /api/merchant-vocabulary.
Boolean signals: has_ad_pixels, has_marketing_automation, has_ecommerce, has_reviews_widget, has_scheduling, has_contact_form. Firmographics: state, domain_age_years, email_marketing_platform. AI visibility: ai_crawler_access (open/partial/blocked/unknown, whether the site's robots.txt blocks AI crawlers like GPTBot, ClaudeBot, PerplexityBot), ai_bots_blocked, has_llms_txt. Also includes chat_widget, booking_system, crm_platform, has_ssl, contact_name, contact_title, contact_linkedin, relevance, and relevance_evidence.
Search Match
Google Maps returns businesses that are not what you asked for. Across 284 businesses we hand-checked, 72.2% of results matched the keyword, ranging from 95.8% for roofing contractor to 28.0% for home remodeling contractors.
So enrichment reads each company's own website and judges it against the query that row came from. The verdict ships with the sentence that justifies it, quoted from the page, so you can audit any call we make. This runs automatically on every enriched lead and costs nothing beyond the search lead.
| Field | Type | Description |
|---|---|---|
| relevance | string | yes, no, unclear, or null. null means never judged (no website, unreadable page, or a row created without a query). It never means "no". |
| relevance_evidence | string | The phrase copied verbatim from the company's page that justifies the verdict. |
// POST /api/v1/count, free at any size
{
"filters": { "relevance": "yes", "has_email": true }
}
// A row from GET /api/v1/results
{
"name": "Bergen Roofing & Sheet Metal",
"query": "roofing contractor",
"relevance": "yes",
"relevance_evidence": "Residential and commercial roof replacement since 1994"
}
In a 284-business study we published, a model reading the company's website kept 98.8% of the genuine businesses, against 87.4% for filtering on the Google Maps category, which silently discards about one real business in eight. That measures the method rather than this exact check, and both raters in the study were language models, which is the weakness. We publish the study including the half that argues against us.
Running the whole thing without us
A subscription and an API key are the entire product. Everything the web app does to find, enrich and contact a business is reachable over HTTP, using the same handlers, the same filters and the same lead accounting the browser gets.
The loop
POST /api/v1/countsizes a segment for nothing. Do this before anything that spends.POST /api/v1/searchessweeps whole areas with the grid as a job, andGET /api/v1/searches/{id}/resultsreads what it found. For one small town,POST /api/v1/searchanswers in the same request withresult_ids. Duplicates you already own are skipped and not charged.POST /api/v1/leads/importbrings in businesses you already have, free. Enriching them withPOST /api/enrichcosts five businesses per token; enrolling them in the monthly re-check withPOST /api/v1/leads/enrollcosts two businesses per token, once, enrichment included.POST /api/enrich/find-contactstakes those ids and returns named contacts with their mailboxes SMTP-verified, 2 tokens per contact found and nothing where nobody turns up.GET /api/v1/peoplereads the contacts back, filtered byemailStatus=verified, seniority, or any of fourteen other filters.GET /api/v1/leadsqueries everything you own across 129 filter parameters: relevance, verification state, enrichment status, detected CMS, CRM, chat widget, booking system, data freshness.GET /api/v1/exporthands the same filtered segment back as a file for your warehouse.
Keeping it alive
A lead list starts ageing the moment you download it, so we re-check every enrolled lead in your account about once a month, on every plan, without spending leads and without you scheduling anything. Every lead a search finds is enrolled; an imported lead is enrolled when you enroll it (POST /api/v1/leads/enroll). These three endpoints tell you what the re-checks found.
GET /api/v1/signals/cataloglists the change signals we track: review surges, rating moves, technology added or dropped, hiring flips.GET /api/v1/leadsacceptssignalandsignalWindow, so "everything that changed in the last 30 days" is a query, not a diff you write yourself.GET /api/v1/leads/{id}/timelinereturns the observation history and the change events for one business, so you can show a customer what moved and when.
Events are only emitted between two successful enriched runs, never from a degraded one, and a disappearance needs two consecutive misses before it counts as a removal. A signal we are not sure about is not sent.
Getting pushed to instead of polling
Polling a job you started is fine. You have the jobId and you know it's running. Change signals are the awkward case: a landscaper installs online booking three weeks after you last looked, with no request of yours to hang a response on. Without a push you'd poll on a timer forever and mostly learn nothing.
POST /api/v1/webhooks subscribes an https endpoint to signal.detected, job.completed and job.failed. The response carries a signing secret, once. Every delivery then arrives with X-Lyre-Timestamp and X-Lyre-Signature: sha256=<hex>, the digest being HMAC-SHA256 of "{timestamp}.{raw body}" under that secret. Verify it before trusting a payload, and reject timestamps far from now so a captured delivery can't be replayed at you.
Failed deliveries retry five times with widening backoff, from one minute out to ten hours (about twelve hours in all), which is long enough to fix a broken endpoint and still receive the event. A subscription that fails twenty times consecutively is switched off rather than retried forever, and stays visible with its last error so you can see why. GET /api/v1/webhooks/{id}/deliveries is the log, and POST /api/v1/webhooks/{id}/test fires a synthetic event so you can prove the endpoint works before a real signal ever arrives.
Your URL has to be https on a publicly routable address. We refuse private and reserved ranges, cloud metadata addresses, localhost, and hostnames that resolve to any of those, and we re-check at delivery time as well as at registration, because a hostname can be repointed after you register it.
Every endpoint above is in the OpenAPI spec, and the fields they return are defined in the data dictionary.
OpenAPI spec
Returns a complete OpenAPI 3.0 spec. Use this to auto-configure LLM tool-use, Postman, Swagger UI, or any OpenAPI-compatible tooling. No API key required to fetch the spec.
from langchain.tools import OpenAPISpec, APIOperation
from langchain.chains import OpenAPIEndpointChain
spec = OpenAPISpec.from_url("https://lyreleads.com/api/v1/openapi.json")
# Agent can now auto-discover and call Lyre Leads endpoints
Lyre Leads