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. _meta.settled says whether
those figures are final: it is false while credits are still held for in-flight work
(_meta.reserved reports the held amount — like balance, it covers the whole workspace, so another
call running at the same time can hold credits and make this one provisional too) or while an async provider job the call left running is
still open (_meta.pending_jobs lists each as {provider, job_id}, plus fetch — the job-status
endpoint below — when the job can be polled directly). Its charge lands on a later poll or when the job finishes. While settled
is false, treat credits_charged/balance as provisional and re-read GET /billing/balance to
reconcile. 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). A method that reads a provider's own account balance or usage also carries
access: own_key_required means it reads the account behind your own key for that provider, so
connect one in Integrations first; unavailable means it reads a Hyreflow-side account and no key
of yours unlocks it. For your Hyreflow credit balance use hyreflow billing balance.
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.
Payload keys must match the method's parameter names (hyreflow tools get <tool> <method> lists
them). A key the method doesn't have, sent to a method that takes a single id or list, say
{"enrichment_id": …} to bettercontact.get_enrichment(request_id): is refused before anything is
charged: 400 {"error": "invalid_payload", "unknown": [...], "expected": "request_id"}.
A payload that omits a key the method requires is refused the same way, naming what is missing:
{"q": "…"} to serper.search(query) returns
400 {"error": "invalid_payload", "missing": ["query"], "expected": "query"}. Methods that accept
extra vendor options pass unrecognised keys straight through to the provider, so a required key spelled
wrong reads as an unknown option rather than a substitute: check the name against
hyreflow tools get <tool> <method>.
A data-provider response that consumes Hyreflow credits or credits from a connected provider account,
and contains table-like rows, is persisted and returns
_meta.dataset_id plus _meta.row_count, so the rows can be retrieved through the dataset endpoints
without running and paying for the provider call again. Small provider envelopes remain inline;
above the inline row cap, only the row list is sampled while provider metadata such as track ids,
totals, and pagination remains in the immediate response. A directly returned row list uses the
canonical dataset envelope with a five-row sample whenever it is persisted. The dataset stores the
rows, not provider metadata.
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.
POST /tools/{waterfall}endpointRun a waterfall tool by name. Body: {"payload": {...}} for a search or a single enrichment row,
or {"rows": [{...}, ...]} (up to 100 row objects) for an enrichment waterfall
(email_enrichment, personal_email, linkedin_profile) to enrich many people in one call. rows
and payload are exclusive; a search waterfall takes payload only. Add "dry_run": true to
preview every step's mapped payload without calling or charging. The response is the same shape as
POST /enrich/{capability} below.
curl -X POST https://recruit.hyreflow.ai/tools/personal_email \
-H "Authorization: Bearer hf_live_xxx" \
-d '{"rows": [{"linkedin_url": "https://linkedin.com/in/jane"}, {"linkedin_url": "https://linkedin.com/in/joe"}]}'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) — with the settled/reserved markers from
the _meta contract above.
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; a capability-gated field (keywords, skills, tenure windows, firmographics) is named in unsupported_fields |
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.
A batch is walked stage by stage and batched at the provider. With more than one row the engine sends every row through the first provider, the rows still without an answer through the second, and so on: first hit still wins per row. Where a provider accepts a batch (FullEnrich, up to 100 contacts) all the pending rows become one provider job with one poll loop rather than one job per row, so 12 or 100 rows finish in roughly the time a single row takes. Results are matched back to rows by a per-contact correlation key the engine stamps, never by position. Billing stays per row: one charge per hit, misses free, and a shared provider call never bills twice.
A multi-row call returns as soon as the batch is registered. The response comes back in seconds
carrying _meta.dataset_id, _meta.run_id and _meta.status: "running" while the batch is walked in
the background — rows land in the dataset as they are enriched, so a slow provider chain can never
outlast your HTTP client's timeout. Read them with hyreflow_dataset_read/hyreflow_dataset_export
(or the dataset endpoints).
A re-sent batch replays for free. Re-POSTing an identical rows batch within 15 minutes returns
the first call's dataset (_meta.dataset_id again, _meta.replayed: true, charged: 0) with no
provider calls at all — so a retry after a gateway timeout never bills twice for the same rows. After
the window, an identical call is treated as a fresh re-run and bills normally.
Still enriching. If a provider job is still running when the poll budget ends, that step reports
"outcome": "still_enriching" with "charged": 0, plus the job's identity, and that row's chain ends
there — no further provider is started or paid for that row:
{ "step": "fullenrich.start_personal_email_enrichment", "outcome": "still_enriching", "charged": 0,
"job_id": "abc123", "resume": { "tool": "fullenrich", "method": "get_bulk_enrichment", "id_arg": "enrichment_id" } }The job is persisted and Hyreflow keeps polling it to completion on its own; the settled result is
billed once, the same dedup a manual poll uses, and written into that row's entry in the run's dataset
(_meta.dataset_id) — a batch is never left half-priced by a dropped connection. The response's
_meta.pending_jobs lists every job still open like this, and _meta.settled stays false until they
resolve, since their charges land after the response. To check it sooner, resume with
POST /tools/{resume.tool}/{resume.method} and body {"payload": {"<id_arg>": "<job_id>"}}, or fetch the
job directly:
GET /enrich/jobs/{provider}/{job_id}endpointOwner-gated: only the workspace that started the job may read it. Returns status
(pending/settled/failed/expired) plus, once settled, charged, email, and result.
Both are free polls; do not resend the original row, that starts and pays for a
second job. Every row of one batch shares one job_id, so one resume call covers them all. A row whose
whole chain ended without a verdict carries the same job_id/resume at row level with
reason: "still_enriching" and a hint holding the exact command.
A cap is not a miss. When the workspace spending cap stops a provider mid-run, the affected rows
record {"outcome": "skip", "detail": "spending_cap_reached"} for that step, and the response carries
one top-level entry in _meta.warnings:
{ "code": "spending_cap_reached", "rows_affected": 9, "providers_skipped": ["fullenrich"], "message": "…" }Those rows were never asked, so a lower hit count says nothing about coverage: retry them once the cap
is raised or rolls over. Do not substitute another provider, and never a work-email finder on the
personal_email channel (see BetterContact).
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.
candidates is a list of candidate objects — the rows people_search or /enrich/linkedin_profile
returned, passed through as they came back. A row that is anything else is rejected with 422 and
detail.error = "bad_candidates", with detail.rows listing the offending positions. min_score is a
number (7, not "7"); anything else is rejected with detail.error = "bad_min_score". Both are
checked before the batch is scored, so a malformed body costs nothing.
Billing
GET /api/v2/billing/plansendpointNo auth. The public plan catalog: {"plans": [...], "annual_discount", "downgrade_grace_months", "topup_tiers", "topup_requires_plan"}.
Each plan carries id, name, order, self_serve, monthly_usd, monthly_amount_cents, annual_amount_cents, annual_monthly_usd, annual_total_usd, monthly_credits, seat_limit, rollover_cap_multiplier, features. See
Plans for the rendered table.
GET /me, GET /billing/balance and GET /api/v2/billing/overview all carry the workspace's plan state:
plan (the effective plan: always present, Free included), subscription (null on Free, else the
sold terms plus status, period bounds, cancel_at_period_end, pending_plan_id/pending_cadence,
cap_grace_until, next_grant_at), can_buy_credits (true when the workspace has a live subscription or
has beta access), beta (true when the workspace has beta access, the plan limits on top-ups, managed
data-provider keys, and seats don't apply to it), and seats ({"used", "limit"}, limit: null means
unlimited). The overview
additionally carries buckets: {"balance", "plan_credits", "purchased_credits", "plan_remaining", "purchased_remaining", "rollover_cap"}: the two balances a charge draws from and the live rollover
ceiling (null on Free/Enterprise custom).
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).
POST /api/v2/billing/subscription/checkoutendpointStart a self-serve subscription. Body: {"plan_id": "starter"|"essential"|"pro", "cadence": "monthly"|"annual"} → {"url": "<checkout url>"}. 409 already_subscribed if a non-canceled subscription already exists — use /change instead.
POST /api/v2/billing/subscription/changeendpointSame body as checkout. An upgrade applies immediately, returning applied: "immediate" with the
subscription object. A downgrade is scheduled for period end: applied: "scheduled" plus
effective_at (ISO 8601) and the subscription. 404 no_subscription on Free; 400 same_plan for
a no-op.
POST /api/v2/billing/subscription/cancelendpointBody: {"resume": false}. Sets cancel_at_period_end (or clears it with resume: true) and clears any
pending downgrade → {"subscription": {...}}.
POST /api/v2/billing/portalendpoint{"url": "<billing portal url>"} for the connected payment provider's self-serve portal.
All four self-serve subscription routes return 503 {"error": "stripe_disabled"} when payments aren't
configured.
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}.
Plan-gated actions
A request blocked by a plan limit: topping up on Free, connecting your own key for a managed data provider below Essential, or inviting past your seat limit, returns 402 in this shape:
{
"error": "plan_required",
"feature": "byok_managed_data",
"provider": "apollo",
"required_plan": "essential",
"current_plan": "free",
"message": "Connecting your own Apollo key needs the Essential plan or above."
}feature is "byok_managed_data", "topup", or "seats"; provider is only set for the BYOK case. A
workspace with beta access is not subject to these limits.
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.