Errors
Every failure returns the same envelope: a broad type that matches the HTTP status, a specific code stable enough to branch on, a message written for the developer reading the log, and a request id to quote at support.
{
"error": {
"type": "unprocessable_entity",
"code": "salary_structure_missing",
"message": "ext:emp-1044 has no salary structure effective on 2026-09-01.",
"doc_url": "https://www.indpayroll.com/docs/api/errors",
"request_id": "req_01J9K7YR4H"
}
}Validation failures carry field_errors as well, one entry per rejected field, with a dotted path you can map straight onto your own form.
Status codes
| Status | Type | What it means |
|---|---|---|
| 400 | validation_error | The request was malformed, or a field failed validation. |
| 401 | authentication_error | No key, an unknown key, or a signature that did not verify. |
| 403 | permission_error | The key is valid but lacks the scope, or cannot reach that organization. |
| 404 | not_found | No such resource - or it belongs to an organization the key cannot reach. |
| 409 | conflict_error | The resource's state forbids the change: a locked run, a duplicate external_id. |
| 422 | unprocessable_entity | Well-formed, but the engine cannot compute it. A missing structure, no PT state, an empty period. |
| 429 | rate_limit_error | Too many requests. Wait for Retry-After. |
| 5xx | server_error | Our fault. Safe to retry with backoff. |
Codes worth handling
| Code | Usually means |
|---|---|
| invalid_signature | The canonical string was assembled differently. Check the path and the body hash. |
| signature_expired | More than 300 seconds between signing and sending. |
| missing_scope | Ask for a key with the scope named in the message. |
| run_locked | Unlock the run, or recalculate before locking it. |
| salary_structure_missing | The employee has no structure effective in the period. |
| pt_state_missing | The employee has no professional tax state mapped. |
| duplicate_external_id | Two employees in one organization claim the same external_id. |
| period_overlaps_run | Another run already covers part of this period for these employees. |
Retrying
- Retry on 429 and 5xx, with exponential backoff and jitter.
- Do not retry 400, 403, 404, 409 or 422 unchanged - the same request will fail the same way.
- Send an
Idempotency-Keyon every mutating call so a retry after a timeout cannot pay twice. See Idempotency.
Quote the request idEvery response carries
X-Request-Id, and every error repeats it in the body. It is the fastest way for us to find what happened.