Run searches from your own tools.
Start a search, poll it, and get verified business contacts back as JSON. Three endpoints, bearer-token authentication, no SDK required. Built for n8n, Make, Zapier and anything else that can send an HTTP request. Available on the Scale plan.
How the API works
The API mirrors what the dashboard does, with the same job runner and the same credit accounting behind it. There is one flow and it has three steps.
- POST a search. You send business types and locations. Every keyword runs against every location, so one call can cover a whole territory. The response comes back immediately with an id — nothing waits on the connection.
- Poll it. A search runs for minutes, not seconds. GET the id every five to ten seconds and you get progress plus whatever rows have been found so far.
- Use the rows. Each lead carries the business name, address, phone, website, category, socials and — where one exists and could be verified — an email address labelled
valid,riskyorinvalid.
There is no webhook or callback yet. Polling is the only completion mechanism, which is why the examples below all include a real loop rather than a single request.
Authentication
Open the dashboard, click Billing, and create a key under API keys. It is shown once and stored only as a hash, so copy it before closing the panel — if it is lost, revoke it and issue another. Keys look like glx_live_… and never expire on their own.
Send it as a bearer token on every request:
Authorization: Bearer glx_live_xxxxxxxxxxxxxxxxYour plan is checked on every request rather than only when the key is created. A downgrade from Scale stops an existing key working immediately with a 403 — which is deliberate, but worth knowing if a workflow suddenly stops after a billing change.
Check the connection
GET /api/v1/me is the cheapest way to confirm a key works and see how many credits are left. Use it as the first node of any workflow you are debugging.
curl https://goleadx.com/api/v1/me \
-H "Authorization: Bearer glx_live_xxxx"{
"email": "you@company.com",
"plan": "scale",
"credits": { "used": 1240, "left": 3760, "limit": 5000 }
}On an unlimited plan, used and left are null and limit is the string "unlimited" — worth handling if you branch on credit numbers in a workflow.
Start a search
POST /api/v1/search. Returns 202 with an id and a poll URL. A real search runs for minutes, so nothing waits on the connection — that would time out at whatever proxy sits in front of you, and n8n would give up long before the results arrived.
curl -X POST https://goleadx.com/api/v1/search \
-H "Authorization: Bearer glx_live_xxxx" \
-H "Content-Type: application/json" \
-d '{
"queries": ["dental clinics", "orthodontists"],
"locations": ["Miami", "Orlando", "Tampa"],
"sources": ["google_maps"],
"maxResults": 100,
"verifyEmails": true,
"enrich": true
}'{
"id": "9f2c1b7e-…",
"status": "queued",
"poll": "https://goleadx.com/api/v1/search/9f2c1b7e-…"
}Request fields
| Field | Type | Notes |
|---|---|---|
queries | string[] | Required. 1–25 business types, each at least 2 characters. Every query runs against every location. |
locations | string[] | Up to 20. City names, regions or countries — whatever you would type into Maps. Defaults to an empty list, which searches without a place filter. |
sources | string[] | 1–12 of the ids in the table below. Defaults to ["google_maps"]. |
maxResults | number | 10–1000, default 100. One result is one credit. Capped at your remaining credits rather than rejected. |
verifyEmails | boolean | Default true. Syntax, MX and — where enabled — a real SMTP probe. |
enrich | boolean | Default true. Crawls each website for contact details. Turning it off makes the search much faster and returns almost no email addresses. |
Source ids
Combine as many as you like in one search — results deduplicate, so overlap costs credits but never appears twice in the output. Each source has a page explaining what it returns and what it cannot do.
| id | Source | Notes |
|---|---|---|
google_maps | Google Maps | Highest email yield. Returns the website directly, which is what the crawl needs. |
web_search | Open web search | Best for businesses with a site but no map listing. |
shopify | Shopify stores | Also hands over the domain directly, so yields are high. |
linkedin | Public company pages found via site-restricted search. | |
instagram | Profile discovery; the website is found by looking the business up by name. | |
tiktok | TikTok | As above. |
facebook | As above. | |
youtube | YouTube | As above. |
x | X / Twitter | As above. |
pinterest | As above. | |
yelp | Yelp | Strong in hospitality and local services. |
openstreetmap is accepted by the schema but has no backend yet and is rejected with a 422. It is listed here so you know why, rather than discovering it in a failed workflow run.
Poll for results
GET /api/v1/search/:id. Rows arrive as they are found, not only at the end — so you can start sending on the first hundred while the rest are still being verified. Poll every five to ten seconds until status is done.
curl https://goleadx.com/api/v1/search/9f2c1b7e-… \
-H "Authorization: Bearer glx_live_xxxx"{
"id": "9f2c1b7e-…",
"status": "running",
"error": null,
"progress": {
"stage": "Verifying",
"found": 247,
"withEmail": 112,
"processed": 180,
"total": 247
},
"creditsUsed": 0,
"createdAt": "2026-08-24T09:12:04.221Z",
"finishedAt": null,
"leads": [
{
"name": "Bayview Dental Studio",
"email": "hello@bayviewdental.com",
"emailStatus": "valid",
"phone": "+1 305 555 0134",
"website": "https://bayviewdental.com",
"domain": "bayviewdental.com",
"address": "1200 Biscayne Blvd, Miami, FL",
"city": "Miami",
"country": "US",
"category": "Dentist",
"otherEmails": ["info@bayviewdental.com"],
"socials": { "instagram": "bayviewdental" },
"source": "google_maps",
"sourceUrl": "https://maps.google.com/…"
}
]
}Response fields
| Field | Notes |
|---|---|
status | One of queued, running, done, failed, cancelled. |
error | null unless status is failed, in which case it explains why. |
progress.stage | Human-readable phase: discovering, crawling, verifying, and so on. Useful for a status display, not for branching logic. |
progress.found | Businesses discovered so far. |
progress.withEmail | How many of those yielded a contactable address. This is the number that matters for sending volume. |
creditsUsed | Settles when the job finishes. Zero while running. |
leads[] | Everything found so far. Grows between polls; it is the full array each time, not a delta. |
emailStatus | valid, risky or invalid. Filter to valid before sending — risky addresses are what damage a sender reputation. |
Every lead field except name and source can be null. A business with no website has no email, and a listing without a published phone number returns null rather than an empty string. Handle nulls rather than assuming presence — this is the single most common cause of a workflow breaking on row 40 of 200.
Complete examples
Each of these does the whole job: start the search, poll until it finishes, filter to deliverable addresses. They are the shortest correct version rather than the shortest version.
JavaScript / Node
const KEY = process.env.GOLEADX_KEY;
const API = "https://goleadx.com/api/v1";
const headers = {
Authorization: `Bearer ${KEY}`,
"Content-Type": "application/json",
};
async function search(body) {
const start = await fetch(`${API}/search`, {
method: "POST",
headers,
body: JSON.stringify(body),
});
if (!start.ok) throw new Error(`start failed: ${start.status} ${await start.text()}`);
const { id } = await start.json();
// Poll until the job settles. Ten seconds is polite and fast enough —
// the 60/min rate limit is per account, and a tight loop across several
// workflows is the usual way people hit it.
for (;;) {
await new Promise((r) => setTimeout(r, 10_000));
const res = await fetch(`${API}/search/${id}`, { headers });
if (res.status === 429) continue; // backed off; try again
if (!res.ok) throw new Error(`poll failed: ${res.status}`);
const job = await res.json();
console.log(job.progress.stage, job.progress.found, "found");
if (job.status === "done") return job.leads;
if (job.status === "failed") throw new Error(job.error ?? "search failed");
if (job.status === "cancelled") return job.leads;
}
}
const leads = await search({
queries: ["dental clinics"],
locations: ["Miami", "Orlando"],
sources: ["google_maps"],
maxResults: 200,
});
const sendable = leads.filter((l) => l.emailStatus === "valid" && l.email);
console.log(`${sendable.length} deliverable of ${leads.length}`);Python
import os, time, requests
KEY = os.environ["GOLEADX_KEY"]
API = "https://goleadx.com/api/v1"
headers = {"Authorization": f"Bearer {KEY}"}
def search(payload):
r = requests.post(f"{API}/search", json=payload, headers=headers, timeout=30)
r.raise_for_status()
job_id = r.json()["id"]
while True:
time.sleep(10)
r = requests.get(f"{API}/search/{job_id}", headers=headers, timeout=30)
if r.status_code == 429:
time.sleep(int(r.headers.get("Retry-After", 30)))
continue
r.raise_for_status()
job = r.json()
print(job["progress"]["stage"], job["progress"]["found"], "found")
if job["status"] == "done":
return job["leads"]
if job["status"] == "failed":
raise RuntimeError(job.get("error") or "search failed")
leads = search({
"queries": ["roofing contractors"],
"locations": ["Leeds", "Manchester", "Sheffield"],
"sources": ["google_maps", "web_search"],
"maxResults": 300,
})
sendable = [l for l in leads if l.get("email") and l["emailStatus"] == "valid"]
print(len(sendable), "deliverable of", len(leads))curl, start to finish
# 1. start, keeping the id
ID=$(curl -s -X POST https://goleadx.com/api/v1/search \
-H "Authorization: Bearer $GOLEADX_KEY" \
-H "Content-Type: application/json" \
-d '{"queries":["gyms"],"locations":["Berlin"],"sources":["google_maps"],"maxResults":50}' \
| jq -r .id)
# 2. poll until done
until [ "$(curl -s https://goleadx.com/api/v1/search/$ID \
-H "Authorization: Bearer $GOLEADX_KEY" | jq -r .status)" = "done" ]; do
sleep 10
done
# 3. deliverable addresses only
curl -s https://goleadx.com/api/v1/search/$ID \
-H "Authorization: Bearer $GOLEADX_KEY" \
| jq -r '.leads[] | select(.emailStatus=="valid") | [.name,.email,.city] | @csv'Using it in n8n
- HTTP Request — POST to
https://goleadx.com/api/v1/search. Authentication: Generic Credential Type → Header Auth, nameAuthorization, valueBearer glx_live_xxxx. Body: JSON, as above. Store the credential once and reuse it on every node — pasting the key into each node is how it ends up in an exported workflow. - Wait — 30 seconds. Most searches finish well inside a couple of minutes, and polling faster than this buys nothing.
- HTTP Request — GET
{{ $json.poll }}with the same credential. Thepollfield in the start response is a complete URL, so you do not have to rebuild it. - IF — if
{{ $json.status }}is notdone, loop back to the Wait node. Add a second condition onfailedso a broken search stops the workflow instead of looping until the execution times out. - Item Lists → Split Out on
leads, then Filter toemailStatus = validbefore sending. Risky addresses are the ones that cost you a sender reputation.
The same shape works in Make (HTTP module → Sleep → HTTP → Router) and in Zapier, though Zapier’s step limits make the polling loop awkward — for Zapier it is usually easier to start the search in one Zap and pick the results up in a scheduled second one.
Limits, errors and retries
| Code | Meaning | What to do |
|---|---|---|
401 | Missing, invalid or revoked key. | Do not retry. Check the header format — it is Bearer then a space then the key. |
402 | No active subscription, or out of credits. | Do not retry. The message distinguishes the two. |
403 | The plan does not include API access. | Do not retry. Response carries plan and an upgrade URL. |
404 | No such search under this account. | Do not retry. Another account’s job reports as missing rather than forbidden, on purpose. |
422 | Invalid body, or an unavailable source. | Do not retry unchanged. details names the offending fields. |
429 | Over 60 requests a minute, or 5 searches already running. | Wait for Retry-After seconds, then retry. |
Rate limits are per account, not per key, so issuing a second key does not raise them. Sixty requests a minute is generous for polling and easy to exceed if several workflows poll the same account in a tight loop — poll every ten seconds and you will never see a 429.
Credits
One result is one credit. Credits are reserved when a search starts, based on maxResults, and settled when it finishes — so a search that requested 500 and found 180 consumes 180. If your remaining balance is lower than maxResults, the search is capped at what you have rather than rejected.
Error response shape
{
"error": "Invalid request body.",
"details": { "queries": ["Array must contain at least 1 element(s)"] }
}Things worth knowing before you build
- No tool can return an address a business never published. Expect a contactable email for roughly a third to two thirds of what a search finds, depending on industry and country. Plan sending volumes on
progress.withEmail, not onfound. - Poll on an interval, not in a tight loop. The job runs server-side and polling faster does not make it finish sooner. Ten seconds is the right number.
leadsis cumulative. Each poll returns the full array, not the rows added since last time. Deduplicate bydomainoremailif you process partial results as they arrive.- Searches are per account, not per key. A job started with one key is visible to every key on the same account and invisible to everyone else.
- Filter to
validbefore sending. Risky addresses are the ones that produce bounces, and bounces are what damage a sending domain. - There is no webhook yet. Polling is the only completion signal. If you need a fire-and-forget pattern, store the id and check it on a schedule.
Get a key
API access is on the Scale plan. Create an account, subscribe, then issue a key from the Billing panel — the whole thing takes about two minutes, and /api/v1/me will tell you immediately whether it works.