API Reference
Every HTTP endpoint on the Hyreflow engine.
The engine base URL is https://recruit.hyreflow.ai. Every endpoint except the public bootstrap
set requires a bearer token:
Authorization: Bearer hf_live_xxxMint and manage keys under API Keys. All responses are JSON. Metered endpoints
return a _meta block with credits_charged, balance, and a run_id. A poll of a native job scraper
also returns billed_jobs (jobs this call charged for) and already_billed (delivered jobs charged on an
earlier poll) — so credits_charged: 0 with already_billed > 0 means "already paid for", not "free".
Prefer to connect an agent without writing HTTP? The same tools are exposed over the Model
Context Protocol at /api/v2/mcp (OAuth sign-in, one workspace per connection) — see
MCP Setup.
Tools
Discover and run a single tool/method.
GET /toolsendpointList tools in an envelope: {count, tools, results, filter}. Pass ?filter=<text> to narrow by tool,
kind, module, or method name; each entry includes method_count and methods.
GET /tools/{tool}endpointA tool's methods (or, for a flat name like apollo_search_people, the
resolved method view).
GET /tools/{tool}/{method}endpointThe method contract — http, path, inputs (params), cost (credits, usd,
unit, note), and, for a method that takes a single JSON body, payload_schema (the inner
shape of that body). Every method is priced per-endpoint off what it actually costs us — see
pricing for how the rate card works, or run hyreflow tools get <tool> <method> to see the live cost. BYOK/runtime providers run at pass-through. Read this before
calling.
POST /tools/{tool}/{method}endpointRun the adapter. Body: {"payload": {...}} (ergonomic — mapped to args/kwargs by the
method signature) and/or {"args": [...], "kwargs": {...}} (escape hatch). Set
"dry_run": true to preview the resolved call and cost without charging.
curl -X POST https://recruit.hyreflow.ai/tools/apollo/search_people \
-H "Authorization: Bearer hf_live_xxx" \
-d '{"payload": {"titles": ["CTO"], "locations": ["New York"]}, "dry_run": true}'dry_run returns _meta.would_charge_credits, the function it would_call, and the
resolved args/kwargs — the cheapest way to validate a payload shape.
Search (waterfall)
POST /search/{capability}endpointPeople-search waterfall → a deduped list of people. Capabilities: people_search.
Body
{
"query": {
"titles": ["Backend Engineer"],
"locations": ["Berlin"],
"skills": ["Go"],
"company_domains": ["acme.com"],
"seniority": "senior",
"limit": 25
},
"coverage": "single",
"limit": 25
}coverage: "single"(default) — walk the provider order, accumulate + dedup, and stop as soon aslimitpeople are collected. Providers that fill the quota win; the rest are skipped and never billed.coverage: "max"— run every source for the full deduped union (exhaustive).
Filters may be written nested under query or flat at the body top level. A recognized
top-level filter key is folded into the query, and an explicit query wins for any field it
names — a top-level alias never outranks the nested canonical. Both shapes work here and via the
CLI's hyreflow tools execute people_search --payload …, which takes the same envelope. The
non-filter envelope keys are query, coverage, limit, providers, dry_run, preview,
session_id, workflow_run_id.
A key that matches no known filter is listed in _meta.ignored_query_keys; a non-empty list
means that key narrowed nothing, so check it whenever a search comes back wider than you expected.
If no key binds a filter at all, the request is rejected with 400, naming any keys it
couldn't place and listing the canonical filter keys, rather than running an unscoped search
over the whole provider database and charging for it. This only applies when nothing binds — if some
filters bind and others don't, the search runs and the unrecognized keys are reported in
_meta.ignored_query_keys.
Response carries results[] (each tagged _source), per-provider steps[], hit, and
_meta.balance + _meta.charged (what this search cost).
Each entry in steps[] carries counts (returned, new, charged, pages), an outcome
explaining why that provider contributed nothing, or both — a step can return 0 rows and still
carry an outcome naming why. Outcomes worth knowing:
outcome | Meaning |
|---|---|
cant_scope_company | the query carries a filter this provider can't express, so it was skipped rather than run unscoped |
cant_scope_location | the location couldn't be resolved to a token this provider accepts |
filter_not_bound | the provider accepted the request but proved it ignored a filter — the results were discarded and you were not charged |
company_mismatch | the provider returned rows but none of them could be verified as at the company you asked for, so they were dropped rather than returned — verify_dropped on the step carries the count (a public-web step also carries verify_dropped with no outcome when it dropped some rows but kept others), and the two public-web steps below are charged per request either way |
no_result / skip | no matches, or the provider call failed. A people database is charged per profile, so its miss is free; the two public-web steps below are charged per request, found or not |
insufficient_credits / session_limit | stopped on a balance or per-session spend cap |
Public-web coverage for company-scoped queries
The people databases at the front of the chain hold no rows at all for some companies — typically
very small or very new ones. When the query names a company (via company_names, company_domains or
company_linkedin_urls) and the databases haven't filled limit, the chain continues onto the public
web: first a people search that returns structured work history, then a LinkedIn profile search. Under
coverage: "max" they run as part of the exhaustive union like every other source.
- Rows are kept only when the person's current company matches the company you asked for, so a
plausible stranger at another company is dropped instead of counted or billed as a match. The
match is on the whole company name, truncation included: a request that matches a whole word and
runs into the next one (
Acme CorpmatchingAcme Corporation) is the same company. A single word that merely contains or begins with what you asked for (a query foracmeagainstGoAcmeorAcmeteer) is a different company, and its people are dropped. Spelling differences of the same name (Acme Inc,ACME-Inc) and a domain brand likeacmelabsforAcme Labscan still match. - These two steps read a company anchor and portable filters (
titles,locations,seniority,keywords,min_experience_years). A query carrying a filter nothing in a web result can prove —skills, tenure windows,departments, firmographics — ends at the databases withcant_scope_companyrather than being answered by a search that quietly ignored it. - A row from the LinkedIn profile search carries
verification_required: trueand averification_reason: it comes from a search result rather than a profile record, so confirm the company and title on the profile before contacting the person. When any such row is returned,_meta.warningscarries anunverified_web_resultsentry with the count.
Enrich (waterfall)
POST /enrich/{capability}endpointPer-row enrichment waterfall. Capabilities: email_enrichment (work email), personal_email,
linkedin_profile (employment history — fills profile.experience[] instead of email).
Body
{ "rows": [ { "linkedin_url": "https://linkedin.com/in/jane" } ] }For each row the engine walks the provider chain in order and stops at the first real
hit. Only the hitting step is billed — soft misses charge 0, and so does a result the capability
rejects: on personal_email, an address on the person's own company domain is not a personal email,
so it is dropped and charged 0 no matter which provider returned it. _meta.charged is what the whole
call cost. Add "preview": true to see each provider's mapped payload without calling or spending.
Rows in a single run are processed in parallel, so a batch takes about as long as its slowest row
rather than the sum of every row.
linkedin_profile returns a normalized profile: {headline, current_title, current_company, experience[{company, title, start, end, is_current, duration_months?, description?}], about?, skills?, certifications?, education?}, newest role first. The dated experience[] is the result — an answer without
employment history counts as a miss, costs nothing, and the chain moves to the next provider. The other
fields are filled only by providers that carry them, so read an empty skills/about as unknown. Enrich
profiles before POST /qualify, which scores on that history.
Each entry in steps[] carries its outcome and what it charged. An address the chain drops is not a
usable result, so the step that produced it charges 0 and the chain continues: corporate_domain (a work
address on the personal channel), company_mismatch (an address at some other employer) and
invalid_email (an address that failed deliverability) all settle at zero. Deliverability is checked by a
validator, which is charged for its verdict — once per address, however many providers offered it.
On email_enrichment an address is only accepted if it belongs to the person's current employer, so
send the employer along with the identifiers. A company_domain on the row is authoritative: an
address on any other domain is dropped with outcome company_mismatch and the chain keeps walking —
which is what keeps a former employer's address (the person moved, the provider's record didn't) out of
the result, at no cost. Sending a company_domain from people_search is the strongest signal and the
only thing that can reject a mismatch.
With only a company_name the engine compares the name against the address domain and reports the
verdict without dropping anything, since a company can legitimately send mail from an unrelated domain.
An employer field that carries no usable domain (a placeholder value, or a social/site-builder link) is
treated as no domain at all, so nothing on the row is dropped because of it.
Either way the row carries company_match plus the company_match_basis (company_domain or
company_name) it was judged on.
Qualify
POST /qualifyendpointScore each candidate's enriched profile against a job spec (0–10) via the AI step.
Body
{
"job_spec": "<JD text>",
"candidates": [ { "full_name": "Jane Doe", "profile": { "experience": [ /* … */ ] } } ],
"min_score": 7,
"allow_thin_profiles": false
}Scoring reads each candidate's work history with dates — profile.experience[] from
POST /enrich/linkedin_profile, or an experience[]/work_experience[]/job_history[] the row already
carries (e.g. a CRM record) — together with the headline, about section, skills, certifications and education
when present. A current title alone cannot separate a long-tenured specialist from a recent career changer,
so enrich the pool first.
Returns candidates ranked by score (desc), each with qualify: {score, basis, summary, strengths, gaps},
where basis is work_history or title_only. With min_score set, only those at/above it are kept.
_meta.scored_on counts both bases and _meta.thin_profiles names the rows that had no history — both
describe everything that was scored, so a row min_score filtered out of candidates[] still appears
there.
A batch in which no candidate carries work history is rejected with 422 and
detail.error = "no_work_history" — enrich the rows, or set allow_thin_profiles: true to rank on the
sourcing fields (current title, employer, location) knowingly.
Billing
GET /billing/balanceendpointCurrent balance plus recent ledger entries.
GET /api/v2/billing/overviewendpointEverything the billing page renders: balance, 30-day spend + cap, usage series, recent ledger, and
session_spend_cap — the workspace's default per-session dollar cap (null if unset).
PUT /api/v2/billing/spending-capendpointSet the rolling 30-day workspace spend cap. Body: {"cap": <credits > 0>}. Returns {"cap": cap}.
DELETE /api/v2/billing/spending-capendpointRemove the 30-day workspace spend cap. Returns {"cap": null}.
PUT /api/v2/billing/session-capendpointSet the workspace's default per-session dollar cap. Body: {"cap": <usd > 0>}. Returns {"cap": cap}.
Sessions created after this call inherit it as their starting cap; a session already running keeps its
own — see POST /session/{sid}/limit.
DELETE /api/v2/billing/session-capendpointRemove the default per-session cap. Returns {"cap": null}.
API keys
POST /api/v2/keysendpointMint a key ({"name": "..."}). The plaintext key is returned once.
GET /api/v2/keysendpointList the caller's keys (metadata only — never the secret).
DELETE /api/v2/keys/{key_id}endpointRevoke a key.
Sessions
POST /session/startendpointPublish a plan: {steps: [...], user_prompt}.
POST /session/updateendpointMark a step: {session_id, index, status} — pending|running|completed|error|skipped.
POST /session/statusendpointLive sub-step message: {session_id, message, step_index?}.
POST /session/outputendpointRegister a CSV artifact by path: {session_id, csv, label}.
GET /session/{sid}endpointFetch a session's plan + status.
POST /session/{sid}/limitendpointSet this session's own per-session spend cap: {"usd": <number> | null}. Overrides the workspace
default for this session only; null removes the cap entirely rather than reverting to the default.
Every session endpoint except POST /session/start names its session — in the body as session_id, or in
the path as {sid}. It must be the ses_… id /session/start returned; anything else is a 400. A
well-formed ses_… id that belongs to another workspace reads the same as one that doesn't exist: 404.
Bootstrap (no auth)
These are public so the CLI can install itself:
| Endpoint | Purpose |
|---|---|
GET /api/v2/cli/install | The install shell script |
GET /api/v2/npm/hyreflow | npm packument — the registry fallback for sandboxes that block npmjs.com |
GET /api/v2/npm/hyreflow/-/hyreflow-{version}.tgz | A published package tarball, proxied from npm |
GET /api/v2/cli/releases | Published versions + their SHA-256 checksums |
POST /api/v2/auth/cli/start | Begin browser auth |
GET|POST /api/v2/auth/cli/claim/{id} | Claim the token |
GET /api/v2/auth/cli/poll/{id} | Poll for completion |
GET /api/v2/skills/bundle | The four skill packages, as one archive |
GET /.well-known/skills/index.json | Skill catalog — one entry per package, listing its files and a SHA-256 digest |
GET /.well-known/skills/{name}/{path} | One file from one package, as text — the entry's files paths |
GET /.well-known/skills/archives/{name}.tar.gz | The same package as one archive, matching the digest above — it unpacks to the package's own contents, SKILL.md at the root |
GET /api/v2/reference/onet/{titles|occupations}.md | O*NET lookup tables the skills fetch on demand |
GET /api/v2/versions | Current CLI + skills content hashes (what hyreflow update diffs) |
GET /health | Liveness |
Any agent with its own skill installer can install from the catalog instead of using the Hyreflow CLI —
each archive is byte-stable, so its digest is safe to pin and to re-check for updates. skills.sha256
in /api/v2/versions is the same version number the catalog reports, so one request answers "is what I
have current?" whichever route installed it.
The index declares its $schema as
https://schemas.agentskills.io/discovery/0.2.0/schema.json, so a generic installer that speaks agent-skills
discovery can take the catalog as-is:
npx skills@latest add https://recruit.hyreflow.ai/.well-known/skills/index.json \
--agent claude-code --global --skill '*' -yThe CLI ships on public npm — npm install -g hyreflow is the primary install. The install script
above uses it, and falls back to this host's npm registry (/api/v2/npm/) for locked-down sandboxes that
can't reach npmjs.com.
Inspecting the CLI before you run it
The CLI is MIT-licensed and ships as a single self-contained JavaScript bundle. Fetch and read it without installing anything globally:
npm pack hyreflow
tar -xzf hyreflow-*.tgz
less package/dist/cli.mjsEvery published version carries npm's own integrity hash, covering the exact bytes the registry serves:
npm view [email protected] dist.integrity # sha512-… for that exact version
npm view hyreflow dist.integrity # …for whatever `latest` currently resolves toThat hash is fixed at publish time and a published version is immutable, so the check works for any
version — including the one you are about to install. GET /api/v2/cli/releases lists every published
version alongside its SHA-256 if you'd rather pin from this host.
Installing through this host's registry fallback verifies the same way: the packument it serves carries
npm's own shasum and integrity untouched, and the tarball is proxied byte-for-byte, so a client that
resolves and downloads entirely through /api/v2/npm/ still ends up verifying against what npm
published.