eContract Developers

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"
}
FieldTypeDescription
statusCodenumberHTTP status
errorstringHTTP reason phrase (or a legacy upper-case code)
codestringStable error code, see the table below
messagestring or string[]Human-readable description; an array for validation errors
detailsarrayOptional. For validation errors: [{ "field": "signers.0.email", "constraints": { … } }]
requestIdstringSame 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

LayerWhenBody
GatewayMissing, 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 authenticationThe 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 revocationRFC 6749 format { "error": "invalid_grant", "error_description": "…" }, see OAuth
MCP serverTool callsA tool result with isError: true and the code, message and requestId of the API error, see MCP

Error codes

Authentication and access

CodeStatusMeaningWhat to do
UNAUTHORIZED401No credential sentSend Authorization: Bearer …
INVALID_TOKEN401Key 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 credentialCreate a new key, or refresh / re-authorize the OAuth token
INSUFFICIENT_SCOPE403The 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_ALLOWED403The endpoint is not available to API keys or OAuth tokensOnly a signed-in user can do this
SCOPE_REQUIRED403Same, for endpoints of the contract API that are role-restricted (users, roles, audit logs, …)Only a signed-in user can do this
HUMAN_ACTION_REQUIRED403Signing, declining, saved signatures and certificates are for people onlyGive the signer their signing link
FORBIDDEN403The role of the user (or of the key's creator) does not allow itUse an account with a suitable role
AUTH_UNAVAILABLE503The gateway could not check the credentialRetry after Retry-After (5 s)

Limits

CodeStatusMeaningWhat to do
RATE_LIMITED429Per-key / per-grant rate limit hit (limit, window in the body)Wait Retry-After seconds
USAGE_LIMIT_REACHED429A fair-use cap (emails, AI calls, signups, workspaces, storage). Body has metric, often used and limitWait Retry-After (daily caps reset at 00:00 UTC). Never a payment prompt
AI_DAILY_LIMIT429Daily cap of AI contract prefill (resetInSeconds in the body)Retry tomorrow
TOO_MANY_REQUESTS429Per-IP or per-endpoint throttle. The gateway's per-IP limiter answers { "message": "API rate limit exceeded" } without a codeWait Retry-After, then back off

See Limits for the numbers.

Requests and idempotency

CodeStatusMeaning
VALIDATION_FAILED400Body or query failed validation (details lists the fields)
BAD_REQUEST400Invalid request or a state rule, for example "Only draft contracts can be updated"
INVALID_FILE400A 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_REJECTED400files[].url is not https, resolves to a private address, redirects, or answered non-200
FILE_URL_TIMEOUT400files[].url did not answer within 30 s
FILE_TOO_LARGE400The file at files[].url is larger than 50 MB
IDEMPOTENCY_KEY_INVALID400Idempotency-Key is not 1–255 visible ASCII characters
IDEMPOTENCY_KEY_REUSED422The key was already used with a different request
IDEMPOTENCY_IN_PROGRESS409A request with this key is still running

Details: Idempotency.

Contracts and documents

CodeStatusMeaning
NOT_FOUND404Contract, template or other resource not found in this workspace
CONTRACT_NOT_IN_SIGNING409Signing links exist only while the contract is pending or in_signing (for example it is still processing, completed or voided)
DOCUMENT_NOT_SIGNED409version=signed asked for before the contract is completed
DOCUMENT_NOT_READY409The DOCX is still being converted to PDF
ORIGINAL_NOT_AVAILABLE404The unsigned original was not kept for this contract
FILE_NOT_FOUND404fileId does not belong to the contract
CONFLICT409Other state conflicts

Account

CodeStatusMeaning
BILLING_DISABLED410Checkout, billing portal and plan changes are off: eContract is free for now
OPENAPI_UNAVAILABLE503GET /api/v1/openapi.json has no document on this server
INTERNAL_ERROR500Unexpected 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

SituationRetry?
Network error, timeout, 502, 503, 504, AUTH_UNAVAILABLEYes, with exponential backoff (1 s, 2 s, 4 s, …)
429Yes, after Retry-After
409 IDEMPOTENCY_IN_PROGRESSYes, after a few seconds, with the same key
500Once or twice with backoff, then report it with the requestId
Other 4xxNo: 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))

On this page