eContract Developers

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.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/contractsCreate a draft contract
POST /api/v1/contracts/sendCreate and send (JSON or multipart)
POST /api/v1/contracts/{id}/submitSubmit for signing
POST /api/v1/contracts/{id}/voidVoid
POST /api/v1/contracts/{id}/resend-signing-requestEmail pending signers again
POST /api/v1/contracts/{id}/signing-linksIssue signing links
POST /api/v1/contracts/{contractId}/signersAdd a signer
POST /api/v1/contract-templates/{id}/generateGenerate 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.

SituationAnswer
First request with this keyRuns normally; the result is stored
Same key, same request, first one finishedThe stored status and body are replayed with the header Idempotent-Replayed: true. Nothing runs again
Same key, same request, first one still running409 IDEMPOTENCY_IN_PROGRESS: retry in a few seconds
Same key, different request422 IDEMPOTENCY_KEY_REUSED
Key empty, longer than 255 characters or not visible ASCII400 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

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.

On this page