The companies API turns a domain into an outside-in wage breakdown, builds a reusable company library, and exports it for outreach. You enrich a domain once, then list, fetch, and export it as many times as you like for free. A research lookup is the unit of cost: it is consumed only when a domain is newly enriched into your library or explicitly refreshed.
Auth
All requests use a Bearer token from your API keys page. The base URL is https://humanhours.dev/api.
Authorization: Bearer hh_live_xxxxxxxx_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
The lookup model
A research lookup runs the enrichment: it researches the domain, estimates the role mix and headcount, pulls labour wages from official statistics, and assembles a business case. That work is what costs a lookup.
| Action | Lookup consumed? |
|---|---|
POST /v1/companies (domain new to your library) |
Yes |
POST /v1/companies (domain already owned, no ?refresh=true) |
No |
POST /v1/companies?refresh=true |
Yes |
POST /v1/companies/{domain}/refresh |
Yes |
POST /v1/companies/bulk (per domain, on success only) |
Yes |
GET /v1/companies (list / export) |
No |
GET /v1/companies/{domain} (single record) |
No |
GET /v1/jobs/{id} (bulk job status) |
No |
Lookup economics
Lookups are a hard cap with no overage billing. When you reach the limit, enrichment is blocked until you upgrade. Listing, fetching, and exporting companies already in your library always keep working.
| Plan | Research lookups |
|---|---|
| Free / Hobby | 10 lookups, one-time |
| Pro | 100 lookups per month |
| Agency | 500 lookups per month, pooled across workspaces |
| Enterprise | Custom |
When you hit the cap, enrichment requests return code lookup_quota_exceeded. The hint tells you to upgrade: there are no overages, so raising the limit means moving to a higher plan at /billing.
The company record
Enrichment returns an EnrichedCompany. It carries the company profile, an estimated role mix, wage data per role and country, the business case, and a per-dimension confidence breakdown.
type ConfidenceTier =
| "fetched_cited" // pulled from a cited source this run (highest trust)
| "official_statistic" // official labour statistic
| "seeded_reference" // curated reference table
| "llm_inferred" // model estimate
| "hard_fallback"; // last-resort default (lowest trust)
type EnrichedCompany = {
domain: string;
company_name?: string;
country?: string; // ISO-2
industry?: string;
headcount_estimate?: number;
roles: {
role: string; // canonical job function key (see below)
headcount: number;
confidence: ConfidenceTier;
}[];
wages: {
country: string;
role: string;
wage_data: {
blended_hourly_eur: number;
gross_hourly_eur: number;
employer_factor: number;
hours_per_year: number;
};
source?: string;
source_url?: string;
confidence: ConfidenceTier;
}[];
business_case: {
annual_labour_cost_eur?: number;
summary?: string; // outreach-ready wage narrative
};
confidence: {
overall: number; // 0..1
wages?: number; // 0..1
headcount?: number; // 0..1
roles?: number; // 0..1
};
sources: string[]; // source URLs
};Fields
| Field | Type | Notes |
|---|---|---|
domain |
string | Root domain, e.g. "stripe.com". Always present. |
company_name |
string | Enriched company name. |
country |
string | ISO 3166-1 alpha-2, e.g. "US". |
industry |
string | Industry label. |
headcount_estimate |
number | Estimated total headcount. |
roles |
array | Estimated role mix. Each entry is one canonical function with a headcount. |
wages |
array | Wage data per role and country, each entry cited where a source is available. |
business_case |
object | Annual wage bill and the outreach narrative (see below). |
confidence |
object | Per-dimension confidence, all 0..1. Honest and varies with data quality. |
sources |
string[] | Source URLs used during enrichment. |
Role functions
roles[].role and wages[].role use a canonical job function key:
customer_service, sales, marketing, operations, finance_admin,
human_resources, engineering_it, product, data_analytics,
logistics, legal, support_helpdesk, management, other
Business case
The business_case object is what you put in front of a prospect.
| Field | Type | Notes |
|---|---|---|
annual_labour_cost_eur |
number | Estimated annual wage bill across the role mix. |
summary |
string | The outreach-ready wage narrative. Drop it into a first-touch message. |
The per-function wage breakdown lives in roles and wages; the largest function and its hourly wage are the natural outreach hook.
Wages and confidence
Wages are sourced from official labour statistics (for example Eurostat, ONS, BLS, INSEE) where available, each cited with a source_url. Confidence is reported per dimension and is honest: a record with cited wages and a weak headcount estimate will show high confidence.wages and lower confidence.headcount. Treat every number as an outside-in estimate to validate against a company's own data, not as ground truth.
CSV columns
When you request format=csv, each row flattens one company. Columns, in order:
domain,external_id,company_name,country,industry,headcount_estimate,annual_labour_cost_eur,confidence,summary,tags,added_at,last_refreshed_at
tags is a pipe-separated list within the cell (e.g. outreach-q3|enterprise). Empty optional fields are empty strings. The first row is always the header.
POST /v1/companies
Enrich a single domain and add it to your library. This call is synchronous: it researches and enriches the domain inline (typically 15-30 seconds) and returns the full record in the response. Use the bulk endpoint for large lists.
It charges one lookup when the company is new to your library, or when you pass ?refresh=true. Returning a company you already own without refresh is free.
Request
curl -X POST https://humanhours.dev/api/v1/companies \
-H "Authorization: Bearer $HUMANHOURS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"domain": "stripe.com",
"external_id": "acct_123",
"tags": ["outreach-q3", "enterprise"]
}'Body fields:
| Field | Type | Required | Notes |
|---|---|---|---|
domain |
string | Yes | Root domain, e.g. "stripe.com". Subdomains are normalised automatically. |
external_id |
string | No | Your stable cross-system id for this company. |
tags |
string[] | No | Free-form labels for segmentation and export filtering. |
Query parameters:
| Parameter | Default | Notes |
|---|---|---|
refresh |
false | Set ?refresh=true to re-enrich and charge a lookup even if the domain is already in your library. |
Response (201 when newly added)
{
"company": {
"domain": "stripe.com",
"company_name": "Stripe",
"country": "US",
"industry": "Financial Technology",
"headcount_estimate": 8000,
"roles": [
{ "role": "engineering_it", "headcount": 3200, "confidence": "llm_inferred" },
{ "role": "customer_service", "headcount": 900, "confidence": "seeded_reference" }
],
"wages": [
{
"country": "US",
"role": "customer_service",
"wage_data": {
"blended_hourly_eur": 28.5,
"gross_hourly_eur": 21.0,
"employer_factor": 1.36,
"hours_per_year": 1800
},
"source": "BLS Occupational Employment Statistics",
"source_url": "https://www.bls.gov/oes/",
"confidence": "official_statistic"
}
],
"business_case": {
"annual_labour_cost_eur": 410000000,
"summary": "Stripe runs an estimated 900-person customer service function, its largest, at around 30 EUR an hour. Total annual labour cost is in the order of 410 million. These are outside-in estimates to validate against the company's own figures."
},
"confidence": {
"overall": 0.71,
"wages": 0.9,
"headcount": 0.6,
"roles": 0.65
},
"sources": ["https://www.bls.gov/oes/", "https://stripe.com/jobs"]
},
"charged": true
}201 with charged: true means the company was newly added and one lookup was consumed. When the company was already in your library and you did not pass refresh, the response is 200 with charged: false and no lookup is used. With ?refresh=true the response is 200 with charged: true.
Errors
| Code | When |
|---|---|
validation_error |
domain missing or not a valid domain string |
lookup_quota_exceeded |
Monthly research-lookup cap reached; upgrade to raise the limit |
internal |
Enrichment failed unexpectedly; retry |
GET /v1/companies
List your company library, or export it. Free; no lookup consumed. This is the outreach pull.
Request
# JSON
curl "https://humanhours.dev/api/v1/companies?tag=outreach-q3&limit=500" \
-H "Authorization: Bearer $HUMANHOURS_API_KEY"
# CSV download
curl "https://humanhours.dev/api/v1/companies?format=csv&tag=outreach-q3" \
-H "Authorization: Bearer $HUMANHOURS_API_KEY" \
-o outreach-q3.csvQuery parameters:
| Parameter | Default | Notes |
|---|---|---|
format |
json |
json returns a JSON body; csv returns a CSV file download. |
tag |
(none) | Filter to companies that carry this tag. |
limit |
500 | Number of records to return. Max 1000. |
Response (200), JSON
format=json returns the library rows. Each row wraps the EnrichedCompany together with your library metadata.
{
"companies": [
{
"domain": "stripe.com",
"external_id": "acct_123",
"tags": ["outreach-q3", "enterprise"],
"added_at": "2026-05-31T14:00:00.000Z",
"last_refreshed_at": "2026-05-31T14:00:00.000Z",
"company": { "domain": "stripe.com", "company_name": "Stripe" }
}
]
}Response (200), CSV
format=csv returns a CSV file download. See CSV columns for the column order.
domain,external_id,company_name,country,industry,headcount_estimate,annual_labour_cost_eur,confidence,summary,tags,added_at,last_refreshed_at
stripe.com,acct_123,Stripe,US,Financial Technology,8000,410000000,24000000,19000000,customer_service,900,46000000,0.52,0.71,"Stripe runs an estimated 900-person...",outreach-q3|enterprise,2026-05-31T14:00:00.000Z,2026-05-31T14:00:00.000Z
Errors
| Code | When |
|---|---|
validation_error |
limit out of range or bad format |
GET /v1/companies/export
Download your entire library as a single file. Free; no lookup consumed. Unlike GET /v1/companies (capped at 1000 rows), this endpoint pages through every company you own and streams one downloadable file. Use it for the full outreach export.
Request
# CSV (default)
curl "https://humanhours.dev/api/v1/companies/export?tag=outreach-q3" \
-H "Authorization: Bearer $HUMANHOURS_API_KEY" \
-o companies.csv
# JSON
curl "https://humanhours.dev/api/v1/companies/export?format=json" \
-H "Authorization: Bearer $HUMANHOURS_API_KEY" \
-o companies.jsonQuery parameters:
| Parameter | Default | Notes |
|---|---|---|
format |
csv |
csv or json. Both return a file download. |
tag |
(none) | Filter to companies that carry this tag. |
Response (200)
A file download (companies.csv or companies.json). CSV uses the same column order as the list endpoint; JSON returns { "companies": [ ... ] } with every row.
GET /v1/companies/{domain}
Fetch a single record from your library. Free; no lookup consumed. Returns 404 when the domain is not in your library.
Request
curl https://humanhours.dev/api/v1/companies/stripe.com \
-H "Authorization: Bearer $HUMANHOURS_API_KEY"Response (200)
{
"domain": "stripe.com",
"external_id": "acct_123",
"tags": ["outreach-q3", "enterprise"],
"added_at": "2026-05-31T14:00:00.000Z",
"last_refreshed_at": "2026-05-31T14:00:00.000Z",
"company": {
"domain": "stripe.com",
"company_name": "Stripe",
"business_case": {
"summary": "Stripe runs an estimated 900-person customer service function..."
},
"confidence": { "overall": 0.71 }
}
}Errors
| Code | When |
|---|---|
not_found |
Domain not in your library (enrich it first) |
POST /v1/companies/{domain}/refresh
Re-enrich a company you already own. Always charges one lookup. Use this when the data may be stale. Returns 404 when the domain is not in your library.
Request
curl -X POST https://humanhours.dev/api/v1/companies/stripe.com/refresh \
-H "Authorization: Bearer $HUMANHOURS_API_KEY"Response (200)
Same EnrichedCompany shape as POST /v1/companies, with charged: true.
{
"company": { "domain": "stripe.com", "company_name": "Stripe" },
"charged": true
}Errors
| Code | When |
|---|---|
not_found |
Domain not in your library |
lookup_quota_exceeded |
Monthly research-lookup cap reached; upgrade to raise the limit |
POST /v1/companies/bulk
Enrich a list of domains. This call is asynchronous: it dedupes and validates the input, queues a background job, and returns 202 immediately. Nothing is charged at submission time. The background worker charges one lookup per domain as it processes, and only on a successful enrichment. Poll GET /v1/jobs/{id} for progress.
Request
curl -X POST https://humanhours.dev/api/v1/companies/bulk \
-H "Authorization: Bearer $HUMANHOURS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"domains": ["stripe.com", "vercel.com", "supabase.com"],
"tags": ["outreach-q3"]
}'Body fields:
| Field | Type | Required | Notes |
|---|---|---|---|
domains |
string[] | Yes | Up to 1000 domains. Duplicates and invalids are dropped. |
tags |
string[] | No | Applied to every company the job enriches. |
Response (202)
{
"job_id": "job_9f2c...",
"accepted": 3,
"rejected": 0,
"duplicates_removed": 0,
"status": "queued"
}accepted is the number of domains queued for enrichment after dedupe and validation. rejected counts invalid domains dropped; duplicates_removed counts duplicates removed from the input.
Errors
| Code | When |
|---|---|
validation_error |
domains missing, empty, or over 1000 entries |
GET /v1/jobs/{id}
Poll a bulk job for progress. Free; no lookup consumed. Poll until status is done.
Request
curl https://humanhours.dev/api/v1/jobs/job_9f2c... \
-H "Authorization: Bearer $HUMANHOURS_API_KEY"Response (200)
{
"job_id": "job_9f2c...",
"status": "running",
"input_count": 3,
"accepted": 3,
"processed_count": 1,
"error_count": 0,
"percent": 33
}| Field | Type | Notes |
|---|---|---|
status |
string | queued, running, or done. |
input_count |
number | Domains in the original submission. |
accepted |
number | Domains queued after dedupe and validation. |
processed_count |
number | Domains enriched so far. |
error_count |
number | Domains that failed enrichment (no lookup charged on these). |
percent |
number | Progress, 0-100. |
When status is done, pull the results with GET /v1/companies, filtered by the tag you applied.
Errors
| Code | When |
|---|---|
not_found |
Job id does not exist |
Error reference
Every error is JSON in this shape:
{
"error": {
"code": "lookup_quota_exceeded",
"message": "...",
"hint": "Upgrade your plan to raise the limit."
}
}| Code | Cause | Fix |
|---|---|---|
validation_error |
Bad or missing domain, or invalid request body | Send a valid root domain and body |
lookup_quota_exceeded |
Monthly research-lookup cap reached | Upgrade at /billing; there are no overages |
not_found |
Company or job not found | Enrich the domain first, or check the job id |
internal |
Unexpected server error | Retry; if it persists, contact support |
See Errors for the full catalogue.