Errors
Failures are one flat object with a stable machine code and a message written for a human. The HTTP status carries the category, the code carries the specific reason, and the message says what to do about it.
The envelope
Success responses wrap their payload in data; collections add pagination:
{ "data": { … } }
{ "data": [ … ],
"pagination": { "limit": 50, "offset": 0, "total": 1234, "has_more": true } }Every failure is the same shape:
{ "error": { "code": "unauthorized", "message": "Invalid or missing API key." } }Branch on error.code — codes are stable machine identifiers. Messages are for people and logs; their wording can improve over time.
Status codes
| Status | Code | Meaning and what to do |
|---|---|---|
| 400 | bad_request | The request itself is malformed — for example, a body that is not valid JSON. Fix the request. |
| 400 | invalid_input | The body parsed but failed the action’s schema. The message lists what to fix — see the format below. |
| 401 | unauthorized | Missing, malformed, unknown or revoked credential. The response carries WWW-Authenticate: Bearer. Check the token, then verify with GET /api/v1. |
| 402 | plan_feature_required | The action belongs to a plan feature the workspace doesn’t currently carry. The message names it; upgrading in the app turns the action on. |
| 403 | forbidden | The credential is valid but its scope (read/write) or role doesn’t cover this operation. A token never exceeds the permissions of the member behind it. |
| 404 | not_found | No such record — or no such action: an action your credential may not run is reported as missing, so the catalog you can list and the actions you can call are the same set. |
| 409 | duplicate | The write conflicts with a record that already exists (for example, a contact with the same phone number). Read the existing record instead of retrying. |
| 422 | insert_failed and friends | The input was valid but the operation was refused — the message carries the store’s reason. Not retryable as-is. |
| 429 | rate_limited | Budget spent for this window. Honor Retry-After — details on the rate limits page. |
| 500 | server_error | Something failed on Chirply’s side. Safe to retry with backoff. |
Validation detail — invalid_input
When an action’s arguments fail its schema, the message is the flattened issue list: each entry is path: message, joined with semicolons, up to the first six issues. A problem with the body’s root reports its path as (root).
{
"error": {
"code": "invalid_input",
"message": "phone: Invalid phone number; tags.0: Expected string, received number"
}
}The authoritative field list for any action is its JSON Schema — GET /api/v1/actions/<name> or the actions reference. Validate against it client-side and invalid_input becomes a bug report, not a runtime branch.
The OAuth exception
One endpoint deliberately speaks a different dialect: POST /api/v1/oauth/token answers errors in the RFC 6749 §5.2 shape, the flat object every OAuth library already parses:
{ "error": "invalid_grant", "error_description": "This refresh token is no longer valid. Reconnect." }Its codes are the registered ones — invalid_request, invalid_client, invalid_grant, unsupported_grant_type — and when it throttles you it answers 429 with slow_down and the same Retry-After header as everywhere else.
Retry guidance
- 429 — wait the seconds in
Retry-After, then retry. The window is short (one minute), so a queued retry nearly always succeeds. - 500 and network failures — retry with exponential backoff and a small cap. Make writes idempotent on your side where you can: check for the record before recreating it.
- Every other 4xx — the request will fail the same way again until something changes. Fix the input (
400), the credential (401/403), or the plan (402) first.
A healthy client fails once
Alert on repeated 401s instead of looping on them — a revoked or rotated credential answers the same way every time, and unidentified traffic spends the shared per-address budget described under rate limits.

Chirply