Preview. The npm package is not published yet. Examples on this page work today against
https://humanhours.dev/api/v1/*. For now, copy the snippet you need or use plainfetchagainst the documented endpoints; the published@humanhours/sdkpackage is on the way.
# Once the package ships:
npm install @humanhours/sdk60-second quickstart
import { Humanhours } from "@humanhours/sdk";
const hh = new Humanhours({ apiKey: process.env.HUMANHOURS_API_KEY! });
await hh.track({
agent_id: "support-classifier",
task_type: "email_classification",
outcome: "success",
});Pass model plus token counts and the run cost is priced for you and subtracted into net_saved (no agent_cost needed). Use the OpenRouter model id:
await hh.track({
agent_id: "support-classifier",
task_type: "email_classification",
outcome: "success",
model: "anthropic/claude-opus-4.8",
tokens_in: 1800,
tokens_out: 120,
});Time + track in one call
const result = await hh.withTask(
{ agent_id: "support-classifier", task_type: "email_classification" },
() => classifyEmail(subject),
);withTask measures wall-clock time for the wrapped fn, captures the outcome, and tracks it. Throws inside fn are reported as outcome="failure" and re-raised so your control flow is unchanged.
Reading numbers back
await hh.summary({ period: "30d" });
await hh.agents();
await hh.reports.raw("/time-saved?period=30d&group_by=agent");Idempotency
Every track() call sends a fresh UUID Idempotency-Key so each run is its own event. Pass your own value only when you have a per-run identifier you want to deduplicate retries against, like an inbound message id or workflow execution id:
await hh.track({...}, { idempotencyKey: messageId });The server returns the original event_id on a replay within 24h. Do not pass agent_id or any value that repeats across runs — that would dedupe every subsequent run to the first event of the day.
Errors
HumanhoursError carries the structured error code from the API.
import { HumanhoursError } from "@humanhours/sdk";
try {
await hh.track({...});
} catch (e) {
if (e instanceof HumanhoursError && e.code === "unknown_task_type") {
// pass human_baseline_minutes inline, or POST /v1/task-types to register one
}
throw e;
}Companies
Enrich domains, manage your library, and pull data for outreach. Lookup credits are consumed only on new enrichment and explicit refreshes; listing and exporting are free.
// Enrich one domain, synchronously (charges one lookup on first call)
const { company, charged } = await hh.companies.enrich({
domain: "stripe.com",
external_id: "acct_123",
tags: ["outreach-q3"],
});
// Read the outreach-ready business case
const bc = company.business_case;
console.log(bc.summary); // narrative to drop into a first-touch message
console.log(bc.annual_labour_cost_eur); // estimated annual wage bill
console.log(company.confidence.overall); // 0..1, how much to lean on the numbers
// Re-enrich when data may be stale (always charges a lookup)
await hh.companies.enrich({ domain: "stripe.com" }, { refresh: true });
// or equivalently:
await hh.companies.refresh("stripe.com");
// Bulk-enqueue a list, asynchronously (nothing charged until the job runs)
const job = await hh.companies.bulk({
domains: ["stripe.com", "vercel.com", "supabase.com"],
tags: ["outreach-q3"],
});
console.log(job.accepted, job.status); // e.g. 3 "queued"
// Poll the job until it finishes
let status = await hh.jobs.get(job.job_id);
while (status.status !== "done") {
console.log(`${status.percent}%`);
await new Promise((r) => setTimeout(r, 3000));
status = await hh.jobs.get(job.job_id);
}
// List your library (free, no credit)
const { companies } = await hh.companies.list({ tag: "outreach-q3", limit: 500 });
// Fetch one record (free)
const record = await hh.companies.get("stripe.com");
// Export full library as JSON (free)
const { companies: all } =
(await hh.companies.export()) as import("@humanhours/sdk").ListCompaniesResponse;
// Export as CSV string (free)
const csv = (await hh.companies.export({ format: "csv", tag: "outreach-q3" })) as string;Errors surface as HumanhoursError. The most common one is lookup_quota_exceeded (HTTP 402), which means your monthly cap is reached and you need to upgrade:
import { HumanhoursError } from "@humanhours/sdk";
try {
await hh.companies.enrich({ domain: "example.com" });
} catch (e) {
if (e instanceof HumanhoursError && e.code === "lookup_quota_exceeded") {
// Monthly cap hit — upgrade at /billing to continue enriching
}
throw e;
}See the Companies API reference for the full record shape, CSV columns, and bulk jobs.
Runtime support
Native fetch, zero dependencies. Works on Node 18+, Deno, Bun, Cloudflare Workers, Vercel Edge, modern browsers.