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:

json
{
  "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"
  }
}
FieldAlways presentWhat it is for
typeYesThe error family. Maps one-to-one to the HTTP status, and never changes for a given condition. Branch on this for broad handling.
codeYesThe specific reason. This is the value to branch on when you need to react to one particular failure.
messageYesA human-readable sentence. Safe to log or show to your own staff, but do not parse it — wording can change without notice.
paramOnly when a field is at faultNames the offending input, including its array index — e.g. recipients[2].email.
doc_urlYesLinks to the section on this page for that error type.
request_idYesMatches 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 429 after the Retry-After header, and 500/502 with exponential backoff. Do not retry 400, 401, 403, or 404 — the same request will fail the same way.
  • When retrying a write, send the same Idempotency-Key as the original so a request that actually succeeded is not performed twice.
  • Log request_id on 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).

CodeparamWhereWhat it means
missing_authorizationAny endpointNo `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_keyAny endpointThe 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.

CodeparamWhereWhat it means
insufficient_scopescopesAny endpoint that requires a scopeThe 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_requiredAny endpointThe 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.

CodeparamWhereWhat it means
invalid_jsonPOST /documents, POST /documents/{id}/sendThe body was not parseable JSON.
request_too_largePOST /documents, POST /documents/{id}/sendThe body exceeded the maximum accepted size.
invalid_bodybodyPOST /documentsThe top level, or a nested value expected to be an object, was an array, null, or a scalar.
invalid_limitlimitGET /documents`limit` was not an integer between 1 and 100.
invalid_cursorstarting_after or ending_beforeGET /documentsThe cursor was not one we issued. Cursors are opaque — pass back exactly what `next_cursor` gave you and never construct one.
conflicting_cursorsstarting_afterGET /documentsBoth `starting_after` and `ending_before` were supplied. Send one.
invalid_statusstatusGET /documentsThe `status` filter named a status that does not exist. The message quotes the offending value.
invalid_idempotency_keyPOST /documents, POST /documents/{id}/sendThe `Idempotency-Key` header was present but longer than 255 characters.
invalid_titletitlePOST /documents`title` was missing, empty, or longer than 200 characters.
invalid_typetypePOST /documents`type` was not one of the supported document types.
invalid_primary_languageprimary_languagePOST /documents`primary_language` was not `en` or `es`.
invalid_recipientsrecipientsPOST /documents`recipients` was not an array of 1 to 20 items.
invalid_recipient_namerecipients[N].namePOST /documentsA recipient's `name` was missing, empty, or longer than 120 characters.
invalid_channelrecipients[N].channelPOST /documents`channel` was not `email`, `sms`, or `in_person`.
invalid_recipient_emailrecipients[N].emailPOST /documents`email` was not a plausible address, or exceeded 254 characters.
missing_recipient_emailrecipients[N].emailPOST /documents`channel` was `email` but no `email` was given.
invalid_recipient_phonerecipients[N].phonePOST /documents`phone` exceeded 40 characters.
missing_recipient_phonerecipients[N].phonePOST /documentsNo `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_rolerecipients[N].rolePOST /documents`role` was not a string of at most 60 characters.
invalid_sign_orderrecipients[N].sign_orderPOST /documents`sign_order` was not a positive integer.
invalid_auth_levelrecipients[N].auth_levelPOST /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_messagecontent.messagePOST /documents`content.message` was not a string of at most 2000 characters.
invalid_sign_order_modecontent.sign_order_modePOST /documents`content.sign_order_mode` was not `sequential` or `parallel`.
invalid_expires_in_dayscontent.expires_in_daysPOST /documents`content.expires_in_days` was not an integer between 1 and 365.
document_not_sendablestatusPOST /documents/{id}/sendOnly 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_recipientsrecipientsPOST /documents/{id}/sendThe document has no recipients to send to.
no_signersrecipientsPOST /documents/{id}/sendEvery recipient is copy-only, so the document could never reach completion. At least one must actually sign.
recipient_contact_requiredrecipientsPOST /documents/{id}/sendA recipient is missing the contact detail its channel needs. The message names the recipient and the field.
approver_requires_sequentialsign_order_modePOST /documents/{id}/sendThe roster includes an approver, which only works as a gate in a sequential order. Set `content.sign_order_mode` to `sequential`.
pay_app_unsignedPOST /documents/{id}/sendThe 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.

CodeparamWhereWhat it means
document_not_foundEvery /documents/{id} endpointNo 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.

CodeparamWhereWhat it means
rate_limit_exceededAny endpointYour account's per-minute or per-day budget ran out. The message names which one.
ip_throttledAny endpoint, before authenticationToo 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).

CodeparamWhereWhat it means
idempotency_key_reusedPOST /documents, POST /documents/{id}/sendThis `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_progressPOST /documents, POST /documents/{id}/sendAn 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.

CodeparamWhereWhat it means
account_unavailableGET /accountWe could not load your account record. Retry.
documents_unavailableGET /documentsThe document query failed. Retry.
document_unavailableGET /documents/{id}, GET /documents/{id}/recipientsWe could not load the document. Retry.
recipients_unavailableGET /documents/{id}/recipientsWe could not list the recipients. Retry.
document_create_failedPOST /documentsThe draft could not be written. Nothing was created, so retrying is safe — reuse the same `Idempotency-Key`.
document_send_failedPOST /documents/{id}/sendThe send failed for an internal reason. Retry with the same `Idempotency-Key`.
idempotency_store_failedPOST /documents, POST /documents/{id}/sendWe could not record the idempotency key, so we refused to run the write rather than risk doing it twice. Retry.
key_lookup_failedAny endpointWe could not verify your key. Retry.
tenant_resolution_failedAny endpointWe could not resolve the account for your key. Retry.
tenant_divergenceAny endpointAn 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_errorAny endpointAn 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.

Errors · XOsign API docs