Errors
Every error is JSON with a stable machine-readable code and an HTTP status you can branch on. Codes never change meaning within v1. New codes may be added over time, so treat a code you do not recognize as a generic failure for its status.
The two envelope shapes
Where an error comes from decides its shape. Errors raised before your request reaches an
endpoint (authentication, the plan entitlement gate, the rate limiter) use the transport
envelope; errors raised by the endpoint itself use standard RFC 7807 problem details. Both
are served as application/problem+json.
Transport errors
Returned by API key checks, the entitlement gate, and the rate limiter. The code
is in the error field; detail mirrors message.
{
"error": "invalid_api_key",
"title": "Invalid API key",
"message": "The API key is invalid or has been revoked.",
"detail": "The API key is invalid or has been revoked.",
"status": 401
}
Covers missing_api_key, invalid_api_key,
api_access_required, and rate_limited.
Endpoint errors
Standard RFC 7807 problem details from the endpoint itself. Where the endpoint
sets a code, it is carried in the title field.
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.5",
"title": "unknown_user",
"status": 404,
"detail": "No employee was found for external id 'E-9999'.",
"traceId": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-00"
}
Requests that fail input validation (for example a blank firstName on
an employee update) return this same shape with an errors dictionary
keyed by field name and no code in title.
Error codes
| Code | Status | Envelope | Meaning |
|---|---|---|---|
missing_api_key | 401 | Transport | No key on the request. Send it via Authorization: Bearer or X-Api-Key. |
invalid_api_key | 401 | Transport | The key does not exist or has been revoked. |
api_access_required | 402 | Transport | The key is valid but the plan does not include API access. |
invalid_return_url | 400 | Endpoint | The returnUrl passed to /today is not on the tenant's exact-match allowlist. |
unknown_user | 404 | Endpoint | No employee matches the external id in the URL. |
duplicate_external_id | 409 | Endpoint | POST /employees with an external id that already exists. |
seat_cap_reached | 409 | Endpoint | POST /employees would exceed the plan's employee limit. |
rate_limited | 429 | Transport | Too many requests in the current one minute window. Honor the Retry-After header. |
Sample bodies
missing_api_key 401
{
"error": "missing_api_key",
"title": "Missing API key",
"message": "Provide your key via 'Authorization: Bearer <key>' or the 'X-Api-Key' header.",
"detail": "Provide your key via 'Authorization: Bearer <key>' or the 'X-Api-Key' header.",
"status": 401
}
invalid_api_key 401
{
"error": "invalid_api_key",
"title": "Invalid API key",
"message": "The API key is invalid or has been revoked.",
"detail": "The API key is invalid or has been revoked.",
"status": 401
}
api_access_required 402
{
"error": "api_access_required",
"title": "API access required",
"message": "This tenant's plan does not include API access. Add the API access option or start a trial.",
"detail": "This tenant's plan does not include API access. Add the API access option or start a trial.",
"status": 402
}
invalid_return_url 400
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "invalid_return_url",
"status": 400,
"detail": "The returnUrl is not in this tenant's allowlist.",
"traceId": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-00"
}
unknown_user 404
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.5",
"title": "unknown_user",
"status": 404,
"detail": "No employee was found for external id 'E-9999'.",
"traceId": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-00"
}
duplicate_external_id 409
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.10",
"title": "duplicate_external_id",
"status": 409,
"detail": "An employee already exists for external id 'E-1001'.",
"traceId": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-00"
}
seat_cap_reached 409
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.10",
"title": "seat_cap_reached",
"status": 409,
"detail": "Employee limit reached (25). Add seats in Billing to raise the cap.",
"traceId": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-00"
}
rate_limited 429
# Response headers HTTP/1.1 429 Too Many Requests Retry-After: 60 Content-Type: application/problem+json { "error": "rate_limited", "title": "Too many requests", "message": "Rate limit exceeded. Wait for the current one minute window to reset, then retry.", "detail": "Rate limit exceeded. Wait for the current one minute window to reset, then retry.", "status": 429 }
Rate limits
Limits use fixed one minute windows with no request queueing. When a window is full, every
further request in that window gets 429 rate_limited with
Retry-After: 60.
| Caller | Limit | Counted per |
|---|---|---|
Sandbox key (tbm_test_) | 60 requests per minute | Caller IP (the key is shared) |
Live keys (tbm_live_) | 300 requests per minute | Key |
| No key (health, OpenAPI) | 30 requests per minute | Caller IP |
Handling errors well
- 401 and 402 are configuration problems, not transient faults. Alert an operator instead of retrying.
- 404 unknown_user usually means your roster sync has not created the employee yet. Create it, then retry.
- 409 duplicate_external_id on create means the employee already exists. Treat it as success and switch to
PUTif you need to update. - 429 resolves itself. Wait the
Retry-Afterseconds, then continue; do not tight-loop. - 5xx is ours. Retry with backoff, and remember our stance: your systems own the policy for what happens while we are down.