Errors, rate limits and quotas
The error envelope, every status code, the codes worth branching on, rate limits, and the hard limits to design around.
All documentation
Every failure comes back in one shape, and most of them mean something specific enough to act on.
The error envelope
{
"error": {
"code": "validation_error",
"message": "One or more fields are invalid.",
"details": [
{ "field": "recipients.0.email", "message": "Invalid email" }
]
}
}code is the contract and is safe to branch on. message is prose written for a human and may be reworded, so never match on it. details appears on validation errors and names each bad field - log it, or you are debugging blind.
Some errors carry extra keys alongside those three: requiredPlan on a 402, known_roles and missing_roles on a template send, file_ids on an unknown file, cap on a limit.
signer_missing_signature_field:<ids>, max_recipients_exceeded:10, duplicate_email:<email> and entitlement_required:<key> append their detail after a colon. An equality check against the bare name will never match. Use code.startsWith("...") for these four.Status codes
| Field | Type | Notes |
|---|---|---|
400 | Malformed, or a domain rule you can fix. Do not retry unchanged. | |
401 | Key missing, malformed, revoked, expired, or its creator lost access. Do not retry. | |
402 | The plan does not include the API, or a plan limit was reached. Not a card problem. | |
403 | Missing scope, or the workspace IP allowlist refused you. | |
404 | No such object - or it exists in the other mode. | |
409 | Valid request, wrong state: already sent, already signed, embedding not configured. | |
413 / 415 | File too large, or a type we do not accept. | |
429 | Rate limited. Back off and retry. | |
5xx | Our problem. Retry with backoff. |
Only 429 and 5xx are worth retrying automatically. Retrying a 400 or a 402 just produces the same failure more often.
Codes worth knowing by name
| Field | Type | Notes |
|---|---|---|
api_not_enabled | 402 | Workspace plan has no API access. Carries requiredPlan. |
insufficient_scope | 403 | The key lacks the scope named in the message. |
document_not_found | 404 | Missing, or created in the other mode. |
document_not_draft | 409 | You tried to send something already out. |
document_not_in_progress | 409 | Cancel and embed links both need a live document. |
unknown_role | 400 | Template role mismatch. Carries known_roles. |
missing_roles | 400 | A template role was left unfilled. Carries missing_roles. |
field_out_of_bounds | 400 | A field does not fit on its page. |
embedding_not_configured | 409 | No origins on the workspace embed allowlist. |
password_protected_pdf | 400 | Remove the password before uploading. |
corrupt_pdf | 400 | The file could not be parsed. |
unsupported_file_type | 415 | Detected from the bytes, not the declared type. |
Rate limits
10 requests per minute per key. The limit is per key, so splitting work across several keys to go faster is circumventing it - ask for a higher limit if you have a genuine bulk need.
x-ratelimit-limit: 10
x-ratelimit-remaining: 8
x-ratelimit-reset: 42x-ratelimit-reset is the seconds left in the current window and counts down toward zero. A 429 additionally carries a standard retry-after header, which is the simplest thing to honour.
{ "error": { "code": "request_failed" ... } }. There is no rate_limited code, so a switch case on one will never fire. Check res.status === 429.async function withRetry(call, attempts = 4) {
for (let i = 0; i < attempts; i++) {
const res = await call();
if (res.status !== 429 && res.status < 500) return res;
if (i === attempts - 1) return res;
const retryAfter = Number(res.headers.get("retry-after"));
const waitMs = Number.isFinite(retryAfter) && retryAfter > 0
? retryAfter * 1000
: Math.min(2 ** i * 1000, 30_000);
// Jitter, or every worker retries in the same instant.
await new Promise((r) => setTimeout(r, waitMs + Math.random() * 250));
}
}Hard limits
| Field | Type | Notes |
|---|---|---|
Requests | 10 / min | Per key. |
File size | 25 MB | Per upload. |
Files per document | 10 | Merged in order. |
Recipients per document | 10 | Signers and CCs combined, on POST /documents. |
Recipients per template send | 20 | Genuinely higher than the direct path. |
Fields per document | 500 | |
Embed link lifetime | 60 - 3600 s | Defaults to 900. |
Download URL lifetime | 600 s | Mint a fresh one rather than storing it. |
API keys | 10 | Per workspace. |
Webhook endpoints | 5 per mode | Five test and five live. |
Webhook signature tolerance | 300 s | Older deliveries should be rejected. |
List page size | 1 - 100 | Defaults to 20. |
What API sends cost
Business includes 40 API documents a month and Ultimate includes 200, flat per workspace rather than per seat. That is a free threshold, not a ceiling: document 41 still sends, billed at $0.40. Read usage on GET /account to see where you are in the current period.
A live API send counts against your normal monthly document allowance as well, because it is the same pool the web app draws from. Sandbox sends count against neither.
Mistakes worth avoiding
- Retrying a 4xx. Only 429 is retryable in that range.
- Branching on
message. It is prose.codeis the contract. - Swallowing
detailson a 400. It names the exact field that is wrong. - Retrying a send after a timeout without checking. The document may already exist. Look it up first, or you will send twice.