Errors
Every error response on /v1/* has the same shape:
{
"error": {
"code": "rate_limited",
"message": "Rate limit exceeded (200 requests / second on pro).",
"hint": "Retry after 1 second, or upgrade for a higher cap.",
"field": "task_type"
}
}
code is the stable machine-readable identifier — switch on this in your code, not on message. field is set when the error is tied to a specific request field. hint is a human-readable suggestion, never required to display.
Catalogue
| Code | HTTP | Cause | Customer fix |
|---|---|---|---|
missing_authorization | 401 | No Authorization header sent | Send Authorization: Bearer hh_* |
invalid_api_key | 401 | Token format invalid or unrecognized | Check the key was copied in full |
key_revoked | 401 | The key existed but was revoked | Generate a new key at /api-keys |
validation_error | 422 | Request body fails schema validation | Read field and fix the input |
unknown_task_type | 422 | task_type not in catalogue and no inline human_baseline_minutes | Use a built-in, register a custom, or send a baseline inline |
occurred_at_out_of_range | 422 | occurred_at is more than 90 days old or > 24h in the future | Send a recent timestamp |
duplicate_idempotency_key | 200 | Same Idempotency-Key already used; original event returned | None — this is the success path for a retry |
rate_limited | 429 | Per-key per-second cap exceeded | Retry after Retry-After seconds |
quota_exceeded | 429 | Plan monthly cap reached | Upgrade or wait for the next period |
feature_locked | 403 | Feature requires a higher plan | Upgrade at /billing |
internal | 500 | Bug on our side | Check /status, retry with backoff |
How to handle in code
const res = await fetch("/v1/track", { ... });
if (!res.ok) {
const { error } = await res.json();
switch (error.code) {
case "rate_limited":
case "duplicate_idempotency_key":
// backoff + retry
break;
case "validation_error":
case "unknown_task_type":
// bug in your code, fix and don't retry
throw new Error(error.message);
case "feature_locked":
case "quota_exceeded":
// surface to the user; needs an upgrade
throw new Error(error.message);
default:
throw new Error(error.message);
}
}
The official SDKs (@humanhours/sdk, humanhours for Python) implement this switch with built-in retry on rate_limited + duplicate_idempotency_key.