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.
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.jsonUse 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 example400 VALIDATION_FAILEDor404): 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
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");
}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 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.