# 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.

Source: https://signclad.com/docs/errors-and-rate-limits/

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`: `409`s, `410`s, 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

```json
{
  "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

```json
{
  "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

```json
{
  "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

```json
{
  "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

```json
{
  "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.**

```json
{ "errors": { "files": "At least one file should be present." } }
```

```json
{ "errors": { "recipients": { "unknown_recipient": "This document has no recipient with id signer-9." } } }
```

```json
{
  "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`:

```json
{ "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:

```json
{
  "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:

```json
{
  "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

```json
{
  "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.

```json
{
  "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 `422`s 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[]`.

## Related

- [Creating a document](https://signclad.com/docs/creating-a-document/)
- [Webhooks](https://signclad.com/docs/webhooks/)
