The API
documentation
LocalContact finds the actual decision makers (names and job titles) at local businesses, and can add their email addresses and phone numbers. The website is one way to use it. The API is the other: it lets your own programs do the same thing automatically, without clicking around a website.
This guide assumes zero programming experience beyond being able to copy, paste, and press Enter. Every term is explained. If anything is unclear, scroll to the glossary or the FAQ.
Search for people by company name + address. Enrich contacts with emails, phones, and LinkedIn. Upload a CSV of hundreds of companies and process them all. Get notified when work finishes.
Reading this page top to bottom: about 20 minutes. Running your first successful search with the copy-paste examples: about 5 minutes. You do not need to read everything to get started.
You send us a company name and address with your secret API key in the request header; a few seconds later you ask "is it done?" and when it is, you get back a list of people — names, job titles, and (optionally) their contact details.
What you need first
Three things. If any are missing, the API will politely refuse to work.
-
A Scale plan
API access is a Scale-plan feature. If your account is on a different
plan, every API call returns
403with a message telling you to upgrade. You can check your plan on the Billing page. - Some credits Credits are the pre-paid currency the API charges per action — like arcade tokens. A search costs 1 credit per company for up to 5 job titles (each additional set of 5 titles is another credit); enrichment is priced per template — Deep Enrich 1 credit/contact, Email Finder 2 credits/contact — billed per contact processed. See Credits & pricing for the full table and the rules (including when you get money back).
- An API key A long password-like string that proves each request is really yours. The next section shows exactly how to get one. It takes about 30 seconds.
The examples use curl, a small program that is already installed on
Mac (open Terminal) and Windows 10/11 (open PowerShell).
Paste a command, press Enter, and the response appears as text.
If you use Windows PowerShell and get a quoting error, try replacing each
outer double-quote with a single-quote in the curl examples.
Two addresses you need to know
Every request goes to a base URL (the street address of the API) plus an endpoint (the room inside the building):
| Environment | Base URL | Use it when |
|---|---|---|
| Live (production) | https://api.localcontact.ai/api | Real work with real credits |
| Local development | http://localhost:8000/api | Running the open-source stack yourself with Docker |
So "the search endpoint" is really the full address
https://api.localcontact.ai/api/v1/search.
Get your API key
Your API key is a house key for your account. Anyone holding it can spend your credits, so treat it exactly like a password.
- Log in and open API Access Log in at localcontact.ai, then click API Access in the left menu (it only appears on the Scale plan).
- Create a key Click Create Key, give it a name you'll recognize later (like "my-sales-script"), and confirm.
-
Copy it immediately
The key looks like
ff_live_followed by 32 random characters. It is shown exactly once — after that page, we can only show you a masked preview. If you lose it, delete the key and make a new one.
Never put the key in public places: browser-side JavaScript, a public GitHub repo, a screenshot, a Slack message. If a key leaks, delete it on the API Access page immediately — deleting it disconnects whoever is using it.
Your first search in 5 minutes
Searches are not instant — real work happens behind the scenes (usually 10–60 seconds). So a search is always three moves: start it, check on it, collect the results. You'll do all three now.
Step 1 — Start a search
Paste this into your terminal. Replace ff_live_YOUR_KEY_HERE with your
real key first. It asks: "who is in charge at Joe's Pizza in Austin, TX?"
(the JSON drops the apostrophe — "Joes Pizza" — so the command pastes
cleanly into any shell).
curl -X POST https://api.localcontact.ai/api/v1/search \
-H "Content-Type: application/json" \
-H "api_token: ff_live_YOUR_KEY_HERE" \
-d '{
"company": "Joes Pizza",
"address": "123 Main St, Austin, TX",
"titles": ["Owner", "Manager"]
}'
What each line means: -X POST says we're sending data,
not just reading. The two -H lines are headers —
little labels attached to the request. The first says the body is JSON; the second is
your key (your proof of identity). The -d part is the actual question,
written as JSON. Prefer the standard style? -H "Authorization: Bearer ff_live_YOUR_KEY_HERE"
works exactly the same everywhere.
What you'll see (a "receipt" for your search):
{
"cached": false,
"task_id": "019dbf57-e235-71f5-ac0c-b45509edd5aa",
"message": "Search task initiated successfully",
"cost": { "search": 1.0, "enrichment": 0.0, "total": 1.0, "balance": 49.0 }
}
Write down that task_id — it's your claim ticket. Notice the
balance dropped by exactly the cost shown. Nothing was enriched here, so that part is 0.
Step 2 — Check if it's done
curl https://api.localcontact.ai/api/v1/tasks/019dbf57-e235-71f5-ac0c-b45509edd5aa \ -H "api_token: ff_live_YOUR_KEY_HERE"
While it's working you'll see "status": "retrieving" or
"extracting" with a progress number. When it finishes:
{
"id": "019dbf57-e235-71f5-ac0c-b45509edd5aa",
"status": "completed",
"progress": 100,
"created_at": "2026-08-14T10:00:01+00:00",
"leads_found": 3,
"results_url": "/api/v1/tasks/019dbf57-e235-71f5-ac0c-b45509edd5aa/results"
}
Step 3 — Collect the people
curl "https://api.localcontact.ai/api/v1/tasks/019dbf57-e235-71f5-ac0c-b45509edd5aa/results" \ -H "api_token: ff_live_YOUR_KEY_HERE"
{
"data": [
{
"id": "42",
"name": "Joseph Marino",
"title": "Owner",
"company_name": "Joe's Pizza",
"company_domain": "joespizza-austin.com",
"company_type": "Restaurant",
"company_address": "123 Main St, Austin, TX",
"address": "123 Main St, Austin, TX",
"enrichment_data": null,
"enriched_at": null
}
],
"meta": { "total": 3, "page": 1, "per_page": 20, "last_page": 1 }
}
enrichment_data is null because a basic search returns
names and titles only. Adding emails/phones is a second, optional step —
see Enrichment.
Start → poll → collect. Every feature in this API is a variation on those three moves. The rest of this page is detail, options, and ready-made code you can paste into real projects.
Core ideas in plain English
Tasks: work is done in the background
A search takes longer than a normal web request, so instead of making you wait, the API immediately creates a task (a job ticket) and gives you its ID. Your program then either asks whether the task is done ("polling" — like checking the oven every minute) or, better, gives us a webhook so we knock on your door the moment it's ready.
Bulk tasks can also end up cancelled (you stopped them) — partial
results are still downloadable.
Job titles: you say who you're looking for
Every search includes titles — the roles you care about, like
Owner or Practice Manager. We search for people holding those
titles at that company. Titles are processed in batches of 5: 1–5
titles is one search (one charge); 6–10 titles is two searches (two charges).
Order doesn't matter — ["Owner","Manager"] and
["Manager","Owner"] are treated as the same search.
The master list: why repeat searches are cheaper
When we find someone's contact details, we remember them (anonymized, keyed to the person + company), so the next search for the same company comes back fast. Searching a company you searched in the last 30 days costs 0.5 credits. The discount is applied to your balance when the search completes.
Depth: accepted but ignored
Older clients could send a depth field (1–5 for single search,
1–8 for bulk) to search deeper. The current API runs every search as a single
standard pass: depth is still accepted so old integrations keep
working, but it changes nothing — not the results and not the price.
No matter what depth you send, the price is 1 credit per company
for up to 5 job titles; each additional set of 5 titles is another
credit.
If you're writing new code, just leave it out.
Credits & pricing
Credits are prepaid tokens. Every action has a price, and you see the exact price in every response before/after it runs. Prices are set by the LocalContact team and can be tuned over time — the numbers below are the defaults.
| Action | Default price | Plain English |
|---|---|---|
| Single search | 1 credit per company | Covers up to 5 job titles; each additional set of 5 titles is another credit |
| — repeat search | 0.5 credits | Same company searched in the last 30 days |
| Enrichment | Per template: Deep Enrich 1 credit/contact, Email Finder 2 credits/contact | Adding emails/phone/LinkedIn to one person; billed per contact processed — the search runs per row whether or not it finds anything, and rows that never run are refunded |
| Business Signals | 2 credits per search | Per signal group per company (news, hiring, reviews, etc.); ad-activity groups cost 4–8 |
| Bulk row | 1 credit per company | One row of your CSV |
| Failed search | Refunded fully | If the search itself fails, your credits come back |
Failed = free. If a task ends failed, the full search
cost is refunded to your balance automatically.
Enrichment reserves are estimates. When you combine search + enrichment, we reserve credits for the maximum possible contacts, then refund the difference once we know how many people were actually found.
Enrichment is priced per template. Deep Enrich costs 1 credit per contact and Email Finder 2 credits per contact, billed per contact processed: the search runs per row, found or not, and only rows that never run are refunded.
If your plan is metered (pay-per-use invoiced monthly) instead of prepaid credits,
searches don't deduct anything — the response shows "balance": null
and usage accrues to your monthly invoice instead. Everything else behaves the same.
Run a search
/v1/search
Starts one search. Returns 202 with a task_id. Costs are
deducted (or reserved) immediately; the cost object in the response shows
exactly what and how much.
What you can send
| Field | Required? | Type | What it means |
|---|---|---|---|
company | Yes | text (max 255) | Business name, e.g. "Joe's Pizza" |
address | Yes | text (max 200) | Street/city/state. More specific = better results. |
titles | Yes | list of texts | Job titles to look for. 1–5 titles = 1 search; each extra batch of 5 = +1 charge. |
depth | Optional; currently ignored (forced to 1) | 1–5 | Accepted for backward compatibility but ignored. Every search is one standard pass: 1 credit per company for up to 5 job titles, and each additional set of 5 titles is another credit. |
enrich | Optional | true/false | Also fetch emails/phones for everyone found. Needs an enrichment-enabled plan — without one the flag is silently ignored and the search runs names-only (only the /v1/enrich* endpoints return 403). |
mode | Optional | find / find_enrich / enrich | Cleaner alternative to enrich. find = names only; find_enrich = both. |
email_types | Optional | list: personal, work, generic | When enriching, which email kinds to accept. Default: all three. |
webhook_url | Optional | https URL | We POST here when the task finishes, so you don't have to poll. See webhooks. |
Search + enrichment in one request
curl -X POST https://api.localcontact.ai/api/v1/search \
-H "Content-Type: application/json" \
-H "api_token: ff_live_YOUR_KEY_HERE" \
-d '{
"company": "Bright Smile Dental",
"address": "88 Oak Ave, Dallas, TX",
"titles": ["Dentist", "Owner"],
"mode": "find_enrich",
"email_types": ["work", "personal"]
}'
{
"cached": false,
"task_id": "019dc1a2-....",
"message": "Search task initiated successfully",
"cost": {
"search": 1.0,
"enrichment": 20.0,
"total": 21.0,
"balance": 79.0
}
}
Enrichment reserved 20 credits here because a search can return up to 10 people, at 2 credits each. If the search finds only 2 people, 16 credits come back.
Plans limit concurrent searches (default: 1). Fire a second
search while the first is still running and you'll get 429 with a
friendly message. Wait for the first to complete, or upgrade the plan.
Check status & get results
/v1/tasks/{task_id}
Status of one task. While running: status + progress (0–100).
When completed, also includes leads_found (deduplicated count) and
results_url. When failed, includes a plain-English error.
results_url always matches how you authenticated: with an API key
on /v1/tasks/{task_id} it points to
/api/v1/tasks/{task_id}/results; the
/api/search/tasks/{task_id} form only appears for logged-in
dashboard sessions. GET the URL exactly as given, with the same
api_token header you use everywhere else — an API-key
client that swaps in the other URL style gets a 401.
{ "id": "019dbf57-....", "status": "extracting", "progress": 70, "created_at": "2026-08-14T10:00:01+00:00" }
/v1/tasks/{task_id}/results
The people found, paginated. Add ?page=2 and/or
?per_page=50 (5–100, default 20). Returns 422 until the
task is completed. Each person includes enrichment_data
(filled in after enrichment — its shape is documented under
Enrichment).
/v1/tasks
All your tasks, newest first. Optional filters: ?page=,
?per_page= (5–100), and ?type=single or
?type=bulk to see only one kind.
Bulk search (a whole CSV)
Have 200 companies? Don't call the search endpoint 200 times. Upload a CSV (a simple spreadsheet saved as text) and one background job walks through every row. You can watch progress and download the finished spreadsheet at the end.
/v1/bulk/upload
This request is multipart form data (file upload), not JSON — curl's
-F flag handles that. Required: the file and your job titles.
curl -X POST https://api.localcontact.ai/api/v1/bulk/upload \ -H "api_token: ff_live_YOUR_KEY_HERE" \ -F "file=@companies.csv" \ -F "titles[]=Owner" \ -F "titles[]=Manager" \ -F "skip_previous=true"
The CSV file: keep it boring
company,location Joe's Pizza,123 Main St Austin TX Bright Smile Dental,88 Oak Ave Dallas TX Corner Hardware,4400 Lamar Blvd Austin TX
Two columns is all you need: company and location
(city/address works). If your headers contain the words "company", "location",
"address", or "city", we find the right columns automatically; otherwise pass
company_column= / location_column= to point us at them
by name. Max 20 MB per file.
What you can send
| Form field | Required? | What it means |
|---|---|---|
file | Yes | The CSV file (or .txt). Max 20 MB. |
titles[] | Yes | Job titles, one per titles[] field. |
depth | Optional | 1–8. Accepted for backward compatibility but ignored — each row runs one standard pass and costs 1 credit. |
company_column | Optional | Header name of the company column, if auto-detect guesses wrong. |
location_column | Optional | Same, for the location column. |
skip_previous | Optional | true = skip companies already searched in the last 30 days (saves credits). |
file_name | Optional | Friendly name shown in the dashboard instead of the filename. |
webhook_url | Optional | Called once when the whole batch finishes. |
{
"task_id": "019dc2b3-....",
"total_companies": 198,
"skipped_no_company": 1,
"skipped_no_location": 1,
"skipped_previous": 12,
"status": "pending",
"message": "Bulk search job queued successfully"
}
Duplicate companies (same name + location, case-insensitive) are charged once.
Rows missing a company or location are skipped and counted for you. With
skip_previous=true, companies you already searched in the last 30 days
are skipped at no charge — that's the skipped_previous count in
the response above.
Watching and collecting a bulk job
| Call | What it gives you |
|---|---|
GET /v1/bulk/tasks | List of your bulk tasks |
GET /v1/bulk/tasks/{id} | Status, progress %, rows processed / total, credits used |
GET /v1/bulk/tasks/{id}/results | People found so far, paginated JSON |
GET /v1/bulk/tasks/{id}/download | The finished results as a CSV file (available when completed) |
A bulk job needs 1 credit × number of unique companies
available before it will even start — otherwise you get 402 with
the exact shortfall. No partial charging happens in that case.
Enrichment: emails & phone numbers
A search tells you who exists. Enrichment adds how to reach them: email addresses (personal / work / pattern-guessed / generic), phone number, and LinkedIn profile. Pricing is per template — Deep Enrich 1 credit per contact, Email Finder 2 credits per contact — billed per contact processed (the search runs per row, found or not; rows that never run are refunded).
What enrichment_data looks like
{
"full_name": "Joseph Marino",
"personal_emails": [ { "address": "joe.m@gmail.com", "source": "..." } ],
"work_emails": [ { "address": "joe@joespizza-austin.com" } ],
"pattern_emails": [ { "address": "j.marino@joespizza-austin.com" } ],
"generic_emails": [ { "address": "info@joespizza-austin.com" } ],
"linkedin_url": "https://linkedin.com/in/joseph-marino-....",
"phone": "+1 512 555 0134",
"bio": "Owner of Joe's Pizza since 2012 ...",
"credentials": "..."
}
Every list can be empty — not every person has a findable email. The
email_types option on search/enrich requests controls which kinds
you accept (personal, work, generic).
/v1/enrich
Enrich people from a search you already ran. Give a
task_id; by default we enrich everyone in that task who isn't enriched
yet. Optionally pass contact_ids (from the results endpoint) to pick
specific people. Returns 202; poll the task's results endpoint.
curl -X POST https://api.localcontact.ai/api/v1/enrich \
-H "Content-Type: application/json" \
-H "api_token: ff_live_YOUR_KEY_HERE" \
-d '{ "task_id": "019dbf57-....", "email_types": ["work"] }'
{
"task_id": "019dbf57-....",
"enriching": 3,
"cost": { "credits": 6.0, "balance": 43.0 },
"message": "Enrichment started. Poll GET /v1/tasks/019dbf57-..../results for results."
}
/v1/enrich/contact
Enrich one person, no prior search needed — you just need a name and company (+ address). This one is synchronous: the answer comes back in the same response (no polling). If we've enriched this exact person before, it returns instantly.
You are not charged. The response returns "found": false and
"data": null with cost.credits: 0 — any reserved
charge is refunded automatically. A successful find returns
"found": true with the contact details.
curl -X POST https://api.localcontact.ai/api/v1/enrich/contact \
-H "Content-Type: application/json" \
-H "api_token: ff_live_YOUR_KEY_HERE" \
-d '{
"name": "Joseph Marino",
"company": "Joes Pizza",
"address": "123 Main St, Austin, TX",
"title": "Owner",
"domain": "joespizza-austin.com"
}'
{
"data": { "full_name": "Joseph Marino", "work_emails": [ "..." ], "...": "..." },
"found": true,
"cached": false,
"cost": { "credits": 2.0, "balance": 41.0 }
}
/v1/enrich/batch
Up to 100 people at once, no prior search needed. Returns
202 with a new task_id (type enrichment) to poll.
Already-known contacts are served from the master list instantly — the
response's cached and enriching counts tell you how many
were already known vs. queued.
curl -X POST https://api.localcontact.ai/api/v1/enrich/batch \
-H "Content-Type: application/json" \
-H "api_token: ff_live_YOUR_KEY_HERE" \
-d '{
"contacts": [
{ "name": "Joseph Marino", "company": "Joes Pizza", "address": "123 Main St, Austin, TX" },
{ "name": "Ana Ruiz", "company": "Bright Smile Dental","address": "88 Oak Ave, Dallas, TX" }
]
}'
{
"task_id": "019dc3c4-....",
"total": 2, "cached": 1, "enriching": 1,
"cost": { "credits": 4.0, "balance": 37.0 },
"message": "Poll GET /v1/tasks/019dc3c4-..../results for results."
}
List signal groups
Business Signals are dated, evidence-linked intelligence per company: news and events, hiring activity, review sentiment, ad spend, website gaps, and local competitor rank. Each group costs credits per provider search (most are 2; ad-activity groups cost 4–8).
/signals/groups
Returns the catalog of available groups with their price and the signals each one reports:
| Group key | Price | What it finds |
|---|---|---|
news | 2 cr | Expansions, leadership changes, partnerships, awards, media mentions |
hiring | 2 cr | Currently hiring, open roles, hiring velocity, role-keyword matches |
reviews | 2 cr | Review sentiment, rating, complaint themes (country-appropriate platforms) |
website | 2 cr | Missing chatbot/booking/ordering, opening hours, best time to call |
competitors | 2 cr | Local competitor list + target's presence/position in best-of results |
ads_core | 4 cr | Active ads on Meta + Google public libraries (with first/last-seen dates) |
ads_full | 8 cr | All four ad libraries: Meta, Google, TikTok, LinkedIn |
Note: Signals endpoints require session auth (login), not API-key auth.
They are available via the dashboard at /dashboard/signals.
Run a signals check
One company, one or more signal groups. Every signal in the response carries dates, a recency label ("last week", "3 months ago"), and evidence URLs.
/signals/run
| Field | Required | Type | What it does |
|---|---|---|---|
company | Yes | string | Business name |
address | Yes | string | Full street address (pins the exact business) |
domain | Optional | string | Business website domain (improves ad-activity accuracy) |
groups | Yes | string[] | One or more group keys from the catalog |
keywords | Optional | object | Per-group keywords (e.g. {"hiring": ["manager", "chef"]}) |
The competitors group requires a category keyword
(e.g. "bbq", "emergency plumber") in keywords.competitors.
Get signals results
/signals/tasks/{task_id}
Returns the task status plus per-group results. Each signal in
groups[].signals[] carries:
| Field | What it means |
|---|---|
key | Signal identifier (e.g. expansion, currently_hiring) |
found | Whether the signal was detected |
value | The specific finding, named (not a vague label) |
detected_date | When the event/content is dated (null if undated) |
recency | Human-readable: "today", "last week", "3 months ago", "undated" |
evidence_urls | 1–2 source URLs proving the finding |
Only signals from the last 12 months are returned; older events are excluded automatically.
Webhooks: we call you
Polling is checking the oven every minute. A webhook is the oven timer ringing. Give us a URL with any search, and the moment the task finishes we POST the results to that URL — no polling loop needed.
Add "webhook_url": "https://yoursite.com/localcontact-hook" to any
search or bulk request. When the task finishes we send:
{
"task_id": "019dbf57-....",
"status": "completed",
"company": "Joe's Pizza",
"address": "123 Main St, Austin, TX",
"leads_count": 3,
"leads": [
{ "name": "Joseph Marino", "title": "Owner", "company_name": "Joe's Pizza",
"company_domain": "joespizza-austin.com", "company_type": "Restaurant",
"company_address": "123 Main St, Austin, TX" }
],
"completed_at": "2026-08-14T10:00:47+00:00"
}
Making sure it's really us
If the LocalContact team has configured a signing secret for your account, every
webhook also arrives with a header X-LocalContact-Signature — a
cryptographic fingerprint of the body. Verify it before trusting the data
(this is how you know a stranger isn't posting fake leads to your URL):
const crypto = require('crypto');
// Capture the RAW request bytes BEFORE JSON parsing. The signature is
// computed over the exact bytes we transmitted, so register this raw
// middleware ahead of your JSON parser (or use body-parser's
// bodyParser.raw({ type: '*/*' }) in the same position).
app.post('/localcontact-hook', express.raw({ type: '*/*' }), (req, res) => {
const expected = crypto
.createHmac('sha256', process.env.WEBHOOK_SIGNING_SECRET)
.update(req.body) // Buffer of the raw request body
.digest('hex');
if (expected !== req.header('X-LocalContact-Signature')) {
return res.status(401).send('bad signature');
}
// Genuine. Parse the payload and carry on.
const payload = JSON.parse(req.body.toString());
res.sendStatus(200);
});
HMAC the raw request body exactly as it arrived. Re-serializing
(e.g. JSON.stringify(req.body)) will not match:
the signature covers the exact transmitted bytes, including how the
JSON was escaped on the wire (slashes, unicode, spacing).
Failed deliveries are retried only when it looks like your side is having trouble: a 5xx response or a timeout (8-second limit). Those get up to 3 total attempts, about 30 seconds and then 1 minute apart. A 4xx response (404, 410, 422 …) is treated as a deliberate rejection: it is logged on our side once and not retried — fix the URL and the next task's webhook will reach it. Respond with any 2xx status quickly, and always verify the signature if one is present.
Rate limits & errors
Rate limit
1,500 requests per hour per API key. Checking status once every
few seconds uses almost nothing — you'd have to poll aggressively to hit it.
If you do exceed it, you receive 429 with a
retry_after field (seconds). Your key isn't punished; just wait and retry.
Every error, and what to do
| Code | Meaning | What to do |
|---|---|---|
| 401 | Missing or wrong API key | Check the api_token header. Typos? Key deleted? Make a new one on the API Access page. |
| 402 | Not enough credits | The response names the price and what you have: search and bulk endpoints say required vs available; the /v1/enrich* endpoints say required vs balance. Same meaning either way. Top up on the Billing page, or send fewer titles. |
| 403 | Plan doesn't include this | API access needs the Scale plan; the /v1/enrich* endpoints need an enrichment-enabled plan. Note: POST /v1/search with enrich=true (or mode=find_enrich) on a plan without enrichment does not 403 — it silently runs a names-only search. |
| 404 | Task not found | Wrong task_id, or the task belongs to a different account (each account only sees its own tasks). |
| 422 | Something in the request is off, or results aren't ready | Read errors in the response — it names the exact field. For results, it means the task isn't completed yet. |
| 429 | Too many requests / too many concurrent searches | Wait (retry_after tells you how long), or wait for your running search to finish. |
| 500 | Our side broke | Retry in a minute. If it persists, contact support with the task_id — failed searches are refunded automatically. |
Code recipes
Complete, working programs — not fragments. Each one searches, waits, and prints the people found. Swap in your key and run.
JavaScript (browser or Node 18+)
const API = 'https://api.localcontact.ai/api';
const KEY = 'ff_live_YOUR_KEY_HERE';
const headers = { 'Content-Type': 'application/json', 'api_token': KEY };
// 1. Start the search
const start = await fetch(`${API}/v1/search`, {
method: 'POST', headers,
body: JSON.stringify({
company: "Joe's Pizza",
address: '123 Main St, Austin, TX',
titles: ['Owner', 'Manager'],
}),
}).then(r => r.json());
console.log('Started task', start.task_id, '— cost', start.cost.total, 'credits');
// 2. Poll every 5 seconds until done
let task;
while (true) {
task = await fetch(`${API}/v1/tasks/${start.task_id}`, { headers }).then(r => r.json());
console.log(` ${task.status} (${task.progress}%)`);
if (task.status === 'completed' || task.status === 'failed') break;
await new Promise(r => setTimeout(r, 5000));
}
if (task.status === 'failed') throw new Error(task.error);
// 3. Collect the people
const results = await fetch(`${API}/v1/tasks/${start.task_id}/results`, { headers }).then(r => r.json());
for (const person of results.data) {
console.log(`- ${person.name} — ${person.title} at ${person.company_name}`);
}
Python
import time, requests
API, KEY = 'https://api.localcontact.ai/api', 'ff_live_YOUR_KEY_HERE'
headers = {'api_token': KEY}
# 1. Start the search
start = requests.post(f'{API}/v1/search', headers=headers, json={
'company': "Joe's Pizza",
'address': '123 Main St, Austin, TX',
'titles': ['Owner', 'Manager'],
}).json()
print('Started task', start['task_id'], '— cost', start['cost']['total'], 'credits')
# 2. Poll every 5 seconds until done
while True:
task = requests.get(f"{API}/v1/tasks/{start['task_id']}", headers=headers).json()
print(f" {task['status']} ({task['progress']}%)")
if task['status'] in ('completed', 'failed'):
break
time.sleep(5)
if task['status'] == 'failed':
raise SystemExit(task['error'])
# 3. Collect the people
results = requests.get(f"{API}/v1/tasks/{start['task_id']}/results", headers=headers).json()
for person in results['data']:
print(f"- {person['name']} — {person['title']} at {person['company_name']}")
No code at all
If you don't want to write code, the dashboard does everything in this document with buttons: single search, CSV upload with live progress, enrichment, and one-click CSV export. The API exists for when you want it inside your tools.
Glossary
Every technical word used on this page, in one sentence each.
| Word | What it actually means |
|---|---|
| API | A service programs can talk to, instead of a website humans click. |
| Endpoint | One specific "page" of the API, like /v1/search. Each does one job. |
| JSON | Text format shaped like {"key": "value"}. The API's native language, both ways. |
| Header | A label attached to a request, like an envelope marking. Yours is api_token: .... |
| POST / GET | POST sends data in (start something). GET asks for data back (read something). |
| HTTP status | The 3-digit "mood" of a response: 200 happy, 202 accepted-but-working, 402 no credits, and so on. |
| Task | A background job with a ticket number (task_id). Searches and batch enrichments are tasks. |
| Polling | Repeatedly asking "done yet?" until the answer is yes. |
| Webhook | You give us a URL; we call it when work finishes. The oven timer, not the oven-checking. |
| Credit | Prepaid unit of work. A search costs 1 per company for up to 5 job titles (each extra set of 5 is another credit); enrichment is priced per template (Deep Enrich 1, Email Finder 2 credits per contact processed). |
| CSV | A spreadsheet saved as plain text, columns separated by commas. Excel and Google Sheets both export it. |
| Decision maker | The person at a business who can say yes — owner, manager, director. What we find. |
| Enrichment | Adding contact details (emails, phone, LinkedIn) to a person we found. |
| Repeat search | Searching a company again within 30 days — half price (0.5 credits) and returned fast. |
| curl | A command-line program for making requests. Preinstalled on Mac and Windows 10+. |
| Pagination | Serving long lists in pages (20 items by default) instead of one huge dump. |
FAQ & troubleshooting
"Unauthorized" / 401 immediately
The key in the header doesn't match. Common causes: a copied trailing space, using the masked preview from the dashboard instead of the full key (the full key is shown only once at creation), or the key was deleted. Create a fresh key and try again.
402 but I just bought credits
The response tells you required and available. Combining
search + enrichment reserves the maximum upfront (up to 10 people × 2 credits
each) — the difference is refunded. To start cheaper, search first, then
enrich only the specific people you want via POST /v1/enrich with
contact_ids.
The search found 0 people
Most often the company name or address is too vague or misspelled. Add a street address or city. Try broader titles ("Owner" finds more than "Chief Pizza Officer"). If the task failed (different from finding 0), credits are refunded automatically — check the status endpoint for the error message.
How long until a search finishes?
Usually 10–60 seconds single; bulk takes roughly 10–30 seconds per company. Repeat searches (same company within the last 30 days) are near-instant. Poll every 5 seconds — that's polite and fast.
Can I see what my key spent?
Yes — the API Access page in the dashboard shows per-key analytics and a log of every request (with credit cost) for all your keys.
Is there a sandbox / test mode?
No separate sandbox. To experiment safely: search a couple of companies, watch the
cost field in each response, and remember failures are refunded and
repeat searches are half price. For a full local copy, the project runs in Docker —
see the repository's RUNNING_LOCALLY.md.
Something in these docs is wrong or unclear
Open a support ticket from the dashboard (Support page) and paste the
task_id — we can trace exactly what happened and fix either the
behavior or this page.