hyreflow

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_xxx

Mint 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 /toolsendpoint

List 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}endpoint

A tool's methods (or, for a flat name like apollo_search_people, the resolved method view).

GET /tools/{tool}/{method}endpoint

The 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}endpoint

Run 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}endpoint

Run 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}endpoint

People-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 as limit people 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:

outcomeMeaning
cant_scope_companythe 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_locationthe location couldn't be resolved to a token this provider accepts
filter_not_boundthe provider accepted the request but proved it ignored a filter, the results were discarded and you were not charged
company_mismatchthe 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 / skipno 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_limitstopped 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 Corp matching Acme Corporation) is the same company. A single word that merely contains or begins with what you asked for (a query for acme against GoAcme or Acmeteer) is a different company, and its people are dropped. Spelling differences of the same name (Acme Inc, ACME-Inc) and a domain brand like acmelabs for Acme Labs can 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 with cant_scope_company rather than being answered by a search that quietly ignored it.
  • A row from the LinkedIn profile search carries verification_required: true and a verification_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.warnings carries an unverified_web_results entry with the count.

Enrich (waterfall)

POST /enrich/{capability}endpoint

Per-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}endpoint

Owner-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 /qualifyendpoint

Score 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/plansendpoint

No 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/balanceendpoint

Current balance plus recent ledger entries.

GET /api/v2/billing/overviewendpoint

Everything 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/checkoutendpoint

Start 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/changeendpoint

Same 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/cancelendpoint

Body: {"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-capendpoint

Set the rolling 30-day workspace spend cap. Body: {"cap": <credits > 0>}. Returns {"cap": cap}.

DELETE /api/v2/billing/spending-capendpoint

Remove the 30-day workspace spend cap. Returns {"cap": null}.

PUT /api/v2/billing/session-capendpoint

Set 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-capendpoint

Remove 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/keysendpoint

Mint a key ({"name": "..."}). The plaintext key is returned once.

GET /api/v2/keysendpoint

List the caller's keys (metadata only: never the secret).

DELETE /api/v2/keys/{key_id}endpoint

Revoke a key.

Sessions

POST /session/startendpoint

Publish a plan: {steps: [...], user_prompt}.

POST /session/updateendpoint

Mark a step: {session_id, index, status}: pending|running|completed|error|skipped.

POST /session/statusendpoint

Live sub-step message: {session_id, message, step_index?}.

POST /session/outputendpoint

Register a CSV artifact by path: {session_id, csv, label}.

GET /session/{sid}endpoint

Fetch a session's plan + status.

POST /session/{sid}/limitendpoint

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

EndpointPurpose
GET /api/v2/cli/installThe install shell script
GET /api/v2/npm/hyreflownpm packument: the registry fallback for sandboxes that block npmjs.com
GET /api/v2/npm/hyreflow/-/hyreflow-{version}.tgzA published package tarball, proxied from npm
GET /api/v2/cli/releasesPublished versions + their SHA-256 checksums
POST /api/v2/auth/cli/startBegin 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/bundleThe four skill packages, as one archive
GET /.well-known/skills/index.jsonSkill 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.gzThe 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}.mdO*NET lookup tables the skills fetch on demand
GET /api/v2/versionsCurrent CLI + skills content hashes (what hyreflow update diffs)
GET /healthLiveness

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 '*' -y

The 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.mjs

Every 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 to

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

On this page