# eContract developer documentation (full text) > All guides of https://developers.econtract.online/docs in one file. Index: https://developers.econtract.online/llms.txt. OpenAPI: https://api.econtract.online/api/v1/openapi.json. MCP server: https://api.econtract.online/mcp. Signing is always done by a person. --- # Developer Documentation Source: https://developers.econtract.online/docs > Build with the eContract e-signature REST API and MCP server. Start with the quickstart, the AI agent guide, authentication, OAuth 2.1 and webhooks. Welcome to the **eContract developer portal**. Send contracts for e-signature from your code or from an AI agent: create and send in one call, follow signing, hand out signing links, and download the signed PDF and the audit trail. eContract is free to use for now. ## Start here - [Quickstart](https://developers.econtract.online/docs/quickstart): send a PDF to two signers and download the signed result - [AI agents](https://developers.econtract.online/docs/ai-agents): the end-to-end flow for agents, and why signing stays human - [MCP server](https://developers.econtract.online/docs/mcp): connect Claude, ChatGPT, Cursor or VS Code to `https://api.econtract.online/mcp` ## Guides - [Authentication](https://developers.econtract.online/docs/authentication): API keys, scopes, the machine role cap and rate limits - [OAuth 2.1](https://developers.econtract.online/docs/oauth): let people connect your app to their workspace - [Core concepts](https://developers.econtract.online/docs/concepts): workspaces, contract lifecycle, signers and templates - [Webhooks](https://developers.econtract.online/docs/webhooks): events, signatures, retries - [Idempotency](https://developers.econtract.online/docs/idempotency): safe retries with `Idempotency-Key` - [Errors](https://developers.econtract.online/docs/errors): the error envelope and codes - [Limits and pricing](https://developers.econtract.online/docs/limits): free mode and fair-use caps - [Changelog](https://developers.econtract.online/docs/changelog) - [API reference](https://developers.econtract.online/docs/api): every endpoint ## Base URL ``` https://api.econtract.online/api/v1 ``` ## Authentication Send an API key or an OAuth access token as a bearer token: ```bash curl https://api.econtract.online/api/v1/contracts \ -H "Authorization: Bearer cl_live_your_key_here" ``` Create API keys under **Dashboard → API Keys** (workspace owners and admins). ## Machine-readable - OpenAPI: [`https://api.econtract.online/api/v1/openapi.json`](https://api.econtract.online/api/v1/openapi.json), or the [YAML used by this portal](https://developers.econtract.online/openapi/econtract-api.yaml), for Postman, Insomnia and code generators - MCP: `https://api.econtract.online/mcp` - OAuth discovery: `https://api.econtract.online/.well-known/oauth-authorization-server` - For LLMs: [`/llms.txt`](/llms.txt) and [`/llms-full.txt`](/llms-full.txt) --- # Quickstart Source: https://developers.econtract.online/docs/quickstart > Send a PDF for signature, hand out the signing links and download the signed PDF with its audit trail This walk-through sends a PDF to two signers with one API call, gets their signing links, and downloads the signed PDF and the audit trail once they have signed. Every request uses `Authorization: Bearer cl_live_…`. Prefer an AI client? Connect Claude, ChatGPT, Cursor or VS Code to the [MCP server](https://developers.econtract.online/docs/mcp) instead, no code needed. ## 1. Create an API key 1. Sign up at [econtract.online](https://econtract.online) (free) and create a workspace. 2. As the workspace owner or admin open **Dashboard → API Keys** → **Create API Key**. 3. Tick `contracts:read` and `contracts:write` (add `webhooks:write` for step 7). 4. Copy the key, it is shown only once, and keep it in an environment variable: ```bash export ECONTRACT_API_KEY=cl_live_your_key_here ``` ## 2. Send the PDF `POST /api/v1/contracts/send` takes the document as base64 JSON, creates the contract and queues it. `delivery: "link"` means eContract sends no invitation emails: you will hand out the links yourself in step 4. Leave `delivery` out (or use `"email"`) to let eContract email each signer instead. **cURL** ```bash # Build the JSON body (jq 1.6+; --rawfile avoids command-line length limits) base64 < agreement.pdf | tr -d '\n' > agreement.b64 jq -n --rawfile b64 agreement.b64 '{ title: "Service Agreement", signingOrderType: "parallel", signers: [ {signerType: "external", name: "Alice Johnson", email: "alice@example.com", signOrder: 1}, {signerType: "external", name: "Bob Smith", email: "bob@example.com", signOrder: 2} ], files: [{filename: "agreement.pdf", contentType: "application/pdf", contentBase64: $b64}], delivery: "link", expiresInDays: 30 }' > send.json curl -X POST https://api.econtract.online/api/v1/contracts/send \ -H "Authorization: Bearer $ECONTRACT_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d @send.json # keep the "id" of the response for the next steps export CONTRACT_ID=8c1e2f3a-4b5c-4d6e-8f90-1a2b3c4d5e6f ``` **Node.js** ```js import { readFile } from "node:fs/promises"; import { randomUUID } from "node:crypto"; const API = "https://api.econtract.online/api/v1"; const headers = { Authorization: `Bearer ${process.env.ECONTRACT_API_KEY}`, "Content-Type": "application/json", }; const pdf = await readFile("agreement.pdf"); const res = await fetch(`${API}/contracts/send`, { method: "POST", headers: { ...headers, "Idempotency-Key": randomUUID() }, body: JSON.stringify({ title: "Service Agreement", signingOrderType: "parallel", signers: [ { signerType: "external", name: "Alice Johnson", email: "alice@example.com", signOrder: 1 }, { signerType: "external", name: "Bob Smith", email: "bob@example.com", signOrder: 2 }, ], files: [{ filename: "agreement.pdf", contentType: "application/pdf", contentBase64: pdf.toString("base64") }], delivery: "link", expiresInDays: 30, }), }); if (res.status !== 202) throw new Error(JSON.stringify(await res.json())); const contract = await res.json(); console.log(contract.id, contract.code, contract.processingStatus); // ... "processing" ``` **Python** ```python import base64 import os import uuid import requests API = "https://api.econtract.online/api/v1" HEADERS = {"Authorization": f"Bearer {os.environ['ECONTRACT_API_KEY']}"} with open("agreement.pdf", "rb") as f: pdf_b64 = base64.b64encode(f.read()).decode() res = requests.post( f"{API}/contracts/send", headers={**HEADERS, "Idempotency-Key": str(uuid.uuid4())}, json={ "title": "Service Agreement", "signingOrderType": "parallel", "signers": [ {"signerType": "external", "name": "Alice Johnson", "email": "alice@example.com", "signOrder": 1}, {"signerType": "external", "name": "Bob Smith", "email": "bob@example.com", "signOrder": 2}, ], "files": [{"filename": "agreement.pdf", "contentType": "application/pdf", "contentBase64": pdf_b64}], "delivery": "link", "expiresInDays": 30, }, timeout=120, ) assert res.status_code == 202, res.json() contract = res.json() print(contract["id"], contract["code"], contract["processingStatus"]) # ... "processing" ``` **Response: `202 Accepted`** ```json { "id": "8c1e2f3a-4b5c-4d6e-8f90-1a2b3c4d5e6f", "code": "CTR-20261010-K7Q2MX", "title": "Service Agreement", "status": "draft", "processingStatus": "processing", "signingOrderType": "parallel", "createdBy": "…", "createdAt": "2026-10-10T09:00:00.000Z", "updatedAt": "2026-10-10T09:00:00.000Z" } ``` Other ways to give the document: `files: [{ "url": "https://…/agreement.pdf" }]`, a template (`templateId` + `templateValues`), or a multipart upload. See [AI agents](https://developers.econtract.online/docs/ai-agents#1-send) for all options. ## 3. Wait until it is ready Processing (storing the file, converting DOCX, adding signers, starting signing) takes a few seconds. Poll until `processingStatus` is `ready`: **cURL** ```bash curl https://api.econtract.online/api/v1/contracts/$CONTRACT_ID \ -H "Authorization: Bearer $ECONTRACT_API_KEY" # repeat until "processingStatus": "ready" (status is then "in_signing") ``` **Node.js** ```js async function waitUntilReady(id) { for (;;) { const c = await fetch(`${API}/contracts/${id}`, { headers }).then((r) => r.json()); if (c.processingStatus === "ready") return c; if (c.processingStatus === "failed") throw new Error(c.processingError); await new Promise((r) => setTimeout(r, 3000)); } } await waitUntilReady(contract.id); ``` **Python** ```python import time def wait_until_ready(contract_id): while True: c = requests.get(f"{API}/contracts/{contract_id}", headers=HEADERS).json() if c["processingStatus"] == "ready": return c if c["processingStatus"] == "failed": raise RuntimeError(c.get("processingError")) time.sleep(3) wait_until_ready(contract["id"]) ``` ## 4. Get the signing links **cURL** ```bash curl -X POST https://api.econtract.online/api/v1/contracts/$CONTRACT_ID/signing-links \ -H "Authorization: Bearer $ECONTRACT_API_KEY" ``` **Node.js** ```js const { data: links } = await fetch(`${API}/contracts/${contract.id}/signing-links`, { method: "POST", headers, }).then((r) => r.json()); for (const link of links) { if (link.signingUrl) console.log(`${link.name} <${link.email}>: ${link.signingUrl}`); } ``` **Python** ```python links = requests.post(f"{API}/contracts/{contract['id']}/signing-links", headers=HEADERS).json()["data"] for link in links: if link["signingUrl"]: print(f"{link['name']} <{link['email']}>: {link['signingUrl']}") ``` ```json { "data": [ { "signerId": "3a7f…", "name": "Alice Johnson", "email": "alice@example.com", "signerType": "external", "status": "pending", "signingUrl": "https://econtract.online/sign/7Kf2…", "expiresAt": "2026-11-09T09:00:05.000Z" }, { "signerId": "9b2c…", "name": "Bob Smith", "email": "bob@example.com", "signerType": "external", "status": "pending", "signingUrl": "https://econtract.online/sign/Qm9x…", "expiresAt": "2026-11-09T09:00:05.000Z" } ] } ``` Send each person **their own** link (chat, your app, your email). No email is sent by this call. ## 5. The signers sign Each signer opens their link, confirms their identity with a code eContract emails to them, reviews the document and signs. **Signing is always done by the person**: API keys and OAuth tokens cannot sign (`403 HUMAN_ACTION_REQUIRED`). Poll `GET /api/v1/contracts/{id}` (every few minutes is enough) until `status` is `completed`, or use the webhook from step 7. Each signer in `signers` shows `pending`, `viewed`, `signed` or `rejected`. ## 6. Download the signed PDF and the audit trail **cURL** ```bash curl -o signed.pdf -D - \ "https://api.econtract.online/api/v1/contracts/$CONTRACT_ID/document?version=signed" \ -H "Authorization: Bearer $ECONTRACT_API_KEY" # headers include X-Document-Version: signed and X-Document-Sha256 curl https://api.econtract.online/api/v1/contracts/$CONTRACT_ID/audit-trail \ -H "Authorization: Bearer $ECONTRACT_API_KEY" ``` **Node.js** ```js import { writeFile } from "node:fs/promises"; const doc = await fetch(`${API}/contracts/${contract.id}/document?version=signed`, { headers }); if (!doc.ok) throw new Error((await doc.json()).code); // DOCUMENT_NOT_SIGNED until completed await writeFile("signed.pdf", Buffer.from(await doc.arrayBuffer())); const trail = await fetch(`${API}/contracts/${contract.id}/audit-trail`, { headers }).then((r) => r.json()); console.log(trail.chain.valid, trail.documentSha256 === doc.headers.get("X-Document-Sha256")); ``` **Python** ```python doc = requests.get(f"{API}/contracts/{contract['id']}/document", params={"version": "signed"}, headers=HEADERS) doc.raise_for_status() # 409 DOCUMENT_NOT_SIGNED until completed with open("signed.pdf", "wb") as f: f.write(doc.content) trail = requests.get(f"{API}/contracts/{contract['id']}/audit-trail", headers=HEADERS).json() print(trail["chain"]["valid"], trail["documentSha256"] == doc.headers["X-Document-Sha256"]) ``` The audit trail lists the signers, every event (who, when, IP address, user agent), the result of the hash-chain check and the SHA-256 of the signed PDF. ## 7. Get notified with a webhook Instead of polling, register an endpoint once (key with `webhooks:write`, created by a workspace owner or admin): ```bash curl -X POST https://api.econtract.online/api/v1/webhooks \ -H "Authorization: Bearer $ECONTRACT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://your-app.example.com/webhooks/econtract", "events": ["contract.processing.completed", "contract.processing.failed", "signer.viewed", "signer.declined", "contract.completed"] }' ``` Store the `secret` from the response and verify `X-Econtract-Signature` on every delivery. On `contract.completed`, run step 6. Details: [Webhooks](https://developers.econtract.online/docs/webhooks). ## Next steps - [AI agents](https://developers.econtract.online/docs/ai-agents): the full flow, delivery modes, documents and audit trail fields - [Authentication](https://developers.econtract.online/docs/authentication): scopes, roles and rate limits - [Idempotency](https://developers.econtract.online/docs/idempotency) and [Errors](https://developers.econtract.online/docs/errors): safe retries - [Limits](https://developers.econtract.online/docs/limits): free mode and fair-use caps - [API reference](https://developers.econtract.online/docs/api): every endpoint --- # AI agents Source: https://developers.econtract.online/docs/ai-agents > How an AI agent sends contracts for e-signature with eContract, end to end An AI agent can do the whole contract job in eContract: prepare the document, send it, follow signing, give each signer their link, and collect the signed PDF and the audit trail. The one thing it can never do is sign. ## Two ways in | | [MCP server](https://developers.econtract.online/docs/mcp) | REST API | |---|---|---| | Best for | Chat assistants and IDE agents (Claude, ChatGPT, Cursor, VS Code) | Your own agent runtime, backends, workflows | | Endpoint | `https://api.econtract.online/mcp` | `https://api.econtract.online/api/v1/…` | | What you write | Nothing: the client lists the tools | HTTP calls ([quickstart](https://developers.econtract.online/docs/quickstart)) | | Auth | OAuth (the client handles it) or an API key header | API key or OAuth access token | | Documents | Up to 10 MB through the connector | Up to 50 MB per file; a request body is limited to 50 MB, so use `files[].url` for large files ([limits](https://developers.econtract.online/docs/limits)) | | Events | Poll `get_contract` | Poll or [webhooks](https://developers.econtract.online/docs/webhooks) | Both paths reach the same API with the same rules: scopes, role cap, rate limits, idempotency and the human-signs rule. ## Signing is human API keys and OAuth tokens are never allowed to sign or decline on anyone's behalf, manage saved signatures or signing certificates. Those calls answer `403 HUMAN_ACTION_REQUIRED`. Instead: - **External signers** get a personal link (`https://econtract.online/sign/`). They open it, confirm their identity with a one-time code sent to their email, and sign or decline themselves. - **Internal signers** (members of the workspace) get a link to the contract in the dashboard and sign there while signed in. - With **sequential** signing each signer can sign only after the previous ones. Rules for agent builders: 1. **Confirm with the user before** sending, voiding or reminding: these contact real people. 2. **Give each signer only their own link**, privately. A link lets its holder act as that signer once they pass the email check. 3. **Never pretend a contract is signed** until `status` is `completed`. ## Choose a credential | | API key | OAuth | |---|---|---| | Acts as | The admin who created the key | The person who connected the app | | Set up | Dashboard → API Keys, paste the key into your config | The user clicks Connect and approves in the browser | | Workspace | The key's workspace | The workspace the user picked on the consent screen | | Scopes | Ticked when the key is created | Requested by the app, can be reduced by the user | | Lifetime | Until revoked or its expiry date | Access token 1 h, refresh token 30 days (rotating), grant until revoked | | Revoke | Dashboard → API Keys | The user: Dashboard → Connected apps | | Pick it when | One team runs the agent for its own workspace, headless | Many users connect their own workspaces, or you ship a client app | Minimal scopes for the flow below: `contracts:read contracts:write`. Add `templates:read` for templates, `webhooks:write` to register webhooks, `ai:use` for AI analysis. ## The flow ``` send ──► processing ──► ready (in_signing) ──► signing links ──► people sign ──► completed │ │ │ poll GET /contracts/{id} POST /signing-links signed PDF + audit trail or webhook contract.processing.* (or email invitations) GET /document, /audit-trail ``` ### 1. Send `POST /api/v1/contracts/send` creates the contract, stores the document and queues it for processing, all in one call. Give exactly one document source: | Source | Field | |---|---| | Inline file | `files: [{ "filename": "nda.pdf", "contentBase64": "JVBERi0…" }]` (PDF or DOCX, optional `contentType`) | | File on the web | `files: [{ "url": "https://example.com/nda.pdf" }]` (https, public host, no redirects, 30 s, 50 MB) | | Template | `templateId` + `templateValues` | | Uploaded earlier | `fileKey` (from `POST /api/v1/files/upload`) | | Multipart | `multipart/form-data` with `files` parts and a `metadata` JSON string | Up to 10 files; DOCX files are converted to PDF. Choose how signers hear about it with `delivery`: | `delivery` | Effect | |---|---| | `email` (default) | eContract emails each signer an invitation (in order for sequential signing) | | `link` | No invitation emails at all, also not for the following signers in sequential order: fetch the links and hand each one out yourself | | `both` | Invitations are emailed and you can fetch links too | ```json { "title": "Mutual NDA - Acme / Globex", "signingOrderType": "parallel", "signers": [ { "signerType": "external", "name": "Alice Johnson", "email": "alice@acme.example", "signOrder": 1 }, { "signerType": "external", "name": "Bob Smith", "email": "bob@globex.example", "signOrder": 2 } ], "files": [{ "filename": "nda.pdf", "contentBase64": "JVBERi0xLjcK…" }], "delivery": "link", "expiresInDays": 30 } ``` The answer is `202 Accepted` with the contract in `processingStatus: "processing"`. Send an [`Idempotency-Key`](https://developers.econtract.online/docs/idempotency) so a retry cannot send the contract twice. ### 2. Wait for processing Poll `GET /api/v1/contracts/{id}` every 2–5 seconds until `processingStatus` is `ready` (the contract is then `in_signing`), or subscribe to `contract.processing.completed` and `contract.processing.failed`. On failure `processingError` says why and the contract stays visible with `processingStatus: "failed"`; fix the input and send again. ### 3. Get signing links `POST /api/v1/contracts/{id}/signing-links` (`contracts:write`) returns a link for every signer. It sends no email. ```json { "data": [ { "signerId": "3a7f…", "name": "Alice Johnson", "email": "alice@acme.example", "signerType": "external", "status": "pending", "signingUrl": "https://econtract.online/sign/7Kf2…", "expiresAt": "2026-11-09T09:00:00.000Z" }, { "signerId": "9b2c…", "name": "Bob Smith", "email": "bob@globex.example", "signerType": "external", "status": "signed", "signingUrl": null, "expiresAt": null } ] } ``` - Every call mints a **fresh** link for each signer who still has to act; older links keep working. - Signers who already signed or declined get `signingUrl: null`. - Internal signers get the dashboard link to the contract and `expiresAt: null`. - External links expire with the contract (30 days when the contract has no later expiry). - Only while the contract is `pending` or `in_signing`; otherwise `409 CONTRACT_NOT_IN_SIGNING` (also while it is still processing). To email an invitation later instead, call `POST /api/v1/contracts/{id}/resend-signing-request` (optionally `{ "signerId": "…" }`). ### 4. Follow signing `GET /api/v1/contracts/{id}` lists each signer with `status` (`pending`, `notified`, `viewed`, `signed`, `rejected`, `expired`) and timestamps. Or listen for `signer.viewed`, `signer.completed` (one per signature), `signer.declined` and `contract.completed`. A decline voids the contract. To cancel it yourself: `POST /api/v1/contracts/{id}/void` with an optional `reason`. ### 5. Collect the signed PDF and the audit trail When `status` is `completed`: ```bash curl -o signed.pdf -D headers.txt \ "https://api.econtract.online/api/v1/contracts/{id}/document?version=signed" \ -H "Authorization: Bearer cl_live_your_key_here" ``` | Query | Returns | |---|---| | (none) | The signed PDF when completed, otherwise the current file | | `version=signed` | The signed PDF; `409 DOCUMENT_NOT_SIGNED` until completed | | `version=original` | The unsigned upload (the PDF rendition of a DOCX); `404 ORIGINAL_NOT_AVAILABLE` if it was not kept | | `fileId=…` | A specific file of a multi-file contract (default: the first) | Response headers: `Content-Type: application/pdf`, `Content-Disposition: attachment; filename="…-signed.pdf"`, `X-Document-Version` (`signed`, `original` or `current`) and `X-Document-Sha256`. `GET /api/v1/contracts/{id}/audit-trail` returns the evidence in JSON: ```json { "contract": { "id": "8c1e…", "code": "CTR-…", "title": "Mutual NDA - Acme / Globex", "status": "completed", "createdAt": "…", "completedAt": "…" }, "signers": [ { "id": "3a7f…", "name": "Alice Johnson", "email": "alice@acme.example", "signerType": "external", "status": "signed", "signOrder": 1, "notifiedAt": null, "viewedAt": "…", "signedAt": "…", "rejectedAt": null, "rejectionReason": null, "ipAddress": "…" } ], "events": [ { "type": "created", "source": "activity", "actorName": "…", "actorEmail": "…", "at": "…", "ip": "…", "userAgent": "…", "details": {} }, { "type": "signer_viewed", "source": "workflow", "actorName": "Alice Johnson", "actorEmail": "alice@acme.example", "at": "…", "ip": null, "userAgent": null, "details": { "signerId": "3a7f…" } } ], "chain": { "valid": true, "checkedAt": "…", "totalEvents": 7, "chainedEvents": 7, "verifiedEvents": 7, "unchainedEvents": 0 }, "documentSha256": "4f1c…", "documentVersion": "signed" } ``` - `events` merges the hash-chained contract activity log (`source: "activity"`) with signing workflow events (`source: "workflow"`), oldest first. - `chain.valid` says whether the activity hash chain verifies (`brokenAt` points to the first broken link otherwise). - `documentSha256` is the hash of the file `GET /document` returns by default; compare it with `X-Document-Sha256` of your download. ## With MCP The same flow with tools: `send_contract` → `get_contract` (until `ready`) → `get_signing_links` → `get_contract` / `send_reminder` → `download_signed_document` + `get_audit_trail`. Connect a client in a minute: [MCP server](https://developers.econtract.online/docs/mcp). ## Checklist - [ ] Credential with the smallest scope set (`contracts:read contracts:write` to start) - [ ] User confirmation before `send`, `void` and reminders - [ ] `Idempotency-Key` on every write, the same key on retries - [ ] Links handed to each signer privately, never signed by the agent - [ ] Webhook signature verification and de-duplication by event `id` - [ ] Backoff on `429` (`Retry-After`) and on `5xx` --- # Authentication Source: https://developers.econtract.online/docs/authentication > API keys, OAuth 2.1 tokens, scopes, the machine role cap and rate limits Every request to `https://api.econtract.online` carries one credential in the `Authorization` header: ``` Authorization: Bearer ``` | Credential | Looks like | Use it for | |---|---|---| | **API key** | `cl_live_` + 64 hex characters | Your own backend, scripts, headless agents, CI | | **OAuth access token** | `eco_at_` + 64 hex characters | Apps and AI clients that act for *other* people (Claude, ChatGPT, Cursor, your SaaS). See [OAuth 2.1](https://developers.econtract.online/docs/oauth) | | User session (JWT) | `eyJ…` | The eContract web app itself. Not meant for integrations | API keys and OAuth tokens are **machine credentials**. They work in exactly one workspace, carry [scopes](#scopes), act with a [capped role](#machine-role), and can never sign or decline a contract: signing is always done by a person. ## API keys ### Create a key 1. Sign in as a workspace **owner** or **admin**. 2. Open **Dashboard → API Keys** (`/dashboard/api-keys`) and click **Create API Key**. 3. Name the key (for example "Production backend"), tick the scopes it needs and optionally set an expiry date. 4. Copy the key. It is shown **once**; eContract stores only its SHA-256 hash. A workspace can have up to 10 active keys. Keys issued by the production service start with `cl_live_`. Keys created on a non-production installation start with `cl_test_`; there is no sandbox mode yet, so a test key behaves exactly like a live key. ### Use a key **cURL** ```bash curl https://api.econtract.online/api/v1/contracts \ -H "Authorization: Bearer cl_live_your_key_here" ``` **Node.js** ```js const response = await fetch("https://api.econtract.online/api/v1/contracts", { headers: { Authorization: `Bearer ${process.env.ECONTRACT_API_KEY}` }, }); const { data } = await response.json(); ``` **Python** ```python import os import requests response = requests.get( "https://api.econtract.online/api/v1/contracts", headers={"Authorization": f"Bearer {os.environ['ECONTRACT_API_KEY']}"}, ) contracts = response.json()["data"] ``` `GET /api/v1/auth/me` works with any valid key and is a quick way to check one. > **Keep keys on the server.** Never put a key in browser or mobile code. Store it in an environment variable or a secret manager. ### Who a key acts as A key acts as **the person who created it**: - Contracts it creates show that person as the creator, and the gateway passes their email to the services. - Its role is that person's *current* workspace role, capped (see [Machine role](#machine-role)). - The key stops working at once when it is revoked or expires, when the workspace is no longer active, or when its creator leaves the workspace. The gateway caches the result of a key check for up to 30 seconds, so a revoked key can still be accepted for up to 30 seconds. ### Manage keys Key management is done by a signed-in workspace owner or admin (the dashboard does this for you). API keys and OAuth tokens cannot manage keys: they get `403 MACHINE_NOT_ALLOWED`. | Action | Endpoint | |---|---| | Create | `POST /api/v1/workspaces/{workspaceId}/api-keys` with `{ name, scopes, expires_at? }` | | List | `GET /api/v1/workspaces/{workspaceId}/api-keys` | | Get | `GET /api/v1/api-keys/{id}` | | Update name, scopes or expiry | `PATCH /api/v1/api-keys/{id}` | | Revoke | `POST /api/v1/api-keys/{id}/revoke` | | Rotate | `POST /api/v1/api-keys/{id}/rotate` | | Usage | `GET /api/v1/api-keys/{id}/usage` | ### Rotation `POST /api/v1/api-keys/{id}/rotate` **revokes the old key immediately** (there is no grace period) and returns a new key with the same name, scopes and expiry. The new key is created by, and from then on acts as, the admin who rotated it. To rotate without downtime, create a second key with the same scopes, deploy it, then revoke the old one. ## OAuth access tokens Apps that act for eContract users get tokens through the OAuth 2.1 authorization code flow with PKCE: the user signs in, picks a workspace and approves the scopes. Tokens are bound to that user, workspace and app, last one hour and can be refreshed. Details: [OAuth 2.1](https://developers.econtract.online/docs/oauth). An access token is accepted by the REST API only if it was issued for the API resource `https://api.econtract.online`. A token issued only for the MCP server (`https://api.econtract.online/mcp`) is rejected with `401 INVALID_TOKEN`. Refresh tokens (`eco_rt_…`) never work as API credentials. ## Scopes Scopes limit what a machine credential may do. Ask for the smallest set you need. | Scope | Allows | |---|---| | `contracts:read` | View contracts, their status, signed documents and audit trails | | `contracts:write` | Create, send, update and void contracts, and issue signing links | | `contracts:delete` | Delete contracts | | `templates:read` | View contract templates | | `templates:write` | Create and update contract templates | | `templates:delete` | Delete contract templates | | `signing:read` | View signing sessions and signer data | | `signing:write` | Manage signing sessions (never signs on your behalf) | | `workflow:read` | View workflow status | | `workflow:write` | Run workflow actions | | `files:read` | Download files | | `files:write` | Upload and update files | | `files:delete` | Delete files | | `webhooks:read` | View webhooks and their delivery logs | | `webhooks:write` | Create, update, test and delete webhooks | | `ai:use` | Use AI features such as contract analysis | | `offline_access` | OAuth only: get a refresh token | - **Legacy names** on older keys keep working: `signing:create` means `signing:write`, `workflow:execute` means `workflow:write`. Both names are still accepted when you create or edit a key. - The legacy wildcard `*` can no longer be created. Old keys that hold it still pass every scope check above (not `offline_access`). - When an OAuth client asks for no scope it gets `contracts:read contracts:write templates:read files:read files:write ai:use`. ### Which scope a path needs The gateway checks the scope from the path and method, first match wins. `GET`/`HEAD` need `:read`. `DELETE` of a resource itself (`/contracts/{id}`, `/contract-templates/{id}`, `/files/{id}`) needs `:delete`; removing a sub-resource (`/contracts/{id}/signers/{signerId}`, `/contracts/{id}/files/{fileId}`) and `DELETE` on families without a `:delete` scope need `:write`. Every other method needs `:write`. | Path prefix | Scope family | |---|---| | `/api/v1/auth/me` | none (any valid credential; `GET` only) | | `/api/v1/ai` | `ai:use` for every method | | `/api/v1/webhooks` | `webhooks:read` / `webhooks:write` | | `/api/v1/contract-templates/{id}/generate` | `contracts:write` (creates a contract; the service also checks `templates:read`) | | `/api/v1/contract-templates/{id}/preview` | `templates:read` for every method | | `/api/v1/contract-templates`, `/api/v1/templates` | `templates:*` | | `/api/v1/contracts` (incl. `/signing-links`, `/document`, `/audit-trail`) | `contracts:*` | | `/api/v1/external-signers`, `/api/v1/signing-tokens`, `/api/v1/signing`, `/api/v1/sign`, `/api/v1/otp`, `/api/v1/saved-signatures` | `signing:read` / `signing:write` | | `/api/v1/workflows`, `/api/v1/workflow` | `workflow:read` / `workflow:write` | | `/api/v1/files` | `files:*` | | anything else | not available to machine credentials (`403 MACHINE_NOT_ALLOWED`) | The service then checks the role permission as well: a machine credential needs **both** the role permission and the scope. A missing scope answers `403 INSUFFICIENT_SCOPE` with a `required_scope` field; see [Errors](https://developers.econtract.online/docs/errors). ## Machine role A machine credential acts with the workspace role of its user (the key creator, or the person who approved the OAuth app), capped at `contract_manager`: | User's workspace role | Role of the key / token | |---|---| | `owner`, `admin`, `manager`, `contract_manager` | `contract_manager` | | `member` | `member` | | anything else | `viewer` | The role is read again on every check, so demoting or removing the user takes effect for their keys and tokens too (within the 30-second gateway cache). Webhook management is the one exception to the cap: `/api/v1/webhooks` needs the `webhooks:*` scope **and** a user who is currently an owner or admin of the workspace. ## What machines cannot do API keys and OAuth tokens never act as a person in the moments that need one: - **Sign or decline** a contract (`POST /api/v1/contracts/{id}/sign`, `/decline`), manage saved signatures or signing certificates: `403 HUMAN_ACTION_REQUIRED`. Hand the signer their link instead (see [AI agents](https://developers.econtract.online/docs/ai-agents)). - **Administer people**: users, roles, invitations, notifications, organization settings and audit logs answer `403 SCOPE_REQUIRED` or `403 MACHINE_NOT_ALLOWED`. - **Manage credentials and the account**: API keys, OAuth consent, connected apps, workspaces and billing are for signed-in users only (`403 MACHINE_NOT_ALLOWED`). ## Rate limits Machine credentials are limited per principal: per API key, or per OAuth grant (one user + workspace + app, shared by all of its tokens). | Window | Limit | |---|---| | Minute | 60 requests | | Hour | 1,000 requests | | Day | 10,000 requests | The windows are fixed (they start at the full minute, hour and UTC day). Every response to a machine credential carries: | Header | Meaning | |---|---| | `X-RateLimit-Limit` | Requests allowed in the window (normally the minute window) | | `X-RateLimit-Remaining` | Requests left in that window | | `X-RateLimit-Reset` | **Seconds** until that window ends (not a timestamp) | When a limit is exceeded the gateway answers `429` with `Retry-After` (seconds) and the headers describe the exceeded window: ```json { "statusCode": 429, "error": "Too Many Requests", "code": "RATE_LIMITED", "message": "Rate limit exceeded", "limit": 60, "window": 60 } ``` Rejected requests (for example `403` for a missing scope) count too. In front of that, the gateway limits every client IP address to 120 requests per minute and 5,000 per hour across all credentials (3,000 per minute for `/mcp` and `/oauth/*`, whose users often share the IP of a hosted AI client). That limiter answers `429` with `Retry-After` and its own `RateLimit-*` headers. The MCP server has its own limit of 60 requests per minute per principal and does not count against the REST limits above. The OAuth endpoints, fair-use caps (AI calls, emails) and per-endpoint limits are listed in [Limits](https://developers.econtract.online/docs/limits). ### Good practice - Use [webhooks](https://developers.econtract.online/docs/webhooks) instead of tight polling loops. If you poll, every 2–5 seconds while a contract is processing and every few minutes while it waits for signers is plenty. - On `429` wait for `Retry-After`, then retry with exponential backoff. - Send an [`Idempotency-Key`](https://developers.econtract.online/docs/idempotency) on retried writes so a retry never creates a second contract. --- # OAuth 2.1 Source: https://developers.econtract.online/docs/oauth > Let people connect your app or AI client to their eContract workspace with OAuth 2.1, PKCE, discovery and dynamic client registration. Use OAuth when your software acts for **other people**: an AI client (Claude, ChatGPT, Cursor, VS Code), a SaaS integration, a desktop tool. Each user signs in to eContract, picks a workspace, approves the scopes, and your app gets tokens bound to that user, workspace and app. For your own backend an [API key](https://developers.econtract.online/docs/authentication#api-keys) is simpler. eContract implements the authorization code flow of OAuth 2.1 with PKCE, as the [MCP authorization spec](https://modelcontextprotocol.io) expects: RFC 8414 metadata, RFC 9728 protected resource metadata, RFC 8707 resource indicators, Client ID Metadata Documents, RFC 7591 dynamic registration, RFC 7009 revocation and RFC 9207 `iss` in authorization responses. ## Discovery | Document | URL | |---|---| | Authorization server metadata (RFC 8414) | `https://api.econtract.online/.well-known/oauth-authorization-server` | | Same document at the OpenID path | `https://api.econtract.online/.well-known/openid-configuration` | | Protected resource metadata of the MCP server (RFC 9728) | `https://api.econtract.online/.well-known/oauth-protected-resource/mcp` (also at `/.well-known/oauth-protected-resource`) | The authorization server metadata: ```json { "issuer": "https://api.econtract.online", "authorization_endpoint": "https://api.econtract.online/oauth/authorize", "token_endpoint": "https://api.econtract.online/oauth/token", "registration_endpoint": "https://api.econtract.online/oauth/register", "revocation_endpoint": "https://api.econtract.online/oauth/revoke", "scopes_supported": ["contracts:read", "contracts:write", "…", "ai:use", "offline_access"], "response_types_supported": ["code"], "response_modes_supported": ["query"], "grant_types_supported": ["authorization_code", "refresh_token"], "code_challenge_methods_supported": ["S256"], "token_endpoint_auth_methods_supported": ["none", "client_secret_post", "client_secret_basic"], "revocation_endpoint_auth_methods_supported": ["none", "client_secret_post", "client_secret_basic"], "client_id_metadata_document_supported": true, "authorization_response_iss_parameter_supported": true, "subject_types_supported": ["public"], "service_documentation": "https://developers.econtract.online" } ``` eContract issues no ID tokens (there is no `jwks_uri`); tokens are opaque strings. ## Register your client Pick one of two ways. Either way the client is **public** by default (no secret; PKCE protects the code). ### Client ID Metadata Document (recommended) Host a JSON document at an https URL you control and use **that URL as your `client_id`**. eContract fetches it on first use. Nothing to register, and the consent screen shows your domain as verified. ```json { "client_id": "https://app.example.com/oauth/client-metadata.json", "client_name": "Example Contracts Assistant", "client_uri": "https://app.example.com", "logo_uri": "https://app.example.com/logo.png", "redirect_uris": ["https://app.example.com/oauth/callback", "http://127.0.0.1/callback"], "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "token_endpoint_auth_method": "none", "scope": "contracts:read contracts:write templates:read offline_access" } ``` Rules: - The URL must be https with a path, at most 512 characters, no fragment, no credentials, no `.`/`..` segments. - The document's `client_id` must equal its URL; `client_name` and `redirect_uris` are required. - Only public clients: `token_endpoint_auth_method` must be `none` (or absent) and the document must not contain a `client_secret`. - eContract fetches it over https from a public address only, without redirects, within 5 seconds and up to 64 KB, and caches it as your `Cache-Control` / `Expires` headers allow (1 hour by default, at most 24 hours; `no-store` re-fetches every time). ### Dynamic client registration (RFC 7591) For clients that cannot host a document: ```bash curl -X POST https://api.econtract.online/oauth/register \ -H "Content-Type: application/json" \ -d '{ "client_name": "Example Contracts Assistant", "redirect_uris": ["http://127.0.0.1:33418/callback"], "grant_types": ["authorization_code", "refresh_token"], "token_endpoint_auth_method": "none" }' ``` ```json { "client_id": "eco_client_3f9a…", "client_id_issued_at": 1791622800, "client_name": "Example Contracts Assistant", "redirect_uris": ["http://127.0.0.1:33418/callback"], "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "token_endpoint_auth_method": "none" } ``` - `token_endpoint_auth_method`: `none` (default), `client_secret_post` or `client_secret_basic`. Confidential clients also get `client_secret` (`eco_cs_…`, shown once, `client_secret_expires_at: 0`). - `grant_types` defaults to `["authorization_code"]`. Add `refresh_token` to always receive refresh tokens (see [Tokens](#tokens)). - Optional metadata that is echoed back: `client_uri`, `logo_uri` (https only), `scope`, `application_type`, `software_id`, `software_version`, `tos_uri`, `policy_uri`, `contacts`. - At most 20 redirect URIs; 300 registrations per hour per IP address. - Dynamically registered clients are shown as **unverified** on the consent screen. ### Redirect URI rules | Redirect URI | Accepted | |---|---| | `https://…` on any host | Yes | | `http://localhost`, `http://127.0.0.1`, `http://[::1]`, any port | Yes (loopback, for native apps and CLIs) | | `http://` on any other host | No: the registration is refused | | Custom schemes such as `cursor://…` or `vscode://…` | **Dropped**: the rest of the registration is kept and the accepted list is echoed back; refused only if no URI is left | | With a `#fragment` or `user:password@` | No | At `/oauth/authorize` the `redirect_uri` must match a registered URI **exactly**, except loopback URIs, where the port is ignored (register `http://127.0.0.1/callback`, then use any free port at run time). A request whose client is unknown or whose redirect URI does not match is never redirected; the user sees an error page instead. ## Authorization code flow with PKCE ### 1. Send the user to the authorization endpoint Create a random `code_verifier` (43–128 characters of `A-Z a-z 0-9 - . _ ~`) and its S256 `code_challenge`, plus a random `state`, then open: ``` https://api.econtract.online/oauth/authorize ?response_type=code &client_id=https%3A%2F%2Fapp.example.com%2Foauth%2Fclient-metadata.json &redirect_uri=https%3A%2F%2Fapp.example.com%2Foauth%2Fcallback &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM &code_challenge_method=S256 &state=af0ifjsldkj &scope=contracts%3Aread%20contracts%3Awrite%20offline_access &resource=https%3A%2F%2Fapi.econtract.online ``` | Parameter | | |---|---| | `response_type` | `code` (the only type) | | `client_id` | Your metadata document URL or `eco_client_…` | | `redirect_uri` | Optional only when the client has exactly one non-loopback redirect URI | | `code_challenge`, `code_challenge_method` | **Required**, `S256` only | | `state` | Recommended, at most 2048 characters | | `scope` | Space-separated. Unknown names are ignored; without any known scope you get the default set `contracts:read contracts:write templates:read files:read files:write ai:use` | | `resource` | Which API the token is for, see [Audience](#audience-resource). May be repeated | | `response_mode` | Only `query` | ### 2. The user signs in and consents eContract shows its consent page at `https://econtract.online/oauth/consent` (after sign-in if needed) with your app's name and logo, the redirect host, a verified-domain badge for metadata-document clients or an "unverified" note for registered ones, a warning when every redirect URI is on localhost, and the scopes in plain language. The user: - picks the **workspace** the app may use (they must be an active member), - may untick scopes (they can only remove, never add), - approves or denies. The pending request is valid for 10 minutes. ### 3. Receive the code eContract redirects to your `redirect_uri`: ``` https://app.example.com/oauth/callback?code=eco_ac_9c1d…&state=af0ifjsldkj&iss=https%3A%2F%2Fapi.econtract.online ``` Check that `state` is yours and that **`iss` equals `https://api.econtract.online`** (RFC 9207; every authorization response, success or error, carries it). A denial comes back as `error=access_denied`. The code is valid for **5 minutes** and works **once**. ### 4. Exchange the code for tokens `POST /oauth/token`, form-encoded (JSON is accepted too): ```bash curl -X POST https://api.econtract.online/oauth/token \ -d grant_type=authorization_code \ -d code=eco_ac_9c1d… \ -d redirect_uri=https://app.example.com/oauth/callback \ -d client_id=https://app.example.com/oauth/client-metadata.json \ -d code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk \ -d resource=https://api.econtract.online ``` ```json { "access_token": "eco_at_5e0b…", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "eco_rt_a7c4…", "scope": "contracts:read contracts:write offline_access" } ``` Then call the API with `Authorization: Bearer eco_at_…`. `GET /api/v1/auth/me` shows the user the token acts for. **Node.js** ```js import crypto from "node:crypto"; const ISSUER = "https://api.econtract.online"; const CLIENT_ID = "https://app.example.com/oauth/client-metadata.json"; const REDIRECT_URI = "https://app.example.com/oauth/callback"; export function startLogin() { const verifier = crypto.randomBytes(32).toString("base64url"); const challenge = crypto.createHash("sha256").update(verifier).digest("base64url"); const state = crypto.randomBytes(16).toString("base64url"); const url = new URL(`${ISSUER}/oauth/authorize`); url.search = new URLSearchParams({ response_type: "code", client_id: CLIENT_ID, redirect_uri: REDIRECT_URI, code_challenge: challenge, code_challenge_method: "S256", state, scope: "contracts:read contracts:write offline_access", resource: ISSUER, }).toString(); return { url: url.toString(), verifier, state }; // keep verifier + state in the session } export async function finishLogin(callbackUrl, { verifier, state }) { const params = new URL(callbackUrl).searchParams; if (params.get("state") !== state) throw new Error("state mismatch"); if (params.get("iss") !== ISSUER) throw new Error("issuer mismatch"); if (params.get("error")) throw new Error(params.get("error")); const res = await fetch(`${ISSUER}/oauth/token`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "authorization_code", code: params.get("code"), redirect_uri: REDIRECT_URI, client_id: CLIENT_ID, code_verifier: verifier, resource: ISSUER, }), }); if (!res.ok) throw new Error(JSON.stringify(await res.json())); return res.json(); // { access_token, refresh_token?, expires_in, scope } } ``` **Python** ```python import base64 import hashlib import secrets from urllib.parse import urlencode, urlparse, parse_qs import requests ISSUER = "https://api.econtract.online" CLIENT_ID = "https://app.example.com/oauth/client-metadata.json" REDIRECT_URI = "https://app.example.com/oauth/callback" def start_login(): verifier = secrets.token_urlsafe(32) challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode() state = secrets.token_urlsafe(16) url = f"{ISSUER}/oauth/authorize?" + urlencode({ "response_type": "code", "client_id": CLIENT_ID, "redirect_uri": REDIRECT_URI, "code_challenge": challenge, "code_challenge_method": "S256", "state": state, "scope": "contracts:read contracts:write offline_access", "resource": ISSUER, }) return url, verifier, state # keep verifier + state in the session def finish_login(callback_url, verifier, state): params = {k: v[0] for k, v in parse_qs(urlparse(callback_url).query).items()} if params.get("state") != state or params.get("iss") != ISSUER: raise RuntimeError("state or issuer mismatch") if "error" in params: raise RuntimeError(params["error"]) res = requests.post(f"{ISSUER}/oauth/token", data={ "grant_type": "authorization_code", "code": params["code"], "redirect_uri": REDIRECT_URI, "client_id": CLIENT_ID, "code_verifier": verifier, "resource": ISSUER, }) res.raise_for_status() return res.json() # access_token, refresh_token (optional), expires_in, scope ``` ## Audience (`resource`) A token works only for the resources it was issued for (RFC 8707): | `resource` | Token accepted by | |---|---| | `https://api.econtract.online` | The REST API (through the gateway) | | `https://api.econtract.online/mcp` | The [MCP server](https://developers.econtract.online/docs/mcp) | | both (repeat the parameter) or none | Both | Any other value fails with `invalid_target`. Values are compared after normalisation (lower-case scheme and host, no default port, no trailing slash). At the token endpoint `resource` may narrow the audience to one of the resources that were authorized. A token presented to a resource it is not for gets `401 INVALID_TOKEN`. ## Tokens | Token | Format | Lifetime | |---|---|---| | Authorization code | `eco_ac_` + 64 hex | 5 minutes, single use | | Access token | `eco_at_` + 64 hex | 1 hour (`expires_in: 3600`) | | Refresh token | `eco_rt_` + 64 hex | 30 days from issue, renewed on every refresh | eContract stores only SHA-256 hashes of codes, tokens and client secrets. ### Refresh tokens You get a refresh token only if the user granted **`offline_access`** or your client registered the **`refresh_token`** grant type. Dynamically registered clients default to `authorization_code` only, so ask for one of the two. ```bash curl -X POST https://api.econtract.online/oauth/token \ -d grant_type=refresh_token \ -d refresh_token=eco_rt_a7c4… \ -d client_id=https://app.example.com/oauth/client-metadata.json ``` - **Rotation**: every refresh returns a new refresh token and retires the old one. Store the new one right away. - **Reuse detection**: presenting a retired refresh token again revokes the whole grant (every access and refresh token of that user, workspace and app). The user has to connect again. Presenting an authorization code twice does the same. - Optional `scope` narrows the new access token (it must be a subset; otherwise `invalid_scope`); the new refresh token keeps the full original scope. Optional `resource` narrows the audience. - A refresh fails with `invalid_grant` once the grant was revoked or the user is no longer an active member of the workspace. ### Grants One grant exists per app, user and workspace. Approving the same app again adds the newly approved scopes to the existing grant. After a revocation, the next approval starts over with only the scopes approved then (old tokens stay revoked). Tokens act with the user's current role, capped at `contract_manager` (see [Machine role](https://developers.econtract.online/docs/authentication#machine-role)). ## Revocation `POST /oauth/revoke` (RFC 7009) with `token` and, for public clients, `client_id`: ```bash curl -X POST https://api.econtract.online/oauth/revoke \ -d token=eco_rt_a7c4… \ -d client_id=https://app.example.com/oauth/client-metadata.json ``` - Always answers `200 {}`, also for unknown tokens. Confidential clients must authenticate; wrong credentials answer `401 invalid_client`. - Revoking a **refresh token** revokes it and every token issued from the same authorization (the whole token family). Revoking an **access token** revokes only that token. - The gateway and the MCP server cache token checks for up to 30 seconds. ## Connected apps Every workspace member sees the apps they connected under **Dashboard → Connected apps** (`/dashboard/settings/connected-apps`): the app, the workspace, the granted scopes and when it was last used. **Disconnect** revokes the grant and all of its tokens at once. This page, the consent screen and their API (`/api/v1/auth/oauth/*`) need a signed-in user; OAuth tokens and API keys cannot approve, list or revoke grants. ## Scopes The same catalogue as API keys, see [Scopes](https://developers.econtract.online/docs/authentication#scopes), plus `offline_access` for refresh tokens. Ask only for what you need: users see every scope on the consent screen and may remove some, so check the `scope` field of the token response. ## Errors The OAuth endpoints answer RFC 6749 errors with `Cache-Control: no-store`: ```json { "error": "invalid_grant", "error_description": "Authorization code has expired" } ``` | Error | Where | Meaning | |---|---|---| | `invalid_request` | all | Missing, repeated or malformed parameter | | `invalid_client` (401) | token, revoke | Unknown client or wrong secret (`WWW-Authenticate: Basic` when Basic auth was used) | | `invalid_grant` | token | Code or refresh token invalid, expired, used or revoked; redirect URI or PKCE verifier does not match; user left the workspace | | `unsupported_grant_type` | token | Only `authorization_code` and `refresh_token` | | `invalid_scope` | token | Refresh `scope` is not a subset of the grant | | `invalid_target` | authorize, token | `resource` is not one of the two resources, or was not authorized | | `invalid_redirect_uri` | register | No usable redirect URI | | `invalid_client_metadata` | register | Invalid registration field | | `unsupported_response_type`, `unauthorized_client` | authorize (redirect) | Only `response_type=code`; the client is not registered for it | | `access_denied` | authorize (redirect) | The user denied the request | | `rate_limited` (429) | register, token | Too many requests, see `Retry-After` | Errors at `/oauth/authorize` are sent to your redirect URI with `error`, `error_description`, `state` and `iss`, except when the client or redirect URI cannot be trusted (then the user sees an error page). ## Rate limits | Endpoint | Limit | |---|---| | `POST /oauth/register` | 300 per hour per IP | | `POST /oauth/token` | 600 per minute per client and IP | | Client ID Metadata Document fetches | 60 per minute per client host | | `GET /oauth/authorize` | 100 per minute per IP | | All `/oauth/*` requests at the gateway | 3,000 per minute and 60,000 per hour per IP | API calls made with the tokens count against the [per-grant API limits](https://developers.econtract.online/docs/authentication#rate-limits). --- # MCP server Source: https://developers.econtract.online/docs/mcp > Connect Claude, ChatGPT, Cursor, VS Code or your own agent to eContract with the Model Context Protocol eContract runs a remote [Model Context Protocol](https://modelcontextprotocol.io) server. AI clients that speak MCP can send contracts for e-signature, follow signing, hand out signing links and fetch signed PDFs and audit trails, without custom code. ``` https://api.econtract.online/mcp ``` - Transport: **Streamable HTTP**, stateless, `POST /mcp` (protocol `2026-07-28`; clients that still start with `initialize` are served too). `GET` and `DELETE /mcp` answer `405` (after authentication). - **Signing is always human.** No tool signs or declines; the agent gives each signer their own link and the person signs. ## Authentication Every request needs `Authorization: Bearer `, either: - **OAuth 2.1** (recommended for apps people connect): the client discovers everything from the server's `401` answer and runs the [OAuth flow](https://developers.econtract.online/docs/oauth) in the browser. The user signs in, picks a workspace and approves the scopes. Tokens are `eco_at_…` and must be issued for the resource `https://api.econtract.online/mcp`. - **An API key** `cl_live_…` (headless agents, CI, CLI configs with a fixed header), created under **Dashboard → API Keys**. A request without a valid token gets: ``` HTTP/1.1 401 Unauthorized WWW-Authenticate: Bearer resource_metadata="https://api.econtract.online/.well-known/oauth-protected-resource/mcp", scope="contracts:read contracts:write templates:read files:read files:write ai:use" ``` `error="invalid_token"` is added when a token was sent but is not valid. The protected resource metadata points to the authorization server `https://api.econtract.online`. The MCP server checks every token with eContract and never forwards it; tool calls reach the contract API with the same identity, scopes and [role cap](https://developers.econtract.online/docs/authentication#machine-role) as REST calls. ## Tools | Tool | Scope | What it does | |---|---|---| | `list_templates` | `templates:read` | List workspace templates (`search`, `category`, `type`, `page`, `limit`) | | `get_template` | `templates:read` | A template and the variables to fill | | `list_contracts` | `contracts:read` | List contracts (`status`, `search`, `fromDate`, `toDate`, `page`, `limit`) | | `get_contract` | `contracts:read` | Status, processing status, signers and a `nextStep` hint. Accepts the id or the code (`CTR-…`) | | `send_contract` | `contracts:write` | Create and send in one step: one of `file` (base64 PDF/DOCX), `fileUrl` (https) or `templateId` + `templateValues`; `signers`, `signingOrder`, `expiresInDays`, `delivery` (`email`, `link`, `both`) | | `get_signing_links` | `contracts:write` | A fresh signing link for every signer who still has to sign. Sends no email | | `send_reminder` | `contracts:write` | Email pending signers again (all, or one `signerId`) | | `void_contract` | `contracts:write` | Cancel a contract (destructive) | | `download_signed_document` | `contracts:read` | The PDF as an embedded `application/pdf` resource (`version`: `signed` or `original`) | | `get_audit_trail` | `contracts:read` | Events, signers, hash-chain check and document SHA-256 | | `verify_pdf` | none | Check the digital signatures of any PDF (base64) | | `analyze_contract` | `ai:use` (+ `contracts:read` with `contractId`) | AI summary: type, parties, value, duration, suggested titles and signers, from `text`, `contentBase64` or `contractId` | Also a resource template `econtract://contracts/{contractId}/document/{version}` (`contracts:read`). - Write tools take an optional `idempotencyKey`. Without it the server generates one and returns it, so the model can repeat a call safely (see [Idempotency](https://developers.econtract.online/docs/idempotency)). - Documents passing through the connector (uploads and downloads) are limited to **10 MB**; larger files must be uploaded with `fileUrl` or downloaded in the eContract app. - API errors come back as tool results with `isError: true`, the error `code`, `message`, `requestId` and a hint for the model. ## Typical workflow 1. `list_templates` / `get_template`, or take the user's PDF or DOCX. 2. `send_contract`, after confirming title, document and signers with the user. It returns `processingStatus: "processing"`. 3. Poll `get_contract` until `processingStatus` is `ready`. 4. With `delivery: "link"` or `"both"`: `get_signing_links`, and give each signer **only their own** link. 5. While waiting: `get_contract`, `send_reminder`; `void_contract` to cancel. 6. When `status` is `completed`: `download_signed_document` and `get_audit_trail`. ## Connect your client Server URL: **`https://api.econtract.online/mcp`**. OAuth clients only need the URL; they find the rest themselves. ### Claude.ai and Claude Desktop **Settings → Connectors → Add custom connector**, URL `https://api.econtract.online/mcp`, then **Connect** and approve the consent screen. (Menu names: verify against the current Claude UI.) ### Claude Code ```bash # OAuth (opens the browser on first use; or run /mcp inside Claude Code) claude mcp add --transport http econtract https://api.econtract.online/mcp # API key claude mcp add --transport http econtract https://api.econtract.online/mcp \ --header "Authorization: Bearer cl_live_..." ``` Project-scoped `.mcp.json`: ```json { "mcpServers": { "econtract": { "type": "http", "url": "https://api.econtract.online/mcp", "headers": { "Authorization": "Bearer ${ECONTRACT_API_KEY}" } } } } ``` ### ChatGPT Enable **Developer mode** (**Settings → Apps & Connectors → Advanced**), then **Create** a connector with URL `https://api.econtract.online/mcp` and authentication **OAuth**. (Menu names: verify against the current ChatGPT UI.) ### Cursor `~/.cursor/mcp.json` (or `.cursor/mcp.json` in a project): ```json { "mcpServers": { "econtract": { "url": "https://api.econtract.online/mcp" } } } ``` Add `"headers": { "Authorization": "Bearer cl_live_..." }` to use an API key instead of OAuth. (Verify against the current Cursor docs.) ### VS Code (GitHub Copilot agent mode) `.vscode/mcp.json`: ```json { "servers": { "econtract": { "type": "http", "url": "https://api.econtract.online/mcp" } } } ``` VS Code runs the OAuth flow when the server answers `401`. For an API key add `"headers": { "Authorization": "Bearer ${input:econtract-key}" }` with a matching `inputs` entry. (Verify against the current VS Code docs.) ### Your own agent or the MCP Inspector Any MCP client with Streamable HTTP works. To try the tools by hand: `npx @modelcontextprotocol/inspector`, transport **Streamable HTTP**, URL `https://api.econtract.online/mcp`, header `Authorization: Bearer cl_live_…`. ## Troubleshooting | Symptom | Cause | Fix | |---|---|---| | `401` with `WWW-Authenticate: Bearer resource_metadata=…` | No token, or the client did not run OAuth | Reconnect; the client should follow `resource_metadata`. With an API key check the `Authorization` header | | `401` with `error="invalid_token"` | Token expired or revoked, key revoked, user left the workspace, or an OAuth token issued only for the REST API | Let the client refresh or reconnect; OAuth clients must ask for `resource=https://api.econtract.online/mcp` | | `400 invalid_request` | Token sent in the URL (`?access_token=`) | Send it in the `Authorization` header only | | `403` with `WWW-Authenticate: Bearer error="insufficient_scope", scope="…"` | The tool needs a scope the connection lacks | OAuth clients can re-authorize with the listed scope; for an API key add the scope under Dashboard → API Keys | | `403 Forbidden: invalid Origin header` | A browser client sent an `Origin` that is not allowed | Browser clients must run on an https origin (or http on localhost) | | `429` with `Retry-After` (JSON-RPC error, `data.code: "RATE_LIMITED"`) | More than 60 requests per minute for this key or connection | Wait `Retry-After` seconds | | `503` with `Retry-After: 5` | eContract could not check the token | Retry shortly | | Tool result `isError: true`, code `HUMAN_ACTION_REQUIRED` | The model tried to sign or decline | Give the signer their link from `get_signing_links` | | Tool result `isError: true`, code `INSUFFICIENT_SCOPE` or `FORBIDDEN` | Scope missing, or the user's workspace role does not allow it | Reconnect with the scope, or use an account with a suitable role | | `download_signed_document` fails with `DOCUMENT_NOT_SIGNED` | Not everyone has signed yet | Wait for `status: "completed"` | | The client shows no eContract tools | Connection failed silently | Check the client's MCP log; test with the MCP Inspector and an API key | --- # Core Concepts Source: https://developers.econtract.online/docs/concepts > Core concepts of the eContract API, from workspaces and roles to the contract lifecycle, async document processing, signers, signing order and templates. Understanding these core concepts will help you build effective integrations with the eContract API. ## Workspaces Workspaces are the top-level organizational unit in eContract. Each workspace is an isolated tenant with its own contracts, users, templates, and API keys. - A user can belong to multiple workspaces - API keys are scoped to a single workspace - All API requests operate within the context of one workspace ### Workspace Roles | Role | Description | |------|-------------| | `owner`, `admin` | Full workspace management: users, API keys, webhooks, settings | | `contract_manager` | Create, send, and manage contracts | | `member` | Work with contracts as the workspace allows | | `viewer` | Read-only access to contracts | API keys and OAuth tokens act with their user's role capped at `contract_manager`; see [Machine role](https://developers.econtract.online/docs/authentication#machine-role). ## Contract Lifecycle Contracts follow a defined lifecycle from creation to completion: ``` ┌───────┐ ┌─────────┐ ┌────────────┐ ┌───────────┐ │ DRAFT │────→│ PENDING │────→│ IN_SIGNING │────→│ COMPLETED │ └───────┘ └─────────┘ └────────────┘ └───────────┘ │ │ │ │ │ │ ▼ ▼ ▼ ┌────────┐ ┌────────┐ ┌─────────┐ │ VOIDED │ │ VOIDED │ │ EXPIRED │ └────────┘ └────────┘ └─────────┘ ``` ### Contract Statuses | Status | Description | |--------|-------------| | `draft` | Contract created, can add files and edit details | | `pending` | Ready but not yet submitted for signing | | `in_signing` | Sent to signers, awaiting signatures | | `completed` | All signers have signed | | `voided` | Cancelled by the creator | | `expired` | Signing deadline passed (default: 90 days) | ### Key Rules - Only `draft` contracts can be edited, have files added, or be deleted - Contracts in `draft` or `pending` status can be submitted for signing - Contracts can be voided unless they are `completed` or already `voided` - A contract moves to `completed` automatically when all signers finish ### Creating a Contract **cURL** ```bash curl -X POST https://api.econtract.online/api/v1/contracts \ -H "Authorization: Bearer cl_live_your_key" \ -H "Content-Type: application/json" \ -d '{ "title": "NDA Agreement", "description": "Mutual non-disclosure agreement", "signingOrderType": "parallel", "expiresInDays": 30 }' ``` **Node.js** ```js const contract = await fetch( "https://api.econtract.online/api/v1/contracts", { method: "POST", headers: { Authorization: `Bearer ${process.env.ECONTRACT_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ title: "NDA Agreement", description: "Mutual non-disclosure agreement", signingOrderType: "parallel", expiresInDays: 30, }), } ).then((r) => r.json()); ``` **Python** ```python contract = requests.post( "https://api.econtract.online/api/v1/contracts", headers={"Authorization": f"Bearer {os.environ['ECONTRACT_API_KEY']}"}, json={ "title": "NDA Agreement", "description": "Mutual non-disclosure agreement", "signingOrderType": "parallel", "expiresInDays": 30, }, ).json() ``` ## Async Contract Processing The `POST /contracts/send` endpoint creates and sends a contract in a single API call. Because this involves file processing, signer validation, and contract submission, it runs **asynchronously**. ### How It Works 1. **Request** — You call `POST /contracts/send` with files/template, signers, and contract details 2. **Immediate response** — The API returns `202 Accepted` with the contract in `processing` state 3. **Background processing** — A worker adds signers, submits the contract, and transitions it to `in_signing` 4. **Completion** — `processingStatus` changes to `ready` (success) or `failed` (error) ### Processing Status | Status | Description | |--------|-------------| | `processing` | Background worker is processing the contract | | `ready` | Processing complete — contract is now `in_signing` | | `failed` | An error occurred — check `processingError` for details | ### Monitoring Processing **Option 1: Polling** ```bash # Poll GET /contracts/:id until processingStatus changes curl https://api.econtract.online/api/v1/contracts/{contractId} \ -H "Authorization: Bearer cl_live_your_key" ``` **Option 2: Webhooks (recommended)** Register a webhook for these events: | Event | Description | |-------|-------------| | `contract.processing.completed` | Processing succeeded, contract is in_signing | | `contract.processing.failed` | Processing failed with error | See [Webhooks](https://developers.econtract.online/docs/webhooks) for setup instructions. ### Failure Handling Processing is retried automatically (up to 3 attempts). When it still fails: - `processingStatus` is set to `failed` - `processingError` contains the error message - The contract is kept, so polling clients see the failure instead of a `404` - A `contract.processing.failed` webhook is sent Fix the input and make a new `POST /contracts/send` request (with a new `Idempotency-Key`). ## Documents & Files Each contract can have one or more attached files. Supported formats: | Format | Notes | |--------|-------| | PDF | Native support, no conversion needed | | DOCX | Automatically converted to PDF after upload | ### Uploading Files Upload files to a draft contract: ```bash curl -X POST \ "https://api.econtract.online/api/v1/contracts/{contractId}/files" \ -H "Authorization: Bearer cl_live_your_key" \ -F "file=@contract.pdf" ``` ### DOCX Conversion When you upload a DOCX file, eContract converts it to PDF automatically. The file object includes a `conversionStatus` field: | Status | Description | |--------|-------------| | `pending` | Conversion queued | | `completed` | PDF ready | | `failed` | Conversion error — retry with `POST /contracts/{id}/files/{fileId}/retry-conversion` | ### File Size Limits - **Direct upload**: 50 MB per file - **Multipart upload**: Use the multipart API for larger files ## Signers & Signing Flow Signers are the people who need to sign a contract. eContract supports both internal users (workspace members) and external signers (anyone with an email address). ### Adding Signers to a Draft `POST /contracts/send` adds the signers for you. For a draft created with `POST /contracts`, add them one by one: **cURL** ```bash curl -X POST "https://api.econtract.online/api/v1/contracts/$CONTRACT_ID/signers" \ -H "Authorization: Bearer cl_live_your_key" \ -H "Content-Type: application/json" \ -d '{ "signerType": "external", "email": "client@example.com", "name": "Jane Smith", "signOrder": 1 }' ``` **Node.js** ```js await fetch(`https://api.econtract.online/api/v1/contracts/${contractId}/signers`, { method: "POST", headers: { Authorization: `Bearer ${process.env.ECONTRACT_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ signerType: "external", email: "client@example.com", name: "Jane Smith", signOrder: 1, }), }); ``` **Python** ```python requests.post( f"https://api.econtract.online/api/v1/contracts/{contract_id}/signers", headers={"Authorization": f"Bearer {os.environ['ECONTRACT_API_KEY']}"}, json={ "signerType": "external", "email": "client@example.com", "name": "Jane Smith", "signOrder": 1, }, ) ``` `/external-signers` is the workspace address book of external signers, not the list of a contract's signers. ### Signer Statuses | Status | Description | |--------|-------------| | `pending` | Added to contract, not yet notified | | `notified` | Signing invitation email sent | | `viewed` | Signer opened the signing page | | `signed` | Signer completed their signature | | `rejected` | Signer declined to sign | | `expired` | Signing period expired | ### Signing Order eContract supports two signing order types: - **`sequential`** — Signers are notified one at a time in `signOrder`. Signer 2 is notified only after Signer 1 completes. - **`parallel`** — All signers are notified immediately and can sign in any order. ### Signing Flow When a contract is submitted: 1. **Notification** — Signers receive an email with a secure signing link 2. **View** — Signer opens the link and reviews the document 3. **OTP verification** — Signer verifies their identity via email OTP 4. **Sign or decline** — Signer draws/uploads their signature or declines 5. **Completion** — When all signers finish, the contract moves to `completed` ### Resending Notifications If a signer hasn't responded, resend the signing request: ```bash curl -X POST \ "https://api.econtract.online/api/v1/contracts/{contractId}/resend-signing-request" \ -H "Authorization: Bearer cl_live_your_key" ``` ## Templates Templates let you create reusable contract structures with variable placeholders. ### Creating a Template ```bash curl -X POST https://api.econtract.online/api/v1/contract-templates \ -H "Authorization: Bearer cl_live_your_key" \ -H "Content-Type: application/json" \ -d '{ "name": "Standard NDA", "description": "Mutual non-disclosure agreement template", "category": "legal" }' ``` ### Template Variables Extract variables from a template document: ```bash curl -X POST \ "https://api.econtract.online/api/v1/contract-templates/{templateId}/extract-variables" \ -H "Authorization: Bearer cl_live_your_key" ``` ### Generating a Contract from a Template ```bash curl -X POST \ "https://api.econtract.online/api/v1/contract-templates/{templateId}/generate" \ -H "Authorization: Bearer cl_live_your_key" \ -H "Content-Type: application/json" \ -d '{ "variables": { "companyName": "Acme Corp", "effectiveDate": "2026-04-01" } }' ``` This creates a new contract in `draft` status with the template variables filled in. --- # Webhooks Source: https://developers.econtract.online/docs/webhooks > Get contract and signing events pushed to your server, verify them and handle retries Webhooks tell your server when something happens to a contract: it finished processing, a signer opened it, signed or declined, everyone signed. Use them instead of polling. ## Set up an endpoint ### In the dashboard Workspace owners and admins manage endpoints under **Dashboard → Webhooks** (`/dashboard/webhooks`): create an endpoint (URL, events, description), copy the signing secret (shown once), enable or disable it, send a test event, read the delivery log and retry failed deliveries. ### With the API The webhook endpoints accept a signed-in admin, an API key or an OAuth token with `webhooks:read` (reading) or `webhooks:write` (everything else). For a key or token the user behind it (the key creator, or the person who connected the app) must currently be an **owner or admin** of the workspace. **cURL** ```bash curl -X POST https://api.econtract.online/api/v1/webhooks \ -H "Authorization: Bearer cl_live_your_key_here" \ -H "Content-Type: application/json" \ -d '{ "url": "https://your-app.example.com/webhooks/econtract", "events": ["contract.processing.completed", "contract.processing.failed", "signer.viewed", "contract.completed", "signer.declined"], "description": "Production" }' ``` **Node.js** ```js const res = await fetch("https://api.econtract.online/api/v1/webhooks", { method: "POST", headers: { Authorization: `Bearer ${process.env.ECONTRACT_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ url: "https://your-app.example.com/webhooks/econtract", events: ["contract.processing.completed", "contract.processing.failed", "signer.viewed", "contract.completed", "signer.declined"], description: "Production", }), }); const { id, secret } = await res.json(); // store the secret: it is shown only now ``` **Python** ```python import os import requests res = requests.post( "https://api.econtract.online/api/v1/webhooks", headers={"Authorization": f"Bearer {os.environ['ECONTRACT_API_KEY']}"}, json={ "url": "https://your-app.example.com/webhooks/econtract", "events": ["contract.processing.completed", "contract.processing.failed", "signer.viewed", "contract.completed", "signer.declined"], "description": "Production", }, ) webhook = res.json() secret = webhook["secret"] # store it: it is shown only now ``` Response (`201`), fields in snake_case: ```json { "id": "5b0e8f0a-3f0e-4c1e-9b8e-2f4a6d7c9e10", "workspace_id": "a1b2c3d4-…", "url": "https://your-app.example.com/webhooks/econtract", "events": ["contract.processing.completed", "contract.processing.failed", "signer.viewed", "contract.completed", "signer.declined"], "description": "Production", "is_active": true, "created_by": "…", "created_at": "2026-10-10T09:00:00.000Z", "updated_at": "2026-10-10T09:00:00.000Z", "secret": "9f2c…64 hex characters…" } ``` | Action | Endpoint | Scope | |---|---|---| | List (with each endpoint's last delivery) | `GET /api/v1/webhooks` | `webhooks:read` | | Create | `POST /api/v1/webhooks` with `{ url, events, description? }` | `webhooks:write` | | Get (with the 20 latest deliveries) | `GET /api/v1/webhooks/{id}` | `webhooks:read` | | Update | `PATCH /api/v1/webhooks/{id}` with `{ url?, events?, description?, is_active? }` | `webhooks:write` | | Delete | `DELETE /api/v1/webhooks/{id}` | `webhooks:write` | | Send a test event | `POST /api/v1/webhooks/{id}/test` | `webhooks:write` | | Delivery log | `GET /api/v1/webhooks/{id}/deliveries?page=1&limit=20` (max 100) | `webhooks:read` | | Retry a failed delivery | `POST /api/v1/webhooks/deliveries/{deliveryId}/retry` | `webhooks:write` | A workspace can have 20 active endpoints. ### Allowed URLs - `https://` (plain `http://` is refused in production), at most 2048 characters, no user name or password in the URL. - The host must resolve **only to public internet addresses**. Private, loopback, link-local, carrier-grade NAT, multicast and other special ranges are refused, including the same addresses written as IPv4-mapped IPv6. The check runs when you save the URL and again before every delivery, and the connection is pinned to the checked address. - Redirects are not followed: answer `2xx` from the URL itself. ## Events | Event | Sent when | |---|---| | `contract.created` | A contract is created (`POST /contracts`) or created by `POST /contracts/send` (`data.via` is `create` or `send`) | | `contract.processing.completed` | A sent contract finished processing and is `in_signing` | | `contract.processing.failed` | Processing failed (`data.processingError`); the contract keeps `processingStatus: "failed"` | | `contract.submitted` | A contract was submitted and the signing workflow started | | `workflow.started` | Same moment as `contract.submitted` | | `signer.invited` | An invitation email went out to a signer | | `signer.viewed` | An external signer opened the contract for the first time | | `contract.signed` | **One signer** signed (sent once per signature, together with `signer.completed`) | | `signer.completed` | A signer signed | | `contract.completed` | Everyone signed; the sealed PDF and audit trail are ready | | `workflow.completed` | Same moment as `contract.completed` | | `signer.declined` | A signer declined (`data.reason`); the contract is voided | | `contract.voided` | The contract was voided: by its sender (`POST /contracts/{id}/void`) or because a signer declined (`data.reason`) | | `workflow.voided` | Same moment as `contract.voided` when the sender voids the contract | | `contract.expired` | The signing deadline passed | | `workflow.expired` | Same moment as `contract.expired` | | `workflow.reminder_sent` | An automatic reminder email went to a signer | | `webhook.test` | You sent a test event | > **Delivery mode `link`.** When a contract is sent with `delivery: "link"` the workflow starts (`contract.submitted`, `workflow.started`) but nobody is invited by email, so there is no `signer.invited` and no automatic reminder for those signers. This also holds for sequential signing: the next signer is not emailed after the previous one signs, so hand out each link when it is that signer's turn. `contract.processing.completed` tells you when to fetch the links. ## Payload Every delivery is a `POST` with a JSON body: ```json { "id": "evt_2f6d8a3e-0c1b-4f7e-9a55-6b1d2e3f4a5b", "event": "signer.viewed", "timestamp": "2026-10-10T09:41:07.512Z", "livemode": true, "data": { "contractId": "8c1e…", "signerId": "3a7f…", "signerEmail": "alice@example.com", "contract": { "id": "8c1e…", "code": "CTR-…", "title": "Service Agreement", "status": "in_signing" }, "signer": { "id": "3a7f…", "name": "Alice Johnson", "email": "alice@example.com", "status": "viewed" }, "occurredAt": "2026-10-10T09:41:07.498Z" } } ``` | Field | Description | |---|---| | `id` | Event id (`evt_…`). The same for every endpoint, every retry and every manual retry of this event: use it to de-duplicate | | `event` | Event name | | `timestamp` | When the event was dispatched (ISO 8601) | | `livemode` | Always `true` for now (test keys are not separated yet) | | `data.contract` | `{ id, code, title, status }`, with the status right after the event | | `data.signer` | Signer events: `{ id, name, email, status }`; signer status is one of `pending`, `notified`, `viewed`, `signed`, `rejected` | | `data.*` | Event specific: `contractId`, `signerId`, `signerEmail`, `reason`, `processingStatus`, `processingError`, `via`, `createdBy`, `occurredAt`, … | The body is limited to 64 KB. ## Headers | Header | Value | |---|---| | `Content-Type` | `application/json` | | `User-Agent` | `eContract-Webhooks/1.0` | | `X-Econtract-Event` | Event name, for example `contract.completed` | | `X-Econtract-Event-Id` | Event id, same as `id` in the body (stable across retries) | | `X-Econtract-Delivery` | Id of this delivery attempt (new on every attempt) | | `X-Econtract-Timestamp` | Unix time (seconds) of this attempt; part of the signature | | `X-Econtract-Signature` | `t=,v1=` | ## Verify the signature The signature is `HMAC-SHA256(secret, ".")` in hex, where `t` is the timestamp from the header and the secret is the string you got when you created the endpoint. Compute it over the **raw bytes** you received (parse the JSON afterwards), compare in constant time, and reject old timestamps to stop replays. **Node.js (Express)** ```js import crypto from "node:crypto"; import express from "express"; const SECRET = process.env.ECONTRACT_WEBHOOK_SECRET; const TOLERANCE_SECONDS = 300; function verify(rawBody, header) { const parts = Object.fromEntries( String(header || "").split(",").map((p) => p.trim().split("=", 2)), ); const t = Number(parts.t); if (!t || !parts.v1) return false; if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false; const expected = crypto.createHmac("sha256", SECRET).update(`${t}.${rawBody}`).digest("hex"); const a = Buffer.from(parts.v1, "hex"); const b = Buffer.from(expected, "hex"); return a.length === b.length && crypto.timingSafeEqual(a, b); } const app = express(); const seen = new Set(); // use your database in production app.post("/webhooks/econtract", express.raw({ type: "application/json" }), (req, res) => { if (!verify(req.body.toString("utf8"), req.get("X-Econtract-Signature"))) { return res.status(401).send("invalid signature"); } const event = JSON.parse(req.body.toString("utf8")); if (seen.has(event.id)) return res.sendStatus(200); // duplicate: already handled seen.add(event.id); // Answer fast, work in the background res.sendStatus(200); queueMicrotask(() => handle(event)); }); function handle(event) { if (event.event === "contract.completed") { // download GET /api/v1/contracts/{id}/document?version=signed and the audit trail } } app.listen(3000); ``` **Python (Flask)** ```python import hashlib import hmac import json import os import time from flask import Flask, request SECRET = os.environ["ECONTRACT_WEBHOOK_SECRET"].encode() TOLERANCE_SECONDS = 300 app = Flask(__name__) seen = set() # use your database in production def verify(raw_body: bytes, header: str) -> bool: try: parts = dict(p.strip().split("=", 1) for p in (header or "").split(",")) t = int(parts["t"]) received = parts["v1"] except (KeyError, ValueError): return False if abs(time.time() - t) > TOLERANCE_SECONDS: return False expected = hmac.new(SECRET, f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(received, expected) @app.post("/webhooks/econtract") def econtract_webhook(): raw = request.get_data() # raw bytes, before any JSON parsing if not verify(raw, request.headers.get("X-Econtract-Signature", "")): return "invalid signature", 401 event = json.loads(raw) if event["id"] in seen: return "", 200 # duplicate: already handled seen.add(event["id"]) # hand the event to a background job here return "", 200 ``` ## Retries A delivery succeeds when your endpoint answers `2xx` within **30 seconds**. Anything else (other status, redirect, timeout, connection or TLS error) is retried with the same body and event id: | Attempt | When | |---|---| | 1 | Right away | | 2 | 1 minute after attempt 1 failed | | 3 | 5 minutes later | | 4 | 30 minutes later | | 5 | 2 hours later | | 6 | 24 hours later | After the sixth failed attempt the delivery is marked `failed`. Delivery states are `pending`, `retrying`, `success` and `failed`. ### Delivery log and manual retry `GET /api/v1/webhooks/{id}/deliveries` (or the dashboard) lists every delivery with its event, event id, status, attempts, last response code, last error, the first 500 characters of your endpoint's last answer and the next retry time. Delivery history is kept for 30 days. `POST /api/v1/webhooks/deliveries/{deliveryId}/retry` re-queues a **failed** delivery immediately, with the same payload and event id and a fresh set of attempts. ## Good practice - **Verify every request** and reject timestamps older than a few minutes. - **De-duplicate by `id`** (or `X-Econtract-Event-Id`): retries and manual retries reuse it. Events can arrive out of order; use `data.contract.status` or fetch the contract when order matters. - **Answer `200` fast** and do the work in a background job. - **Use HTTPS** with a valid certificate on a public host. - **Watch the delivery log** after deploying a new endpoint, and use **Send test event** (`webhook.test`) to check your verification code. --- # Idempotency Source: https://developers.econtract.online/docs/idempotency > Retry eContract API writes safely with the Idempotency-Key header, covering where it works, how replays behave and how long results are kept. Networks fail and agents retry. Send an `Idempotency-Key` header on a write and eContract runs it **at most once**: a retry with the same key and the same body gets the first answer back instead of creating a second contract, a second batch of signing links or a second round of reminder emails. ```bash curl -X POST https://api.econtract.online/api/v1/contracts/send \ -H "Authorization: Bearer cl_live_your_key_here" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 4f8d2c1e-7b3a-4e55-9d0f-2a6c8b1e9f00" \ -d @send.json ``` Use a fresh random value (a UUID v4 is ideal) for every logical operation, and reuse it only for retries of that operation. ## Where it works | Endpoint | | |---|---| | `POST /api/v1/contracts` | Create a draft contract | | `POST /api/v1/contracts/send` | Create and send (JSON or multipart) | | `POST /api/v1/contracts/{id}/submit` | Submit for signing | | `POST /api/v1/contracts/{id}/void` | Void | | `POST /api/v1/contracts/{id}/resend-signing-request` | Email pending signers again | | `POST /api/v1/contracts/{id}/signing-links` | Issue signing links | | `POST /api/v1/contracts/{contractId}/signers` | Add a signer | | `POST /api/v1/contract-templates/{id}/generate` | Generate a contract from a template | Other endpoints ignore the header. Without the header these endpoints behave as before (no idempotency). ## How it behaves The key is scoped to your workspace **and** your credential (API key, OAuth grant or user): two different keys can use the same value without clashing. Results are kept for **24 hours**. eContract fingerprints each request from the method, the path and a SHA-256 of the body (for multipart uploads also the name, size and SHA-256 of every file). Object key order in the JSON body does not matter. | Situation | Answer | |---|---| | First request with this key | Runs normally; the result is stored | | Same key, same request, first one finished | The stored status and body are replayed with the header `Idempotent-Replayed: true`. Nothing runs again | | Same key, same request, first one still running | `409 IDEMPOTENCY_IN_PROGRESS`: retry in a few seconds | | Same key, **different** request | `422 IDEMPOTENCY_KEY_REUSED` | | Key empty, longer than 255 characters or not visible ASCII | `400 IDEMPOTENCY_KEY_INVALID` | What is stored and replayed: - **Successful answers** (`2xx`). - **Definitive client errors** (`4xx`, for example `400 VALIDATION_FAILED` or `404`): the same request would fail the same way, so the stored error is replayed. Fix the request and send it with a **new** key. - **Not stored**: server errors (`5xx`) and transient errors (`408`, `409`, `425`, `429`). Retry them with the same key. A request that crashes without an answer frees its key after 10 minutes. Answers larger than 1 MB are not stored (the next request with that key runs again). If the idempotency store is unavailable, requests run without idempotency rather than failing. ## Example: retrying a send **Node.js** ```js import { randomUUID } from "node:crypto"; const key = randomUUID(); // one key for this contract, reused on every retry async function sendOnce(body) { for (let attempt = 0; attempt < 5; attempt++) { const res = await fetch("https://api.econtract.online/api/v1/contracts/send", { method: "POST", headers: { Authorization: `Bearer ${process.env.ECONTRACT_API_KEY}`, "Content-Type": "application/json", "Idempotency-Key": key, }, body: JSON.stringify(body), }).catch(() => null); // network error: retry with the same key if (res?.ok) { if (res.headers.get("Idempotent-Replayed") === "true") console.log("replayed"); return res.json(); } const err = res ? await res.json() : { code: "NETWORK" }; const retry = !res || [429, 502, 503, 504].includes(res.status) || err.code === "IDEMPOTENCY_IN_PROGRESS"; if (!retry) throw new Error(`${err.code}: ${err.message}`); await new Promise((r) => setTimeout(r, 2 ** attempt * 1000)); } throw new Error("gave up"); } ``` **Python** ```python import os import time import uuid import requests key = str(uuid.uuid4()) # one key for this contract, reused on every retry def send_once(body): for attempt in range(5): try: res = requests.post( "https://api.econtract.online/api/v1/contracts/send", headers={ "Authorization": f"Bearer {os.environ['ECONTRACT_API_KEY']}", "Idempotency-Key": key, }, json=body, timeout=120, ) except requests.RequestException: time.sleep(2 ** attempt) # network error: retry with the same key continue if res.ok: return res.json() err = res.json() if res.status_code not in (429, 502, 503, 504) and err.get("code") != "IDEMPOTENCY_IN_PROGRESS": raise RuntimeError(f"{err.get('code')}: {err.get('message')}") time.sleep(2 ** attempt) raise RuntimeError("gave up") ``` ## MCP tools Every write tool of the [MCP server](https://developers.econtract.online/docs/mcp) takes an optional `idempotencyKey` argument and sends it as `Idempotency-Key`. When the argument is missing the server generates a UUID and returns it in the result, so the model can repeat the call safely with that key. --- # Errors Source: https://developers.econtract.online/docs/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: ```json { "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`: ```json { "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](https://developers.econtract.online/docs/oauth#errors) | | **MCP server** | Tool calls | A tool result with `isError: true` and the code, message and `requestId` of the API error, see [MCP](https://developers.econtract.online/docs/mcp#troubleshooting) | ## 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](https://developers.econtract.online/docs/ai-agents#signing-is-human) | | `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](https://developers.econtract.online/docs/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](https://developers.econtract.online/docs/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): ```json { "statusCode": 403, "error": "Forbidden", "code": "INSUFFICIENT_SCOPE", "message": "API key lacks required scope: contracts:write", "required_scope": "contracts:write" } ``` **A machine tries to sign**: ```json { "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**: ```json { "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. **Node.js** ```js 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)); } } ``` **Python** ```python 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)) ``` --- # Limits and pricing Source: https://developers.econtract.online/docs/limits > 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. ## Pricing eContract is **free** right now: every feature, the REST API, OAuth and the MCP server included. There are no paid plans to buy; checkout, billing portal and plan-change endpoints answer `410 BILLING_DISABLED`. Paid plans may come later. If they do, you will be told **at least 30 days in advance**, and your contracts and data stay yours. Instead of plan quotas there are fair-use caps that protect the service from abuse. Hitting one answers `429` with `code: USAGE_LIMIT_REACHED` and a `Retry-After` header. It is never a payment prompt. ## What the free plan includes | | Limit | |---|---| | Contracts (documents) | Unlimited | | Users per workspace | Unlimited | | Templates and custom templates | Unlimited | | Workspaces per user | 3 | | Storage per workspace | 5 GB (checked when uploading through `POST /api/v1/files/upload`) | | Features (AI assistant, API, webhooks, signature types, …) | All enabled | ## Fair-use caps Daily caps count per UTC day and reset at 00:00 UTC. | What | Cap | Answer when reached | |---|---|---| | Invitation and OTP emails | 100 per workspace per day | Invitations are skipped for the rest of the day (the signer stays un-notified, so you can resend later); `POST /otp/send` and resends answer `429 USAGE_LIMIT_REACHED` (`metric: "emails"`) | | AI calls (`/api/v1/ai/*`: text extraction, contract analysis) | 30 per user per day | `429 USAGE_LIMIT_REACHED` (`metric: "ai_calls"`) | | AI spend safety net | USD 1.00 of model cost per user per day | `429 USAGE_LIMIT_REACHED` (`metric: "ai_cost"`) | | AI prefill of a contract (`POST /api/v1/contracts/{id}/prefill`) | 30 per user and 100 per workspace per day | `429` with `code: AI_DAILY_LIMIT` | | Account signups | 5 per IP address per day | `429 USAGE_LIMIT_REACHED` (`metric: "signups"`) | | Workspaces | 3 per user | `429 USAGE_LIMIT_REACHED` (`metric: "workspaces"`) | | Storage | 5 GB per workspace | `429 USAGE_LIMIT_REACHED` (`metric: "storage"`) | A machine credential counts against the caps of its user (the key creator, or the person who connected the OAuth app) and of its workspace. ## Rate limits | Scope | Limit | |---|---| | Each API key or OAuth grant, REST API | 60 per minute, 1,000 per hour, 10,000 per day ([details](https://developers.econtract.online/docs/authentication#rate-limits)) | | Each API key or OAuth grant, MCP server | 60 per minute (separate from the REST limits) | | Each client IP at the gateway | 120 per minute, 5,000 per hour (`/mcp` and `/oauth/*`: 3,000 per minute, 60,000 per hour) | | Account endpoints (`/api/v1/auth/*`, `/api/v1/webhooks`, …), per IP | 100 per minute | | `POST /oauth/register`, per IP | 300 per hour | | `POST /oauth/token`, per client and IP | 600 per minute | | Client ID Metadata Document fetches, per client host | 60 per minute | | `POST /api/v1/verify/pdf`, per IP | 10 per minute | | `POST /verify-pdf`, per IP | 50 per day | | `POST /api/v1/otp/send`, per signing link | 1 per minute | ## Size and count limits | What | Limit | |---|---| | Request body at the edge | 50 MB per request | | Files per contract (`POST /contracts/send`) | 10 | | One file | 50 MB, PDF or DOCX only (checked from the bytes, not the name) | | Base64 files in a JSON body | Base64 adds a third, so the files of one JSON request can total about 37 MB. Use `files[].url` or multipart for more | | `files[].url` downloads | https only, public addresses only, no redirects, 30 s, 50 MB | | Signers per contract (`POST /contracts/send`) | 100 (the MCP `send_contract` tool: 50) | | MCP documents (upload and download) | 10 MB | | Page size of list endpoints | 100 (larger values are capped) | | Contract expiry | 90 days by default, `expiresInDays` 1–3650 | | Signing links | Valid until the contract expires (30 days when it has no future expiry) | | Active API keys per workspace | 10 | | Active webhooks per workspace | 20 | | Webhook payload | 64 KB | | `Idempotency-Key` | 255 characters, results kept 24 h | --- # Changelog Source: https://developers.econtract.online/docs/changelog > API versioning policy and change history of the eContract API, including the agent platform release with the MCP server, OAuth 2.1 and free mode. All notable changes to the eContract API are documented here. We follow [Semantic Versioning](https://semver.org/) for API releases. ## Breaking Changes Policy We are committed to API stability. Breaking changes are: - **Announced at least 30 days in advance** via email to all registered developers - **Versioned** — breaking changes require a new major API version - **Documented** — all deprecated endpoints include migration guides A "breaking change" includes: - Removing an endpoint or HTTP method - Removing or renaming a request/response field - Changing the type of an existing field - Adding a new required request parameter - Changing authentication or authorization behavior - Changing error response codes for existing conditions Non-breaking changes (added endpoints, new optional fields, new enum values) may be shipped at any time without a version bump. --- ## v1.1.0 — 2026-10 — Agent platform eContract is now built for AI agents: the whole contract job can be done through the API or the new MCP server. Signing stays human. ### Added - **Remote MCP server** at `https://api.econtract.online/mcp` (Streamable HTTP) with 12 tools: templates, send, status, signing links, reminders, void, signed PDF, audit trail, PDF verification and AI analysis. See [MCP server](https://developers.econtract.online/docs/mcp). - **OAuth 2.1 authorization server**: authorization code + PKCE (S256), discovery at `/.well-known/oauth-authorization-server`, Client ID Metadata Documents, dynamic client registration, refresh token rotation with reuse detection, revocation, `iss` in authorization responses, resource indicators for the REST API and the MCP server. Users approve apps per workspace and manage them under **Connected apps**. See [OAuth 2.1](https://developers.econtract.online/docs/oauth). - `POST /contracts/send` accepts JSON with `files[]` (base64 content or an https URL, up to 10 files) and `delivery` (`email`, `link`, `both`). - `POST /contracts/{id}/signing-links`: signing links for every signer, without sending email. - `GET /contracts/{id}/document?version=signed|original`: the PDF with `X-Document-Version` and `X-Document-Sha256`. - `GET /contracts/{id}/audit-trail`: signers, events, hash-chain check and document SHA-256. - `Idempotency-Key` on contract writes (send, create, submit, void, reminders, signing links, add signer, generate from template). See [Idempotency](https://developers.econtract.online/docs/idempotency). - Error envelope on the contract API: `{ statusCode, error, code, message, details?, requestId }` and the `X-Request-Id` header. See [Errors](https://developers.econtract.online/docs/errors). - Webhooks: events `contract.created` and `signer.viewed`; payloads gain `id` (event id) and `livemode`, and `data.contract` / `data.signer`; new headers `X-Econtract-Event`, `X-Econtract-Event-Id`, `X-Econtract-Delivery`, `X-Econtract-Timestamp`. Webhooks can be managed with API keys and OAuth tokens (`webhooks:read`, `webhooks:write`) and in the dashboard under **Webhooks**. - Scope catalogue: `templates:delete`, `files:delete`, `webhooks:read`, `webhooks:write`, `ai:use`; `signing:write` and `workflow:write` replace `signing:create` and `workflow:execute` (the old names keep working). - `GET /api/v1/openapi.json`: the public OpenAPI document. ### Changed - **Free mode**: every feature is free with fair-use caps; over a cap the API answers `429 USAGE_LIMIT_REACHED` with `Retry-After`. Billing endpoints answer `410 BILLING_DISABLED`. See [Limits and pricing](https://developers.econtract.online/docs/limits). - **Rate limits** per API key or OAuth grant: 60/minute, 1,000/hour, 10,000/day, with `X-RateLimit-*` headers (`X-RateLimit-Reset` is in seconds) and `429 RATE_LIMITED`. - API keys and OAuth tokens act as their user with the role capped at `contract_manager`, and need both the role permission and the scope. Contracts they create show the real creator. - Machine credentials get explicit errors: `INSUFFICIENT_SCOPE` (with `required_scope`), `MACHINE_NOT_ALLOWED`, `SCOPE_REQUIRED`, `HUMAN_ACTION_REQUIRED`. - API key rotation revokes the old key at once (no grace period). - A failed `/contracts/send` keeps the contract with `processingStatus: "failed"` instead of deleting it. ### Security - Signing and declining are refused to API keys and OAuth tokens (`403 HUMAN_ACTION_REQUIRED`). - Webhook and file URLs must resolve to public addresses; the connection is pinned to the checked address and redirects are not followed. --- ## v1.0.0 — 2026-03-22 **Initial public release** of the eContract API. ### Endpoints - **Auth** — `POST /auth/login`, `POST /auth/register`, `POST /auth/verify-email`, `GET /auth/me`, `GET /auth/workspaces`, `POST /auth/select-workspace` - **Contracts** — `POST /contracts`, `GET /contracts`, `GET /contracts/:id`, `PUT /contracts/:id`, `DELETE /contracts/:id`, `POST /contracts/:id/submit`, `POST /contracts/:id/void`, `POST /contracts/send` - **Signers** — `GET|POST /contracts/:id/signers`, `DELETE /contracts/:id/signers/:signerId` - **Templates** — `GET|POST /contract-templates`, `GET|PUT|DELETE /contract-templates/:id`, `POST /contract-templates/:id/generate` - **Webhooks** (admin session) — `POST /webhooks`, `GET /webhooks`, `PATCH|DELETE /webhooks/:id`, `POST /webhooks/:id/test` - **API Keys** (admin session) — `POST|GET /workspaces/:workspaceId/api-keys`, `POST /api-keys/:id/revoke`, `POST /api-keys/:id/rotate` ### Authentication - API key authentication via `Authorization: Bearer ` - JWT bearer tokens for session-based access - OTP verification for signer identity ### Rate Limits - Free: 100 requests/day - Personal: 1,000 requests/day - Business: 10,000 requests/day ### Contract Lifecycle Contracts follow the status flow: `draft` → `pending` → `in_signing` → `completed` (or `voided` / `expired`).