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: true on 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-Id into your logs on every response, success or failure — or send your own and have it echoed.
  • Branch on meta.error for the envelopes that have one, and on the code key under errors.base for the rest. Never on the human-readable message.
  • Treat 404 from completed_pdf as “not ready”, and 404 from anything else as “does not exist”.
  • Never parse x-ratelimit-reset as a number.
  • Put an idempotency key on every create.
  • Log warnings[].