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 — 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 providers run at pass-through; runtime providers are
charged from actual usage. 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. A method with a per-record
surcharge also returns _meta.would_charge_credits_max, the worst case with every record carrying it.
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, phone_enrichment) 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 and return the deduped union. Each source is paged only as far aslimitneeds (a source that does not page returns one page), so the union can exceedlimit.
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.
Web results (Exa, Serper) whose location falls outside locations carry outside_locations: true and
an outside_locations warning.
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 is anchored on a company this provider can't scope by with the identifiers given, so it was skipped rather than run unscoped |
unsupported_fields | the query carries a capability-gated field this provider can't express (keywords, skills, max_experience_years, tenure windows, firmographics), named in unsupported_fields; if AI Ark answered and the whole search found no one, these providers run once more without those fields, and that step and its rows carry filters_relaxed (billed, not confirmed to match the dropped filter) |
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 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,max_experience_years, tenure windows,departments, firmographics: ends at the databases withunsupported_fieldsrather 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. - Two more advisory
_meta.warningscodes, both free:many_companies(more than 10 companies in one search: results can't be attributed back to a company, search one company per call) andover_filtered(nothing came back with two or more filter groups applied: drop one at a time,keywordsfirst). - A keyword that is exactly "open to work" (or
#OpenToWork) is searched asprofile_badges: ["OPEN_TO_WORK"]instead, and_meta.warningscarrieskeyword_to_profile_badge. The badge is AI Ark only, so the other providers are skipped and any remaining keywords must also match. The keyword is searched as written when AI Ark can't run for you (left out ofproviders, or no key) or when nothing else, such astitlesorlocations, scopes the search. limitis the total you want. When it is larger than one vendor page, the paginated sources (AI Ark, Prospeo) size their pages evenly to cover it, solimit: 130at 25 per page bills 6 pages of 22 rows rather than 6 of 25. Every returned row on a per-result source is billed.
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),
phone_enrichment (mobile phone: fills phone 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; a row's check_credits is what its email checks cost, on top of its steps' charged. A row
whose reason is insufficient_credits was stopped by your workspace balance, not the provider. 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. When the row carries a name, the
profile must match it, and an employer the row names must appear somewhere in the profile's
experience (an earlier role counts, so a job change still matches). A location or
portfolio/website can only confirm a match, never reject one. A URL that
belongs to someone else ends the row with outcome identity_mismatch, charged 0, and the step names the
matched_name it found instead.
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 isn't safe to send to) all settle at zero. Deliverability is checked by
hyreflow's email verifier, which is charged for its verdict: once per address, however many providers
offered it. Only an address it marks safe to send to is returned; one it answers as not safe (a role
account, a mailbox that doesn't exist) is invalid_email and the chain keeps walking. When the address is
on a catch-all domain, or the verifier couldn't judge it at all, Enrichley checks it too (charged at
Enrichley's own rate), and only an address Enrichley clears is returned. A catch-all address Enrichley
doesn't clear is invalid_email and the chain keeps walking. If the verdict doesn't come back in time,
Enrichley checks the address too: one it calls invalid is invalid_email, and one it can't answer for is
dropped with outcome verify_timeout. Either way the chain keeps walking. If neither verifier can give any verdict at all (both unavailable), the address is dropped with
outcome unverified_email and the row's chain stops there. A rejected address is
never echoed back, not even on its step.
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 the same domain or a sub-domain of it is accepted. An address on a different domain is
scored by a model for how likely it belongs to the same company (acme.com for an acme.de employer)
and kept at or above a threshold. Anything below it 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, or model when the model scored a different domain, kept or
dropped, with its probability in company_match_score.
Dataset enrichment runs
POST /enrich/runendpointEnrich a whole dataset. Body: input (a ds_ dataset id) or input_rows, steps, optional range ("START:END"), in_place, output_name, and estimate/dry_run/preview for a no-spend check. This is what hyreflow enrich and the MCP hyreflow_enrich_run call.
Send "detach": true for any live run that might take more than a few seconds. The response then
comes back within about 20 seconds: either the finished run, or a handle while the run keeps going in
the background:
{ "_meta": { "run_id": "enr_…", "status": "running", "dataset_id": "ds_…", "progress": { "done": 40, "total": 200 }, "poll_after_s": 10 } }Without detach the call blocks until every row is done, and a large batch can outlive your HTTP client
or a gateway (Cloudflare closes a request at about 100 seconds) while the run carries on and bills
server-side. The CLI sends detach on every live run from 0.2.348.
GET /enrich/runs/{run_id}endpointFree poll of an enrich run. Returns _meta.status (pending/running/queued/completed/failed), dataset_id, progress while it runs and, once completed, credits_charged, summary, columns and a 5-row sample. A failed run carries _meta.error.
Poll every poll_after_s seconds until status is completed or failed. Rows land in dataset_id
as they are enriched, so the dataset endpoints can read them while the run is still going.
A re-sent run reattaches for free. Re-POSTing the identical rows, steps and range within 15 minutes
of the first call returns that run (_meta.replayed: true, credits_charged: 0), still running or
finished. A different range, or the same request after the window, is a new run and bills again. If you
lose track of a run after the window, poll it by run_id; don't re-send it.
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,
"async": 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, skills, certifications and education when
present. If structured history is absent, a pasted resume in profile.about is read in full. A current
title alone cannot separate a long-tenured specialist from a recent career changer, so enrich the pool
first.
With "async": true, the call detaches into a background run instead of scoring inline (falling back to
the inline walk when no background worker is running) and returns {run_id, dataset_id, status: "running", candidates, _meta} — here candidates is the row count, not the list — poll
GET /api/v2/datasets/{dataset_id} for enrich_status, then read each row's qualify cell.
min_score is not applied on this path; filter the scored cells yourself.
Returns candidates ranked by score (desc), each with qualify: {score, basis, summary, strengths, gaps},
where basis is work_history, resume_text, 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 every candidate is title-only 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.
Monitors
A monitor checks a source on a schedule and records the items it finds. Its type says what it watches
and shapes its config and its items (see types). Its first check records what's already
there as the baseline; every later check reports the new items. Each completed check costs a run
fee plus credits for each new item, billed to the workspace; items seen before are free, and a failed
check costs nothing. estimate.price says how much. Monitors belong to
the workspace, not to one member, so a member's own spending cap doesn't limit a
monitor's checks; the workspace cap still does. Anyone in the workspace can pause or delete a monitor. A bad
field answers 422 with the reason in detail, and another workspace's monitor answers 404.
Monitors are turned on per workspace: for a workspace without them, every /api/v2/monitors route is a 404.
GET /api/v2/monitorsendpointThe workspace's monitors: {"monitors": [{"id", "name", "type", "config", "schedule", "status", "next_check_at", "created_at", "config_version", "last_check"}], "timezone"}. timezone is the workspace's time zone, which each schedule is read in. config_version goes up by one each time the config changes. last_check is the most recent check, {"id", "status", "items_seen", "items_new", "credits", "error", "started_at", "finished_at"}, or null before the first.
POST /api/v2/monitorsendpointCreate a monitor. Body: {"type", "config", "name"?, "schedule"?}, with config shaped by the type. schedule is {"every", "unit", "days", "day_of_month", "time"} in the workspace's time zone: every 1-12 of unit hour, day, week or month, time as 24-hour "HH:MM" (an hour schedule uses only the minutes), days the weekdays for week (1 Monday to 7 Sunday, at least one) and day_of_month 1-31 for month (a shorter month uses its last day). Default {"every": 1, "unit": "day", "time": "09:00"}. A cron field is a 422. No name: one is taken from the config. Nothing runs when it's saved: the first check waits for the schedule's next time, or for a PATCH with check_now. Returns the monitor with an estimate. A workspace can have up to 25 monitors, and create up to 50 a day (deleting one doesn't make room for more that day). It watches each config once: a second monitor of the same type and config (compared after normalizing, so acme.com and https://ACME.com/ match) is a 422 naming the monitor that already watches it, and so is a PATCH that would make one.
GET /api/v2/monitors/estimateendpointWhat a monitor would cost before it's saved: ?type= (required, the monitor's type, for example company_jobs), ?schedule= (the schedule as JSON, default daily at 09:00) and ?config= (optional, the config so far as JSON: settings that set the price, like max_pages, are priced, the rest is ignored). Returns the same estimate as a saved monitor, with credits_last_check null, plus next_check_at, when its first check would run, or 422 saying why the type, schedule or config is refused (no type, an unknown type, a schedule that isn't valid, or a config that isn't a JSON object).
GET /api/v2/monitors/{monitor_id}endpointOne monitor with its 20 most recent checks and its estimate: {"price", "credits_per_run", "credits_per_item", "checks_per_month", "credits_last_check", "credits_per_month"}. Every completed check costs credits_per_run, plus credits_per_item for each new item; a check that fails costs nothing. credits_per_month is the run fees a month, before new items. credits_last_check is what the last completed check under the current config was charged, or null before one.
PATCH /api/v2/monitors/{monitor_id}endpointChange a monitor. Body: any of {"name", "config", "schedule", "status": "active" | "paused", "check_now": true}. Its type can't change. A config replaces the old one whole, and any change clears the items found so far and takes a new baseline: a check running under the old config stops and shows as failed ("the monitor changed during the check"), and on an active monitor the next one runs within an hour of the last. "status": "paused" also stops a running check before it pays ("monitor paused"): it records and costs nothing. A check that already paid still records what it found. A monitor whose name was taken from its old config takes one from the new config. check_now runs a check within about 5 minutes, but not on a paused monitor, nor within an hour of the last check, whatever it did.
DELETE /api/v2/monitors/{monitor_id}endpointDelete a monitor with its checks and the items it found. Returns {"deleted": true}.
GET /api/v2/monitors/{monitor_id}/itemsendpointThe items found, newest first: {"monitor_id", "items": [{"item_key", "data", "first_seen_at", "last_seen_at", "baseline"}]}. data is the item as its type describes it. baseline: true marks items already there when the monitor started or its config last changed. ?since= (ISO 8601) keeps the items first seen after it; ?limit= (1-500, default 100) and ?offset= page through.
Monitor types
type | Watches | config | Each item |
|---|---|---|---|
company_jobs | New jobs on a company's careers page. | company_url (required; a bare acme.com gets https://, and a domain that doesn't exist is a 422), and optionally company_name, known_career_page_url or known_career_page_urls, target_titles or target_titles_prompt, target_locations, max_pages, filters. With no name, the monitor is named after company_name, else the website. | A job as scraped (title, location, link). Priced per check, more for a higher max_pages, plus each new job: GET /api/v2/monitors/estimate has the current price. |
Billing
Any member can read billing: GET /billing/balance, /api/v2/billing/overview, /api/v2/billing/ledger.csv
(one row per call, with the user_email of the member who made it: every call for the workspace owner,
only your own calls for anyone else)
and /api/v2/billing/auto-top-up. Every route that changes billing (checkout, subscription change and
cancel, the billing portal, saved cards, auto top-up, spend caps, promo codes, the LinkedIn reward) is
owner-only and answers any other member with 403 {"detail": "only the workspace owner can manage billing"}.
For any other member signed in as themselves, the balance block on GET /billing/balance, /api/v2/billing/overview,
/api/v2/me and /api/v2/workspace is their own and carries "scope": "member": spent is what that
member was charged in the last 30 days, reserved is their calls still in flight, granted is their spend
cap in credits (null without one), and balance is what they can still spend. That is what the workspace
has left, bounded by its own 30-day spending cap and by the room left under their cap, so it can be lower
than granted - spent - reserved. limited_by names which of the three sets it: member_cap,
workspace_cap or workspace_balance. The workspace's grant and total spend are the owner's view, and the
overview's buckets is null for everyone else.
A member with a spend cap also gets that allowance as _meta.balance on /tools/* and /enrich/run
(and as current_balance on an /enrich/run estimate or refusal, where can_run is judged on it), and
as balance on a 402. _meta.reserved and _meta.settled there still cover the whole workspace, so
don't reconcile a capped member's balance against reserved: re-read GET /billing/balance instead.
Other metered routes (/search/*, /enrich/{capability}, /qualify, the shortlist and screening
routes) still report the workspace balance, and so does current_balance on the spend confirmation
quote, /enrich/run's included.
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 /api/v2/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 and
data-provider keys 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, each with the user_id and user_email of the
member who made the call (null for runs no member made, such as scheduled workflows). The
workspace owner sees every call. Anyone else sees only their own calls.
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). Pass
?user=<user_id> to narrow the usage series, breakdown, totals and recent entries to one member, and
repeat it (?user=a&user=b) to add several members' usage together. Without ?user=, the owner's
figures cover the whole workspace.
Usage across members is the owner's view. For the workspace owner, members is each member's spend in
the window, whatever ?user= says (user_id, email, calls, credits; user_id is null for
runs no member made, such as scheduled workflows), and each recent entry carries the user_email
of whoever made the call. For anyone else, every usage figure (series, breakdown, totals, recent,
spent_30d) is their own, members holds only their row, and ?user= may only be their own id
(another id is a 403).
POST /api/v2/billing/subscription/checkoutendpointStart a self-serve subscription. Body: {"plan_id": "starter"|"starter_1k"|"essential"|"essential_3k"|"pro"|"pro_7500", "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. When the card's bank asks the cardholder to confirm the payment (3-D Secure),
nothing changes yet: the response is applied: "requires_action" with client_secret (finish it with
Stripe.js handleNextAction), url (the same payment on a hosted page) and the unchanged
subscription. The upgrade applies once that payment succeeds, and lapses after 23 hours if it never
does. A bank debit that is still clearing answers applied: "processing": the upgrade applies when it
clears. 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.
?pay=1: for a past_due or unpaid subscription, url is its unpaid invoice's payment page instead
(falls back to the portal when there is no open invoice, or that lookup fails).
All four self-serve subscription routes (checkout, change, cancel, portal) are owner only, and all four
return 503 {"error": "stripe_disabled"} when payments aren't configured. When the payment provider refuses a request, credit checkout and the four subscription routes
answer 402 (the card was refused; message is the reason to show the cardholder) or 424 (anything
else), both shaped {"detail": {"error": "plan_change_failed", "message": "..."}} (the error code names the route).
While the workspace is scheduled for deletion, credit checkout, subscription checkout, /change
and /cancel with resume: true answer 409 (cancel the deletion first). Cancelling a plan stays open.
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}.
Per-member spend caps
The workspace owner can cap what ONE member spends, in USD, over a rolling 30 days —
independent of the workspace-wide spending cap above. A member cannot set or clear caps, not even their own. A member's own cap gates any call attributed to that member (their
30-day charged spend plus any pending holds plus the new charge): once it would cross their cap, the call
is refused with the same spending_cap_reached reason as the workspace cap, plus cap_scope: "member" so
a caller can tell which cap stopped it (the workspace cap refusal carries cap_scope: "workspace"). A
workflow or scheduled run isn't attributed to any one member, so it is governed by the workspace cap only.
PUT /api/v2/workspaces/members/{user_id}/spend-capendpointOwner only. Body: {"cap_usd": <usd > 0>}. Returns {"user_id", "cap_usd"}. 404 if
user_id isn't a member of the workspace.
DELETE /api/v2/workspaces/members/{user_id}/spend-capendpointOwner only. Clears the member's cap (back to uncapped). Returns {"user_id", "cap_usd": null}.
GET /api/v2/workspaces/members/spend-capsendpointReturns {"members": [{"user_id", "role", "cap_usd", "spent_30d_usd"}, ...]} —
cap_usd is null for an uncapped member. The owner gets every member. Anyone else gets only their
own row.
An invite can carry a cap too: POST /api/v2/workspace/invites accepts an optional spend_cap_usd
(owner only), applied to the member when they accept. An email that isn't a valid address is
refused with 400 and nothing is stored.
GET /api/v2/workspace/invites returns {"invites": [...], "seat_available": true}.
seat_available is always true because every plan allows unlimited members.
Custom skills
Skills your workspace writes itself: see Custom skills for the rules. Every route
needs a key that belongs to a signed-in user, and answers 403 otherwise. A skill you can't see answers
404. A package that isn't a valid text skill answers 400 with the reason in detail.
GET /api/v2/workspace/skillsendpointEvery skill the caller can see. Returns {"skills": [{"id", "name", "scope", "description", "author_user_id", "live_version", "latest_version", "pending_version", "draft_by_other", "created_at", "updated_at"}], "trial_expires_at"}. draft_by_other is true while another member's team version waits for approval, and you can't save a new one until the owner decides. trial_expires_at is when a free workspace's agent stops being able to use its custom skills over MCP, and null when that never happens, such as on a paid plan.
POST /api/v2/workspace/skillsendpointCreate a skill. Body: {"scope": "personal" | "org", "files": {"SKILL.md": "..."}}. Returns {"skill_id", "name", "scope", "version", "status", "changed"}. status is pending when a team skill waits for the owner. 409 if the name is taken, or the workspace already has 200 skills.
GET /api/v2/workspace/skills/{skill_id}endpointOne skill with its files and version history. ?version=N reads an older version, the default is the live one. A team skill's unapproved versions are visible only to their author and the owner. Returns the list fields plus version (with files) and versions.
PUT /api/v2/workspace/skills/{skill_id}endpointSave a new version. Body: {"files": {...}} with the whole package. Same response as create. changed is false when the content matches the latest version. 409 while another member's team version waits for approval.
POST /api/v2/workspace/skills/{skill_id}/versions/{version}/approveendpointOwner only. Makes a pending team version live. Optional body {"note": "..."}. 409 if the version isn't pending. .../reject rejects it the same way.
DELETE /api/v2/workspace/skills/{skill_id}endpointDelete a skill and all its versions. Returns {"ok": true, "deleted": skill_id}.
Plan-gated actions
Topping up on Free or connecting your own key for a data provider below Essential 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" or "topup"; provider is only set for the BYOK case. A
workspace with beta access is not subject to these limits.
Desk defaults
The search defaults your workspace saves once and the dashboard prompt builder pre-fills. The fields are discipline, role_titles, geography, industries, icp and exclusions. Each is text, up to 500 characters, except icp which allows 4000.
GET /api/v2/workspace/desk-defaultsendpointAny member can read. Returns {"desk_defaults": {"discipline": "...", ...}} with only the fields that are set, so an empty workspace gets {"desk_defaults": {}}.
PUT /api/v2/workspace/desk-defaultsendpointOwner only, 403 for a member. Body: {"desk_defaults": {...}}. It replaces the whole set, so a field you leave out is cleared. Surrounding whitespace is trimmed and empty values are dropped. An unknown field, a value that isn't text, or one over its limit answers 400 {"error": "invalid_desk_defaults", "message": "..."} and nothing is stored. Returns the saved set in the same shape as the GET.
Play matching
POST /api/v2/plays/matchendpointRanks the plays library against a plain-language request. It is free and charges no credits. Body: {"intent": "...", "slugs": ["..."]} with an intent of 1 to 500 characters. slugs is optional: send 1 to 200 play slugs to rank only those plays, or leave it out to rank the whole library. Slugs that are not in the library are ignored. If none of them is, the answer is 400 {"error": "invalid_match_request", "message": "..."}, as for any other malformed request. Each workspace gets 60 matches an hour, then 429 {"error": "rate_limit", "message": "..."}. Returns {"slugs": [...], "matched": true, "method": "model"} with up to 3 slugs, best first. matched is false when nothing clearly fits, and the slugs are then only the closest guesses. method is model or keyword: when the AI ranking is unavailable the answer falls back to matching the request's words against each play's title, keywords and summary.
API keys
POST /api/v2/keysendpointMint a key ({"name": "..."}). The plaintext key is returned once. The names session, ops-session, mcp-crm-op and crm-session are reserved for sign-in and internal use and answer 400, here and on rotate (a key that already carries one can't be rotated).
GET /api/v2/keysendpointList the caller's keys (metadata only: never the secret).
DELETE /api/v2/keys/{key_id}endpointRevoke a key. A member can revoke or rotate only keys they minted; the owner can manage any 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.
When the owner has set a workspace default, a member may only lower a session's cap below it: raising
it past the default or clearing it is 403.
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 | Every skill package, 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.