LocalContact API Docs
Start here

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.

What you can do

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.

How long this takes

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.

The one-sentence version

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.

Before you start

What you need first

Three things. If any are missing, the API will politely refuse to work.

  1. A Scale plan API access is a Scale-plan feature. If your account is on a different plan, every API call returns 403 with a message telling you to upgrade. You can check your plan on the Billing page.
  2. 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).
  3. 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.
Where do I type these commands?

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):

EnvironmentBase URLUse it when
Live (production)https://api.localcontact.ai/apiReal work with real credits
Local developmenthttp://localhost:8000/apiRunning the open-source stack yourself with Docker

So "the search endpoint" is really the full address https://api.localcontact.ai/api/v1/search.

Setup

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.

  1. 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).
  2. Create a key Click Create Key, give it a name you'll recognize later (like "my-sales-script"), and confirm.
  3. 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.
Keep it secret

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.

How it works

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.

How it works

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.

ActionDefault pricePlain English
Single search1 credit per companyCovers up to 5 job titles; each additional set of 5 titles is another credit
  — repeat search0.5 creditsSame company searched in the last 30 days
EnrichmentPer template: Deep Enrich 1 credit/contact, Email Finder 2 credits/contactAdding 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 Signals2 credits per searchPer signal group per company (news, hiring, reviews, etc.); ad-activity groups cost 4–8
Bulk row1 credit per companyOne row of your CSV
Failed searchRefunded fullyIf the search itself fails, your credits come back
Money-back rules you should know

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.

Metered plans work differently

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.

Reference

Run a search

POST /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

FieldRequired?TypeWhat it means
companyYestext (max 255)Business name, e.g. "Joe's Pizza"
addressYestext (max 200)Street/city/state. More specific = better results.
titlesYeslist of textsJob titles to look for. 1–5 titles = 1 search; each extra batch of 5 = +1 charge.
depthOptional; currently ignored (forced to 1)1–5Accepted 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.
enrichOptionaltrue/falseAlso 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).
modeOptionalfind / find_enrich / enrichCleaner alternative to enrich. find = names only; find_enrich = both.
email_typesOptionallist: personal, work, genericWhen enriching, which email kinds to accept. Default: all three.
webhook_urlOptionalhttps URLWe POST here when the task finishes, so you don't have to poll. See webhooks.

Search + enrichment in one request

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"]
  }'
Response — 202
{
  "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.

One search at a time (usually)

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.

Reference

Check status & get results

GET /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.

Response — 200
{ "id": "019dbf57-....", "status": "extracting", "progress": 70, "created_at": "2026-08-14T10:00:01+00:00" }
GET /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).

GET /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.

Reference

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.

POST /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.

Request
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

companies.csv
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 fieldRequired?What it means
fileYesThe CSV file (or .txt). Max 20 MB.
titles[]YesJob titles, one per titles[] field.
depthOptional1–8. Accepted for backward compatibility but ignored — each row runs one standard pass and costs 1 credit.
company_columnOptionalHeader name of the company column, if auto-detect guesses wrong.
location_columnOptionalSame, for the location column.
skip_previousOptionaltrue = skip companies already searched in the last 30 days (saves credits).
file_nameOptionalFriendly name shown in the dashboard instead of the filename.
webhook_urlOptionalCalled once when the whole batch finishes.
Response — 202
{
  "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

CallWhat it gives you
GET /v1/bulk/tasksList of your bulk tasks
GET /v1/bulk/tasks/{id}Status, progress %, rows processed / total, credits used
GET /v1/bulk/tasks/{id}/resultsPeople found so far, paginated JSON
GET /v1/bulk/tasks/{id}/downloadThe finished results as a CSV file (available when completed)
Credit check up front

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.

Reference

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

Example
{
  "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).

POST /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.

Request
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"] }'
Response — 202
{
  "task_id": "019dbf57-....",
  "enriching": 3,
  "cost": { "credits": 6.0, "balance": 43.0 },
  "message": "Enrichment started. Poll GET /v1/tasks/019dbf57-..../results for results."
}
POST /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.

If nothing is found

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.

Request & response
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 }
}
POST /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.

Request & response
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."
}
Signals

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).

GET /signals/groups

Returns the catalog of available groups with their price and the signals each one reports:

Group keyPriceWhat it finds
news2 crExpansions, leadership changes, partnerships, awards, media mentions
hiring2 crCurrently hiring, open roles, hiring velocity, role-keyword matches
reviews2 crReview sentiment, rating, complaint themes (country-appropriate platforms)
website2 crMissing chatbot/booking/ordering, opening hours, best time to call
competitors2 crLocal competitor list + target's presence/position in best-of results
ads_core4 crActive ads on Meta + Google public libraries (with first/last-seen dates)
ads_full8 crAll 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.

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.

POST /signals/run
FieldRequiredTypeWhat it does
companyYesstringBusiness name
addressYesstringFull street address (pins the exact business)
domainOptionalstringBusiness website domain (improves ad-activity accuracy)
groupsYesstring[]One or more group keys from the catalog
keywordsOptionalobjectPer-group keywords (e.g. {"hiring": ["manager", "chef"]})

The competitors group requires a category keyword (e.g. "bbq", "emergency plumber") in keywords.competitors.

Signals

Get signals results

GET /signals/tasks/{task_id}

Returns the task status plus per-group results. Each signal in groups[].signals[] carries:

FieldWhat it means
keySignal identifier (e.g. expansion, currently_hiring)
foundWhether the signal was detected
valueThe specific finding, named (not a vague label)
detected_dateWhen the event/content is dated (null if undated)
recencyHuman-readable: "today", "last week", "3 months ago", "undated"
evidence_urls1–2 source URLs proving the finding

Only signals from the last 12 months are returned; older events are excluded automatically.

Extras

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:

POST to your URL
{
  "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):

Node.js verification
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);
});
Sign over the raw bytes, not a re-serialization

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).

If your server is down

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.

Extras

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

CodeMeaningWhat 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.
Extras

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+)

search.mjs — run with: node search.mjs
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

search.py — run with: pip install requests && python search.py
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.

Extras

Glossary

Every technical word used on this page, in one sentence each.

WordWhat it actually means
APIA service programs can talk to, instead of a website humans click.
EndpointOne specific "page" of the API, like /v1/search. Each does one job.
JSONText format shaped like {"key": "value"}. The API's native language, both ways.
HeaderA label attached to a request, like an envelope marking. Yours is api_token: ....
POST / GETPOST sends data in (start something). GET asks for data back (read something).
HTTP statusThe 3-digit "mood" of a response: 200 happy, 202 accepted-but-working, 402 no credits, and so on.
TaskA background job with a ticket number (task_id). Searches and batch enrichments are tasks.
PollingRepeatedly asking "done yet?" until the answer is yes.
WebhookYou give us a URL; we call it when work finishes. The oven timer, not the oven-checking.
CreditPrepaid 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).
CSVA spreadsheet saved as plain text, columns separated by commas. Excel and Google Sheets both export it.
Decision makerThe person at a business who can say yes — owner, manager, director. What we find.
EnrichmentAdding contact details (emails, phone, LinkedIn) to a person we found.
Repeat searchSearching a company again within 30 days — half price (0.5 credits) and returned fast.
curlA command-line program for making requests. Preinstalled on Mac and Windows 10+.
PaginationServing long lists in pages (20 items by default) instead of one huge dump.
Extras

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.