HuntPeople Docs
Docs/Overview

Getting started

The short version of how a search actually works.

Give HuntPeople an email, a phone number, a handle or a web address, and it goes and works out who that is, then keeps going until it has what you actually asked for.

Two inputs, and they do different jobs. What you already know is where it starts, and it is also how it confirms it has the right person. What you want is what the search is for, and it is the stopping point: every other detail it does not have is simply absent, and is not chased. Anything it turns up along the way is kept and reported, but it never makes the search run longer.

There are no dials. No depth, no time limit, no thoroughness setting. The agent decides when it is finished, which is why nothing on any screen quotes a price before a search runs, and why two searches from the same starting details can take very different amounts of work.

A search can be refused before it starts. It needs one thing that points at one person, or at least two weaker clues. A name on its own is refused, in the app and through the API alike, because a name is shared by thousands of people and searching one is spending money on a coin flip.

A search may sit in a queue before it starts, because your plan sets how many run at once. Queued is normal and says where it sits in the line.

How a search works

A search is an agent working on its own. It reads what you gave it, decides what to look at next, and keeps going until it has answered what you asked or decided it has gone as far as it usefully can. There is no fixed number of steps and no dial to turn: one search took 23 steps and another with the same starting details took 41. You watch it work while it happens, and everything it did stays readable afterwards.

A search is in one of seven states, and they are deliberately not collapsed into fewer. Queued is waiting its turn, and says where it sits in the line. Working is running. No activity means it has gone quiet for long enough to be worth telling you, which is not the same as it having failed. Finished found something. Found nothing finished and found nothing, which is a real answer and not a fault. Failed broke on our side. Stopped was ended early, by you or by a limit, and says which. Found nothing, failed and stopped are three different outcomes and the screen never shows one as another.

What a search establishes is written as a card. The agent chooses its own fields and its own words for them, so there is no fixed column list to fit an answer into: a vessel and a person do not have the same details and are not forced into the same shape. Cards for one person are shared across every search your team runs, so a second search on somebody already found adds to what is there rather than starting again.

Four things about every single detail are decided by us rather than by the model, because they are what makes a card safe to act on. Whose it is: a company's switchboard is the company's, whatever page it was found on, and it never answers a request for somebody's own number. Whether it was seen or worked out: seen means the value was written on a page we read, worked out means it was built from a pattern, such as a company's usual email format applied to a name. A worked-out value may still be right, and it is never presented as a sighting. How sure we are it is theirs. Which search wrote it, so a value can always be traced back and a bad one rounded up.

Certainty is three words and never a percentage: Confirmed, Likely, Unconfirmed. They are used for two different claims that must never be read as one. Against the card, the claim is whether this is the RIGHT subject. Against a single detail, the claim is whether that detail is THEIRS. A card can be the right person with a detail on it nothing has tied to them, and a card nobody judged carries no verdict at all rather than a flattering one.

A card also says what it did not find, in two separate lists, because they mean different things. Looked for and not found is a result: it says the thing is probably not findable. Never looked for says nothing about the detail at all, only about what that search was asked to do. A card carrying neither would make those two identical, and only one of them is an answer.

What it costs

A search costs what it takes to run, and we cannot tell you the price before it starts. Some people are found in a handful of steps. Others take dozens: more pages read, more dead ends, more places to look. The same starting details can produce a short search one day and a long one the next, because what is findable about somebody changes as the web does. So there is no price list, and any figure we printed before a search ran would be a guess we then charged you differently for.

What we show instead is what your searches have actually cost. Once you have run a few, your Billing page says "usually about" a figure, and that figure is the middle one: half your searches cost less, half cost more. It is the middle rather than the average on purpose, because one unusually long search drags an average upwards and would make us look dearer than we are. Two things follow, and both are better said here than discovered. Two identical searches can genuinely cost different amounts, because part of what we pay depends on timing rather than on the work; nothing is wrong when that happens. And the figure is a description rather than a promise: it is what happened, not what will, and it says nothing at all until you have run enough for it to mean something.

You add money, and searches spend it. There is one balance for the whole team. Adding money carries a service fee: 5% plus 35 cents, with a $5 minimum, so adding $25 costs $26.60. The exact total is on the button before you press it, and on the receipt afterwards. Nothing else charges your card: searches spend money that is already on your balance, so the only press that takes money is the one that adds it.

When the balance runs out there are two moments and they feel different. A single search is refused before it starts: nothing runs, nothing is charged, and the message says to add money. Your card is never charged automatically to cover it. A list stops part way through, because the balance is checked as each row comes up rather than once when you submit, so the rows that already ran keep their results and their charges and the rest stop and say why. They are not recorded as searches that found nothing, because they never ran. Add money and start them again. Both are deliberate: a busy afternoon can never turn into a bill you did not agree to.

A plan buys seats, features and how many searches you can have running at once. It buys no searching. Every search is paid for out of your balance, on every plan including Free, and no plan adds to that balance or ever will. A new account gets $2 to try it with, once, and that is the only thing we give away.

Database

Everything a search establishes is written to one card per person, shared by your whole team, rather than one per search. A second search on somebody already found adds to the card that exists instead of starting a new one, so the file grows rather than repeating.

A card has no fixed columns. The agent chooses its own fields and its own words for them, because a vessel and a person do not have the same details and forcing both into one shape loses whichever one does not fit. What is fixed is the four things recorded about every single detail, which is what makes a card safe to act on rather than merely full.

Corrections you make are yours. They never touch what another team sees, they never overwrite what the agent found, and they can always be undone: the original value stays underneath. Every value also remembers which search wrote it, so a search that turns out to have been wrong can be traced and its work rounded up.

API reference

Everything below is checked against the running service: a script reads the backend's own routes and fails the build if this table names one that does not exist, and every call was made against production with a real key before it was written down.

Authentication

Send your key as a bearer token on every request. Base URL: https://api.huntpeople.ai (running the stack locally, it is http://localhost:8000).

Authorization: Bearer sk_live_...

A key is scoped to the team it was created in and acts as the account that created it. With a key you can start, follow, stream and stop searches, read every card and correct a field on one, check or repair a spreadsheet, and read the balance, the audit log, your teams and the plan list. Managing keys themselves, notifications and billing are done signed in to the app; a key sent there gets 401. Keys need a plan that includes them.

Endpoints

POST /v2/agentsStart one search per entry in `runs`, each a list of `seeds` ({type, value}); `want` is the list of fields you are after, applied to all of them. Returns the run ids at once (201). Refused with 422 when nothing given points at one person, with 402 when the balance cannot cover it.
GET /v2/agentsEvery search your team has run, newest first, each with its status, steps, findings and what it was charged.
GET /v2/agents/{id}One search: the run and its activities, the agent's own account of what it did.
GET /v2/agents/{id}/eventsLive stream of the same search as text/event-stream: `run` on every change, `activity` for each new or updated entry, `done` when it ends.
GET /v2/agents/{id}/todoThe agent's own running to-do list for the search, when it kept one.
POST /v2/agents/{id}/stopStop a running or queued search. Nothing is charged for a stopped search.
GET /v2/batchesLists started together, newest first: id, name, when and by whom.
GET /v2/cardsEvery card in your team's database, newest change first, with all fields and their marks.
GET /v2/cards/{id}One card.
PUT /v2/cards/{id}/fields/{fieldId}/overrideYour team's correction of one field: `{ "value": "..." }`. Stored beside the agent's value, never over it.
DELETE /v2/cards/{id}/fields/{fieldId}/overrideRemove that correction; the agent's value shows again.
POST /v1/files/checkRead an uploaded list, or say plainly why it cannot be read. Multipart: `file` and `subject` (person). Free. Needs a plan that includes uploading a list.
POST /v1/files/repairWork out how to read a file /check refused. Same multipart. Costs the price the check response named; the response says what was charged.
GET /v1/walletYour team's balance, saved card if any, and the statement: every top-up, grant and search charge.
GET /v1/audit-logYour team's user actions: searches started and stopped, corrections, downloads, key changes. Newest first, `limit` up to 1000.
GET /v1/teamsEvery team the key's account belongs to, with role and plan.
GET /v1/billing/plansNo authThe plans and what each includes.
GET /v1/api-keysSigned inYour team's keys, without the secrets.
POST /v1/api-keysSigned inCreate a key: `{ "name", "limit_usd"?, "expires_at"? }`. The secret is shown once.
PATCH /v1/api-keys/{id}Signed inRename, cap, expire, disable or enable a key.
GET /v1/api-keys/{id}/activitySigned inWhat one key has done.
DELETE /v1/api-keys/{id}Signed inRevoke a key.
GET /v1/notificationsSigned inThe bell: one entry per finished, failed or stopped search.
POST /v1/notifications/read-allSigned inMark every notification read.
GET /v1/billingSigned inPlan, card and invoices.
POST /v1/teamsSigned inCreate a team.
GET /v1/teams/{id}/membersSigned inMembers and roles.
POST /v1/teams/{id}/invitesSigned inInvite somebody by email.

Three families of route exist on the service but are not part of this reference. /v1/auth/* is the app's own sign-up, sign-in and account surface, driven by the session cookie. /v1/admin/* is staff-only. And the previous engine's routes (/v1/search, /v1/searches, /v1/investigations, /v1/people, /v1/exports, /v1/usage, /v1/analytics) are withdrawn or kept only so old history can be read; nothing new should be built on them.

Errors

Errors come back as { "detail": ... } in plain language. 401: the key is missing, wrong, revoked or expired, or you sent a key to a signed-in endpoint. 402: the team's balance cannot cover a search; detail carries code "wallet_empty". 403: the plan does not include this (API access, uploading a list), or the previous engine's withdrawn routes. 404: not your team's search or card. 422: the request is not enough to run on; detail carries code "needs_identifier" or "no_runs" and a sentence saying what to add.

A search costs what it takes to run, and there is no price before it starts: some people are found in a handful of steps and others take dozens. There is one balance for everything, so a search from a key spends the same money as one from the app, and it is charged after it runs. A search that failed, was stopped, or found nothing at all costs nothing. Create keys on the API keys page and add money on Billing.

One call, one or many people.

curl -X POST https://api.huntpeople.ai/v2/agents \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "runs": [{ "seeds": [
      { "type": "full-name", "value": "Jordan Ellis" },
      { "type": "email", "value": "jordan@example.com" }
    ]}],
    "want": ["phone", "linkedin"]
  }'

Returns:

{ "ids": ["run_9e34ce86a6654c9aaf90"], "batch_id": null, "warning": null }

// Two or more runs in one call also get a batch_id, so the list can be read back as one.
// warning is a sentence when the clues were thin but allowed, otherwise null.

Starting a search returns its id at once and the work continues in the background. Read it back any time: status is one of queued, running, stalled, finished, empty, failed or stopped; steps and findings climb while it runs; charged_usd is set once, at the end. The activities are the agent's own account of what it did, in order: text it wrote, the lookups it ran (in plain English, never the method), and the card it wrote when it found something.

curl https://api.huntpeople.ai/v2/agents/run_9e34ce86a6654c9aaf90 \
  -H "Authorization: Bearer sk_live_..."

Returns:

{
  "run": {
    "id": "run_9e34ce86a6654c9aaf90",
    "title": "Jordan Ellis",
    "named": true,
    "status": "finished",
    "reason": null,
    "seeds": [{ "type": "full-name", "value": "Jordan Ellis" }, { "type": "email", "value": "jordan@example.com" }],
    "want": ["phone", "linkedin"],
    "steps": 11,
    "findings": 10,
    "charged_usd": 0.04,
    "charged": true,
    "started_at": "2026-09-09T14:25:36Z",
    "ended_at": "2026-09-09T14:29:59Z",
    "last_event_at": "2026-09-09T14:29:59Z",
    "now_doing": null,
    "started_by": "Jane Doe",
    "batch_id": null,
    "queue_position": null
  },
  "activities": [
    { "id": "act_...", "kind": "text", "at": "...", "text": "Starting from the email, the strongest clue." },
    { "id": "act_...", "kind": "tools", "at": "...", "steps": [
      { "id": "call_...", "name": "Searched the web", "arg": "Jordan Ellis", "state": "done", "ms": 1200 }
    ]},
    { "id": "act_...", "kind": "finding", "at": "...", "profile_id": "p_d60de9cdd4cb4702b576" }
  ]
}

For a live view, open the event stream instead of polling. It sends the run as it stands, then one event per change, with a comment line every fifteen seconds while nothing happens so proxies keep it open. A finished run sends done and closes. Plain text/event-stream: curl -N shows it, and a browser EventSource reads it.

curl -N https://api.huntpeople.ai/v2/agents/run_9e34ce86a6654c9aaf90/events \
  -H "Authorization: Bearer sk_live_..."

What arrives:

event: run
data: { "id": "run_9e34...", "status": "running", "steps": 3, ... }

event: activity
data: { "id": "act_...", "kind": "tools", "steps": [ ... ] }

: still here

event: done
data: { "id": "run_9e34...", "status": "finished" }

Read a card

What a search establishes is written to a card, one per person, shared by the whole team: a second search on somebody already found adds to the same card. Every field carries four marks you should read before acting on it: subject (person or company: a company's switchboard is the company's, whatever page it was on), nature (observed on a page, or inferred from a pattern), certainty (confirmed, probable, possible: how sure it is theirs, which is a different claim from the card's own certainty that this is the right person), and source (which run wrote it). A correction you PUT is stored beside the value, never over it, and can be removed again.

curl https://api.huntpeople.ai/v2/cards/p_d60de9cdd4cb4702b576 \
  -H "Authorization: Bearer sk_live_..."

Returns:

{
  "id": "p_d60de9cdd4cb4702b576",
  "kind": "person",
  "title": "Jordan Ellis",
  "verdict": "Confirmed. Jordan Ellis is ... (the agent's own sentence)",
  "certainty": "confirmed",
  "not_found": ["Phone (any) - checked every page read this run; nothing"],
  "not_looked_at": ["Court or government records"],
  "runs": [{ "id": "run_9e34ce86a6654c9aaf90", "title": "Jordan Ellis", "at": "2026-09-09T14:25:36Z" }],
  "fields": [
    {
      "id": "f_81e54b84af704275b4da",
      "label": "personal email",
      "value": "jordan@example.com",
      "subject": "person",
      "belongs_to": null,
      "nature": "observed",
      "certainty": "confirmed",
      "observed_at": null,
      "note": "Listed in the contact block of his own site.",
      "source": { "run_id": "run_9e34ce86a6654c9aaf90", "run_title": "Jordan Ellis" },
      "superseded": [],
      "override": null
    }
  ]
}

Upload a list

Uploading a list is two doors and code decides which you get. /check is free: it reads CSV, TSV or Excel, says how it understood each column, and either hands back the rows it would run or refuses with plain reasons and a suggested fix. Businesses on a list of people are held aside with the reason, never run and never charged. /repair costs the price the check response names (repair_usd, and repair_failed_usd when it cannot be fixed); a model works out the layout and the same reader judges it. Both need a plan that includes uploading a list. The rows come back as seeds you can send straight to POST /v2/agents.

curl -X POST https://api.huntpeople.ai/v1/files/check \
  -H "Authorization: Bearer sk_live_..." \
  -F "file=@people.csv" -F "subject=person"

Returns (both /check and /repair):

{
  "ok": true,                       // false when the file was refused; then read `problems`
  "note": "3 rows, 1 ready to search.",
  "repaired": "",                   // what the paid repair changed, when it ran
  "row_count": 3,
  "subject": "person",              // what the list was read AS
  "columns": [{ "index": 0, "header": "name", "field": "name", "near_unique": false }, ...],
  "runnable": [{ "row": 2, "known": [{ "type": "name", "value": "Jordan Ellis" }, ...] }],
  "runnable_total": 1,              // the exact count; `runnable` is capped for display
  "held_aside": [{ "row": 3, "known": [ ... ] }],
  "held_aside_total": 1,
  "held_aside_note": "1 of these rows look like businesses rather than people, so we left them out. ...",
  "unusable": [{ "row": 4, "known": [ ... ], "reason": "only name on this row, which is not enough to search on. ..." }],
  "unusable_total": 1,
  "problems": [{ "code": "no-header", "message": "...", "rows": [1], "severity": "refuse" }],
  "first_line": "Jordan Ellis,+1 704 555 0142",   // your file's own first line, when it was refused
  "suggested_header": "name,phone",               // the header we would add, when a missing one is the problem
  "repairable": true,               // false when no clean-up could help, so /repair is not offered
  "repair_usd": 0.60,               // what /repair costs on this file
  "repair_failed_usd": 0.12,        // what /repair charges when it cannot fix the file
  "charged_usd": 0,                 // what THIS call charged: 0 for /check, one of the two above for /repair
  "cleaned_csv": "name,phone\r\n...", // the rows read, written back out, to keep
  "people": [ ... ], "people_total": 1, "organisations": [ ... ], "organisations_total": 1   // older names for runnable and held_aside, kept as exact aliases
}

FAQ

Something wrong or missing on this page?