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

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

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

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

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

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 npmnpm 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