Core concepts
Errors
Every failure returns the same JSON shape, and every error you receive links back to this page.
The envelope
Errors never come back as a bare string or a status code alone. They always look like this:
{
"error": {
"type": "validation_error",
"code": "missing_recipient_email",
"message": "email is required when channel is 'email'.",
"param": "recipients[0].email",
"doc_url": "https://xosign.ai/docs/api/errors#validation_error",
"request_id": "req_b07b069f0a83492877e3fa31"
}
}| Field | Always present | What it is for |
|---|---|---|
type | Yes | The error family. Maps one-to-one to the HTTP status, and never changes for a given condition. Branch on this for broad handling. |
code | Yes | The specific reason. This is the value to branch on when you need to react to one particular failure. |
message | Yes | A human-readable sentence. Safe to log or show to your own staff, but do not parse it — wording can change without notice. |
param | Only when a field is at fault | Names the offending input, including its array index — e.g. recipients[2].email. |
doc_url | Yes | Links to the section on this page for that error type. |
request_id | Yes | Matches the X-Request-Id response header. Quote it to support — it is how we find your exact request. |
What is stable and what is not
type and code are part of the API contract and will not be renamed within v1. New code values may be added to an existing type, so treat an unrecognised code as its type and handle it generically rather than failing.
Handling errors well
- Retry
429after theRetry-Afterheader, and500/502with exponential backoff. Do not retry400,401,403, or404— the same request will fail the same way. - When retrying a write, send the same
Idempotency-Keyas the original so a request that actually succeeded is not performed twice. - Log
request_idon every failure. It is the only identifier that lets us find your request in our logs.
Error types
Each heading below is the anchor that the doc_url in your error response points to.
authentication_errorHTTP 401
The request carried no usable API key, so we could not tell who you are.
Send `Authorization: Bearer xo_live_…`. Every failure mode — missing, malformed, revoked, expired, or simply wrong — returns the same message on purpose, so the response cannot be used to probe which keys exist. Check the key was copied whole, has not been revoked in Settings, and that you are calling https://xosign.ai (see the canonical-host warning on the Authentication page).
| Code | param | Where | What it means |
|---|---|---|---|
missing_authorization | — | Any endpoint | No `Authorization` header at all. This is the one auth failure with its own code, because it cannot leak anything about which keys exist. |
invalid_api_key | — | Any endpoint | The key was malformed, unknown, revoked, expired, or issued for a different mode. All of these return this one code with an identical message — by design, so the endpoint is not an oracle for valid keys. |
permission_errorHTTP 403
We know who you are, but this key or this plan is not allowed to do that.
Either the key lacks the scope the endpoint requires (`insufficient_scope`) or the account's plan does not include API access (`api_access_required`). Scopes are fixed at issuance and cannot be widened — issue a new key with the scopes you need.
| Code | param | Where | What it means |
|---|---|---|---|
insufficient_scope | scopes | Any endpoint that requires a scope | The key is valid but was not issued with the scope this endpoint requires. Keys issued before scopes existed were backfilled read-only, so an old key will hit this on any write or send. Issue a new key with the scopes you need. |
api_access_required | — | Any endpoint | The account's plan does not include API access. Upgrade the plan, then retry with the same key. |
validation_errorHTTP 400
The request reached us intact but something in it was not acceptable.
Read `code` for the machine-readable reason and `param` for the exact field — including the array index, e.g. `recipients[2].email`. These are safe to branch on; `message` is human-facing and may be reworded.
| Code | param | Where | What it means |
|---|---|---|---|
invalid_json | — | POST /documents, POST /documents/{id}/send | The body was not parseable JSON. |
request_too_large | — | POST /documents, POST /documents/{id}/send | The body exceeded the maximum accepted size. |
invalid_body | body | POST /documents | The top level, or a nested value expected to be an object, was an array, null, or a scalar. |
invalid_limit | limit | GET /documents | `limit` was not an integer between 1 and 100. |
invalid_cursor | starting_after or ending_before | GET /documents | The cursor was not one we issued. Cursors are opaque — pass back exactly what `next_cursor` gave you and never construct one. |
conflicting_cursors | starting_after | GET /documents | Both `starting_after` and `ending_before` were supplied. Send one. |
invalid_status | status | GET /documents | The `status` filter named a status that does not exist. The message quotes the offending value. |
invalid_idempotency_key | — | POST /documents, POST /documents/{id}/send | The `Idempotency-Key` header was present but longer than 255 characters. |
invalid_title | title | POST /documents | `title` was missing, empty, or longer than 200 characters. |
invalid_type | type | POST /documents | `type` was not one of the supported document types. |
invalid_primary_language | primary_language | POST /documents | `primary_language` was not `en` or `es`. |
invalid_recipients | recipients | POST /documents | `recipients` was not an array of 1 to 20 items. |
invalid_recipient_name | recipients[N].name | POST /documents | A recipient's `name` was missing, empty, or longer than 120 characters. |
invalid_channel | recipients[N].channel | POST /documents | `channel` was not `email`, `sms`, or `in_person`. |
invalid_recipient_email | recipients[N].email | POST /documents | `email` was not a plausible address, or exceeded 254 characters. |
missing_recipient_email | recipients[N].email | POST /documents | `channel` was `email` but no `email` was given. |
invalid_recipient_phone | recipients[N].phone | POST /documents | `phone` exceeded 40 characters. |
missing_recipient_phone | recipients[N].phone | POST /documents | No `phone` was given but this recipient needs one — either `channel` was `sms`, or `auth_level` was `l1_email_sms_otp` (the one-time code is texted even when the signing link goes by email). |
invalid_recipient_role | recipients[N].role | POST /documents | `role` was not a string of at most 60 characters. |
invalid_sign_order | recipients[N].sign_order | POST /documents | `sign_order` was not a positive integer. |
invalid_auth_level | recipients[N].auth_level | POST /documents | `auth_level` was not one of the supported values, or was `l1_email_sms_otp` on a recipient whose `role` is `cc` or `approver` — SMS verification is enforced on the signing path only. |
invalid_content_message | content.message | POST /documents | `content.message` was not a string of at most 2000 characters. |
invalid_sign_order_mode | content.sign_order_mode | POST /documents | `content.sign_order_mode` was not `sequential` or `parallel`. |
invalid_expires_in_days | content.expires_in_days | POST /documents | `content.expires_in_days` was not an integer between 1 and 365. |
document_not_sendable | status | POST /documents/{id}/send | Only a draft or in-review document can be sent. A document that is already sent, completed, voided, or expired cannot be sent again — and this is also what a duplicate send looks like if you did not use an `Idempotency-Key`. |
no_recipients | recipients | POST /documents/{id}/send | The document has no recipients to send to. |
no_signers | recipients | POST /documents/{id}/send | Every recipient is copy-only, so the document could never reach completion. At least one must actually sign. |
recipient_contact_required | recipients | POST /documents/{id}/send | A recipient is missing the contact detail its channel needs. The message names the recipient and the field. |
approver_requires_sequential | sign_order_mode | POST /documents/{id}/send | The roster includes an approver, which only works as a gate in a sequential order. Set `content.sign_order_mode` to `sequential`. |
pay_app_unsigned | — | POST /documents/{id}/send | The document mirrors a pay application whose signature block is still blank. Sign the pay app first. |
not_foundHTTP 404
No such resource is visible to your account.
This deliberately does not distinguish 'does not exist' from 'belongs to someone else' — a 404 is what another tenant's document looks like. A malformed id returns 404 rather than 400 for the same reason. Templates are also invisible to v1 and return 404.
| Code | param | Where | What it means |
|---|---|---|---|
document_not_found | — | Every /documents/{id} endpoint | No document with that id is visible to your account. Also returned for a malformed id and for templates, which v1 does not expose. |
rate_limit_errorHTTP 429
You sent more requests than your plan allows.
Back off for `Retry-After` seconds, then retry. Read `X-RateLimit-Remaining-Burst` and `-Daily` to see which budget ran out. See Rate limits for the per-plan numbers.
| Code | param | Where | What it means |
|---|---|---|---|
rate_limit_exceeded | — | Any endpoint | Your account's per-minute or per-day budget ran out. The message names which one. |
ip_throttled | — | Any endpoint, before authentication | Too many requests from one IP address, measured before your key is even read. This is an abuse rail against unauthenticated floods, not your plan's limit. |
idempotency_errorHTTP 409
This `Idempotency-Key` conflicts with a request we have already seen.
Either you reused a key with a different body (`idempotency_key_reused` — use a fresh key per distinct operation) or an earlier request with the same key is still running (`idempotency_in_progress` — wait and retry the identical request, which will replay the original response).
| Code | param | Where | What it means |
|---|---|---|---|
idempotency_key_reused | — | POST /documents, POST /documents/{id}/send | This `Idempotency-Key` was already used with a different request body. Keys bind to one exact body — use a fresh key for a different operation. |
idempotency_in_progress | — | POST /documents, POST /documents/{id}/send | An earlier request with this key has not finished. Retry the identical request shortly; you will get the original response, not a second write. |
api_errorHTTP 500
Something failed on our side. Nothing is wrong with your request.
Retry with backoff. These are also returned as 502 when a downstream dependency is the cause. Quote the `request_id` when contacting support — it is the only way we can find your exact request in our logs.
| Code | param | Where | What it means |
|---|---|---|---|
account_unavailable | — | GET /account | We could not load your account record. Retry. |
documents_unavailable | — | GET /documents | The document query failed. Retry. |
document_unavailable | — | GET /documents/{id}, GET /documents/{id}/recipients | We could not load the document. Retry. |
recipients_unavailable | — | GET /documents/{id}/recipients | We could not list the recipients. Retry. |
document_create_failed | — | POST /documents | The draft could not be written. Nothing was created, so retrying is safe — reuse the same `Idempotency-Key`. |
document_send_failed | — | POST /documents/{id}/send | The send failed for an internal reason. Retry with the same `Idempotency-Key`. |
idempotency_store_failed | — | POST /documents, POST /documents/{id}/send | We could not record the idempotency key, so we refused to run the write rather than risk doing it twice. Retry. |
key_lookup_failed | — | Any endpoint | We could not verify your key. Retry. |
tenant_resolution_failed | — | Any endpoint | We could not resolve the account for your key. Retry. |
tenant_divergence | — | Any endpoint | An internal consistency check on account scoping failed and the request was refused. This is a safety stop, never a data leak. Contact support with the `request_id`. |
internal_error | — | Any endpoint | An unhandled internal failure. Contact support with the `request_id`. |
Why every bad key returns the same message
A missing, malformed, revoked, expired, or simply wrong key all return an identical 401. That is deliberate: if the responses differed, the endpoint could be used to work out which keys exist. For the same reason, a document belonging to another account returns 404, not 403.