Errors
The eContract error envelope, every error code, the X-Request-Id header, and which errors are safe to retry and which are not.
Errors use standard HTTP status codes and a JSON body with a machine-readable code. Branch on code, not on message: messages are for people and may change.
The error envelope
Contract, template, file, signer and AI endpoints answer every error with this envelope:
{
"statusCode": 409,
"error": "Conflict",
"code": "DOCUMENT_NOT_SIGNED",
"message": "The contract is not completed yet (status: in_signing)",
"requestId": "0b6f3a52-6c2e-4f7a-9a51-3d4c1f0e2b7d"
}| Field | Type | Description |
|---|---|---|
statusCode | number | HTTP status |
error | string | HTTP reason phrase (or a legacy upper-case code) |
code | string | Stable error code, see the table below |
message | string or string[] | Human-readable description; an array for validation errors |
details | array | Optional. For validation errors: [{ "field": "signers.0.email", "constraints": { … } }] |
requestId | string | Same as the X-Request-Id response header |
Some errors carry extra fields: required_scope (missing scope), metric, used, limit (fair-use caps).
Validation errors
Unknown fields are rejected and every field is validated. A validation error is a 400 with code: VALIDATION_FAILED:
{
"statusCode": 400,
"error": "Bad Request",
"code": "VALIDATION_FAILED",
"message": ["property signer should not exist", "title should not be empty"],
"details": [
{ "field": "signer", "constraints": { "whitelistValidation": "property signer should not exist" } },
{ "field": "title", "constraints": { "isNotEmpty": "title should not be empty" } }
],
"requestId": "6a1d…"
}Malformed JSON answers 400 BAD_REQUEST ("Malformed JSON in request body").
X-Request-Id
Every contract-API response carries X-Request-Id. Send your own (1–128 characters of A-Z a-z 0-9 . _ : -) to correlate logs; otherwise one is generated. Quote it when you contact support.
Who answers
| Layer | When | Body |
|---|---|---|
| Gateway | Missing, invalid or under-scoped credential, rate limit | { statusCode, error, code, message } plus required_scope / limit, window. No requestId. OAuth tokens also get a WWW-Authenticate: Bearer error="…" header |
| Contract API (contracts, templates, files, signers, workflows, AI) | Everything after authentication | The envelope above |
Account API (/api/v1/auth/*, /api/v1/webhooks, /api/v1/api-keys, …) | Account and webhook endpoints | { statusCode, error, message }, with code for the authentication and limit errors below. No requestId yet |
OAuth endpoints (/oauth/*) | Token, registration and revocation | RFC 6749 format { "error": "invalid_grant", "error_description": "…" }, see OAuth |
| MCP server | Tool calls | A tool result with isError: true and the code, message and requestId of the API error, see MCP |
Error codes
Authentication and access
| Code | Status | Meaning | What to do |
|---|---|---|---|
UNAUTHORIZED | 401 | No credential sent | Send Authorization: Bearer … |
INVALID_TOKEN | 401 | Key or token unknown, revoked or expired; the key's creator left the workspace; an OAuth token not issued for this resource; a refresh token used as a credential | Create a new key, or refresh / re-authorize the OAuth token |
INSUFFICIENT_SCOPE | 403 | The credential lacks a scope. required_scope names it (it is null when no scope can open the operation) | Add the scope to the key, or ask the user to reconnect the app with it |
MACHINE_NOT_ALLOWED | 403 | The endpoint is not available to API keys or OAuth tokens | Only a signed-in user can do this |
SCOPE_REQUIRED | 403 | Same, for endpoints of the contract API that are role-restricted (users, roles, audit logs, …) | Only a signed-in user can do this |
HUMAN_ACTION_REQUIRED | 403 | Signing, declining, saved signatures and certificates are for people only | Give the signer their signing link |
FORBIDDEN | 403 | The role of the user (or of the key's creator) does not allow it | Use an account with a suitable role |
AUTH_UNAVAILABLE | 503 | The gateway could not check the credential | Retry after Retry-After (5 s) |
Limits
| Code | Status | Meaning | What to do |
|---|---|---|---|
RATE_LIMITED | 429 | Per-key / per-grant rate limit hit (limit, window in the body) | Wait Retry-After seconds |
USAGE_LIMIT_REACHED | 429 | A fair-use cap (emails, AI calls, signups, workspaces, storage). Body has metric, often used and limit | Wait Retry-After (daily caps reset at 00:00 UTC). Never a payment prompt |
AI_DAILY_LIMIT | 429 | Daily cap of AI contract prefill (resetInSeconds in the body) | Retry tomorrow |
TOO_MANY_REQUESTS | 429 | Per-IP or per-endpoint throttle. The gateway's per-IP limiter answers { "message": "API rate limit exceeded" } without a code | Wait Retry-After, then back off |
See Limits for the numbers.
Requests and idempotency
| Code | Status | Meaning |
|---|---|---|
VALIDATION_FAILED | 400 | Body or query failed validation (details lists the fields) |
BAD_REQUEST | 400 | Invalid request or a state rule, for example "Only draft contracts can be updated" |
INVALID_FILE | 400 | A files[] entry is not a PDF/DOCX, is empty, too large, not valid base64, or has neither or both of contentBase64 and url |
FILE_URL_REJECTED | 400 | files[].url is not https, resolves to a private address, redirects, or answered non-200 |
FILE_URL_TIMEOUT | 400 | files[].url did not answer within 30 s |
FILE_TOO_LARGE | 400 | The file at files[].url is larger than 50 MB |
IDEMPOTENCY_KEY_INVALID | 400 | Idempotency-Key is not 1–255 visible ASCII characters |
IDEMPOTENCY_KEY_REUSED | 422 | The key was already used with a different request |
IDEMPOTENCY_IN_PROGRESS | 409 | A request with this key is still running |
Details: Idempotency.
Contracts and documents
| Code | Status | Meaning |
|---|---|---|
NOT_FOUND | 404 | Contract, template or other resource not found in this workspace |
CONTRACT_NOT_IN_SIGNING | 409 | Signing links exist only while the contract is pending or in_signing (for example it is still processing, completed or voided) |
DOCUMENT_NOT_SIGNED | 409 | version=signed asked for before the contract is completed |
DOCUMENT_NOT_READY | 409 | The DOCX is still being converted to PDF |
ORIGINAL_NOT_AVAILABLE | 404 | The unsigned original was not kept for this contract |
FILE_NOT_FOUND | 404 | fileId does not belong to the contract |
CONFLICT | 409 | Other state conflicts |
Account
| Code | Status | Meaning |
|---|---|---|
BILLING_DISABLED | 410 | Checkout, billing portal and plan changes are off: eContract is free for now |
OPENAPI_UNAVAILABLE | 503 | GET /api/v1/openapi.json has no document on this server |
INTERNAL_ERROR | 500 | Unexpected server error (no details are exposed) |
Examples
Missing scope (gateway):
{
"statusCode": 403,
"error": "Forbidden",
"code": "INSUFFICIENT_SCOPE",
"message": "API key lacks required scope: contracts:write",
"required_scope": "contracts:write"
}A machine tries to sign:
{
"statusCode": 403,
"error": "Forbidden",
"code": "HUMAN_ACTION_REQUIRED",
"message": "This action must be performed by a person in eContract. API keys and OAuth apps cannot sign, decline or manage signatures and certificates.",
"requestId": "c4e1…"
}Daily email cap reached:
{
"statusCode": 429,
"error": "Too Many Requests",
"code": "USAGE_LIMIT_REACHED",
"message": "Daily email limit reached for this workspace. Please try again tomorrow.",
"metric": "emails",
"requestId": "91aa…"
}Retrying
| Situation | Retry? |
|---|---|
Network error, timeout, 502, 503, 504, AUTH_UNAVAILABLE | Yes, with exponential backoff (1 s, 2 s, 4 s, …) |
429 | Yes, after Retry-After |
409 IDEMPOTENCY_IN_PROGRESS | Yes, after a few seconds, with the same key |
500 | Once or twice with backoff, then report it with the requestId |
Other 4xx | No: fix the request first |
Always send an Idempotency-Key on retried POST requests so a retry cannot create a second contract.
import { randomUUID } from "node:crypto";
async function call(url, options = {}, maxRetries = 4) {
const headers = {
Authorization: `Bearer ${process.env.ECONTRACT_API_KEY}`,
...options.headers,
};
// One key for every attempt of this request
if (options.method === "POST" && !headers["Idempotency-Key"]) {
headers["Idempotency-Key"] = randomUUID();
}
for (let attempt = 0; ; attempt++) {
let res;
try {
res = await fetch(url, { ...options, headers });
} catch (err) {
if (attempt >= maxRetries) throw err;
await new Promise((r) => setTimeout(r, 2 ** attempt * 1000));
continue;
}
if (res.ok) return res;
const body = await res.json().catch(() => ({}));
const retryable =
[429, 502, 503, 504].includes(res.status) || body.code === "IDEMPOTENCY_IN_PROGRESS";
if (!retryable || attempt >= maxRetries) {
throw new Error(`${res.status} ${body.code}: ${body.message} (request ${body.requestId ?? "-"})`);
}
const wait = Number(res.headers.get("Retry-After")) || 2 ** attempt;
await new Promise((r) => setTimeout(r, wait * 1000));
}
}import os
import time
import uuid
import requests
def call(method, url, max_retries=4, **kwargs):
headers = {"Authorization": f"Bearer {os.environ['ECONTRACT_API_KEY']}", **kwargs.pop("headers", {})}
if method == "POST":
headers.setdefault("Idempotency-Key", str(uuid.uuid4())) # one key for every attempt
for attempt in range(max_retries + 1):
try:
res = requests.request(method, url, headers=headers, timeout=60, **kwargs)
except requests.RequestException:
if attempt >= max_retries:
raise
time.sleep(2 ** attempt)
continue
if res.ok:
return res
body = res.json() if res.headers.get("content-type", "").startswith("application/json") else {}
retryable = res.status_code in (429, 502, 503, 504) or body.get("code") == "IDEMPOTENCY_IN_PROGRESS"
if not retryable or attempt >= max_retries:
raise RuntimeError(f"{res.status_code} {body.get('code')}: {body.get('message')} (request {body.get('requestId')})")
time.sleep(float(res.headers.get("Retry-After") or 2 ** attempt))Idempotency
Retry eContract API writes safely with the Idempotency-Key header, covering where it works, how replays behave and how long results are kept.
Limits and pricing
eContract is free with fair-use caps. Rate limits per API key or OAuth grant (60/min, 1,000/h, 10,000/day) and file and request size limits.