Errors and rate limits
The exact error bodies for every status, the rate-limit buckets, the headers on every response, and how idempotency interacts with both.
Updated
On this page
These shapes are pinned by recorded fixtures because a client that branches on them cannot afford them to change. Where a shape looks odd, it is odd on purpose: it matches the compatibility target byte for byte.
Errors
Every response — error or not — carries X-Request-Id, and a request_id header with the same value. Supply either header yourself and Signclad echoes it back, so your trace id and ours are the same string; otherwise it is a UUID. Some envelopes repeat it in the body as request_id: 409s, 410s, and the code-keyed 422 under errors.base. The field-validation 422 does not carry one — read the header.
An unparseable body
There is no distinct status for this. A Content-Type: application/json request whose body is not valid JSON is read as an empty body, so it fails validation like an empty request would: 422, with errors.files and errors.recipients telling you a field is missing rather than that your JSON is broken. If you get a 422 complaining about fields you are certain you sent, check that what you sent parses.
401 — bad or missing key
{
"message": "Missing or invalid authorization key",
"meta": {
"error": "api_key_unauthorized_error",
"message": "Not valid authorization token",
"messages": ["Not valid authorization token"]
}
}
Note that meta carries message and messages[], not just error.
403 — not allowed
{
"message": "Forbidden",
"meta": {
"error": "insufficient_permissions",
"message": "You are not allowed to do this.",
"messages": ["You are not allowed to do this."]
}
}
message is always the bare Forbidden; meta is always present, so there is one branch to write rather than “some 403s carry a code and some do not”. Codes you will meet include insufficient_permissions, api_key_not_a_person, and sandbox_key_not_allowed; some add keys of their own, such as required_capability and role.
Authenticated, but not permitted: insufficient role, a credential type the route refuses, a sandbox key outside its allow-list, or a signer acting out of turn. A plan that does not include the feature is a 422 under errors.base, not a 403.
404 — no such record
{
"message": "Not found",
"meta": {
"error": "record_not_found",
"message": "Couldn't find the document requested",
"messages": ["Couldn't find the document requested"]
}
}
meta.message names the resource: “document” for everything the compatibility surface also has — documents, templates, recipients, fields, files — and its own noun for Signclad’s additions (“Couldn’t find the API key requested”, “Couldn’t find the webhook endpoint requested”).
A 404 from Signclad is authoritative. It means the record does not exist for your workspace. It is also what a record in another workspace answers, deliberately, so no id is ever confirmed by a permission error.
The one exception is GET /documents/{id}/completed_pdf, which answers 404 until every signer has finished, with a body byte-identical to a genuine 404. Do not treat that as “document missing”.
409 — wrong state
{
"message": "Document is no longer active",
"meta": {
"error": "document_not_active",
"message": "Document is no longer active",
"messages": ["Document is no longer active"],
"state": "completed"
},
"request_id": "3f1c9a2e-6b7d-4c8e-9a0b-1c2d3e4f5a6b"
}
One envelope for every conflict: message, then meta with error, message and messages[], then request_id. Whatever the domain adds — state, expected_revision — is strictly on top of that.
Codes you will see here: document_not_active, document_finalizing (the document is being sealed; nothing races the seal), revision_mismatch, idempotency_in_progress, self_revoke.
410 — gone
{
"message": "This signing link is no longer valid",
"meta": {
"error": "link_not_found",
"message": "This signing link is no longer valid",
"messages": ["This signing link is no longer valid"]
},
"request_id": "3f1c9a2e-6b7d-4c8e-9a0b-1c2d3e4f5a6b"
}
The link or session was revoked, superseded, used, or expired. The code is one of link_not_found, link_revoked, link_expired, link_already_used, link_superseded, and it is what the signer app switches on to pick a page. An unknown token and a revoked one answer identically on purpose: anything else would let a probe tell a real recipient id from a fabricated one.
422 — validation failed
This is the envelope worth studying. errors maps a field name to a message string, or to an object keyed by an error code or a per-item key. Values are never arrays.
{ "errors": { "files": "At least one file should be present." } }
{ "errors": { "recipients": { "unknown_recipient": "This document has no recipient with id signer-9." } } }
{
"errors": {
"template_id": {
"not_in_account": "There's no template with id 0000... in your account."
},
"recipients": {
"recipient_1": {
"unexisting_placeholder_name": "This placeholder_name is not present in the template(s): signer."
}
}
}
}
Only the first message per attribute survives, so a leaf is a string and never a list. Note there is no request_id in this envelope — take it from the X-Request-Id header.
Per-field errors on template_fields arrive keyed by api_id:
{ "errors": { "template_fields": { "borrower_start_date": { "invalid_date_format": "..." } } } }
That shape is what lets you fix one value and retry, instead of sanitising everything and resending.
If you would rather not fail at all, skip_invalid_fields: true drops the offending entries and reports them in warnings[].
A refusal that belongs to the request as a whole rather than to one attribute arrives under errors.base, keyed by a machine-readable code, with a request_id alongside:
{
"errors": { "base": { "not_available_on_plan": "Conditional rules are not included on this plan." } },
"request_id": "3f1c9a2e-6b7d-4c8e-9a0b-1c2d3e4f5a6b"
}
Codes here include not_available_on_plan, account_read_only, template_read_only, document_limit_reached, unprocessable_entity, and file_rejected.
One 422 is shaped differently again, because its attribute is a header rather than a body field:
{
"errors": { "Idempotency-Key": { "idempotency_key_reused": "This Idempotency-Key was already used with a different request body. Use a new key." } },
"meta": { "error": "idempotency_key_reused" }
}
429 — rate limited
{
"message": "Too many requests. 429 Too Many Requests",
"meta": {
"error": "rate_limit_exceeded",
"message": "Too many requests. 429 Too Many Requests",
"messages": ["Too many requests. 429 Too Many Requests"]
}
}
Wait Retry-After seconds. The message keeps the 429 Too Many Requests substring that existing integrations match on.
Warnings
warnings[] is where Signclad tells you it accepted something the compatibility target would have rejected. Warnings never fail a request.
{
"warnings": [
{ "code": "lock_sign_date_value_ignored",
"message": "A value was supplied for borrower_date, which fills itself on signing. It was ignored.",
"pointer": "/fields/0/1/value" }
]
}
Codes include lock_sign_date_value_ignored, field_skipped and template_field_skipped (what skip_invalid_fields drops), field_api_id_collision, pre_signed_content_superseded, and no_text_tags_found on a file replacement. Log them: a warning today is often a bug in your mapping that would have been a 422 elsewhere.
Rate limits
Per minute, per credential, and the bucket is chosen from the request method, not from a list of endpoints:
| Bucket | Limit | Applies to |
|---|---|---|
| General | 120/min | Every GET, HEAD — and every DELETE. |
| Creates and mutations | 10/min | Every POST, PUT and PATCH, with one exception below. |
| Uploads | 120/min | POST /files. Each file in a batch is its own request, and uploading must not spend the budget for creating the document. |
| Unauthenticated | 50/min | Requests with no resolved credential, keyed by IP. A request that never matched a route lands here too, so probing for paths is not free. |
DELETE counts as general on purpose: the tight bucket exists to slow document creation down, and a client cleaning up after a failed batch should not exhaust its create budget doing so.
Sandbox keys use the same buckets and the same limits as live keys.
A failed create still charges the create bucket. A run of 422s will exhaust it, so fix validation before retrying in a loop.
There is no separate bucket for test mode.
Headers, on every response
| Header | Meaning |
|---|---|
x-ratelimit-limit |
The bucket’s limit. |
x-ratelimit-remaining |
What is left in this window. |
x-ratelimit-reset |
An ISO 8601 timestamp, e.g. 2026-09-09T20:56:00+00:00. Not a seconds count. |
RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset |
The standard headers, added alongside. RateLimit-Reset is in seconds. |
Retry-After |
On 429, in seconds. |
X-Request-Id |
On every response. Quote it when you ask us about a request. |
The difference between the two reset headers catches people. If your client parses x-ratelimit-reset as a number, it will get NaN.
Backing off
Respect Retry-After. Where it is absent, exponential backoff with jitter, capped at a minute. Never retry a 422 unchanged — the input is wrong and the create bucket is being spent on it.
Idempotency
Idempotency-Key on any write, scoped to the credential — API key or OAuth token — plus method plus path, kept 24 hours. A browser session has no stable actor to scope it to, so the header is ignored there.
- A retry with the same key and the same body returns the original response, including its original status, with
Idempotent-Replay: trueon it. - A request that is still in flight answers
409 idempotency_in_progress. - The same key with a different body answers
422 idempotency_key_reused. It is never a silent overwrite. - A request that fails with a
5xx, or throws, releases the key, so the retry you were told to make actually does the work. Only a completed response is stored. - Use one key per logical operation: a UUID generated when your job starts, not when the HTTP call is made, so a retried job reuses it.
A practical client checklist
- Read
X-Request-Idinto your logs on every response, success or failure — or send your own and have it echoed. - Branch on
meta.errorfor the envelopes that have one, and on the code key undererrors.basefor the rest. Never on the human-readablemessage. - Treat
404fromcompleted_pdfas “not ready”, and404from anything else as “does not exist”. - Never parse
x-ratelimit-resetas a number. - Put an idempotency key on every create.
- Log
warnings[].