Idempotency

Safe retries for write operations via the Idempotency-Key header (§1.3). Eligible routes, TTL, storage, and retry semantics.

6 min read
idempotencyidempotency-keyretries

Network failures happen. Sending the same mutating request twice can create duplicate resources (two runs started, two keys generated, double-charged cost rates). CodeCourier's idempotency layer solves this: attach an Idempotency-Key header and the server guarantees a single side effect, even if the client retries.

How It Works

  1. Client generates a unique key per logical operation (UUIDv4 recommended) and sends it in the Idempotency-Key header.
  2. Server hashes the key + method + path + body; on first request it executes the handler and stores the full response.
  3. On any retry with the same key, the stored response is replayed byte-for-byte. No duplicate side effect.
  4. If a retry arrives with the same key but a different body, the server returns 409 idempotency_mismatch.

TTL

Idempotency records are retained for 24 hours. After that, the key is forgotten and replaying it executes a fresh request. Design retries to complete well within this window.

Eligible Routes

All POST mutation endpoints accept the header. Notable examples:

  • POST /api/v1/runs/start
  • POST /api/v1/workflows/create
  • POST /api/v1/issues/create
  • POST /api/v1/project/api-keys/generate
  • POST /api/v1/sandboxes/create
  • POST /api/v1/cost-rates/upsert
  • POST /api/v1/recurring-tasks/create

GET, HEAD, and DELETE are idempotent by definition; the header is ignored (never an error) for them.

PATCH endpoints

Only POST routes are formally documented as supporting the Idempotency-Key header. The single PATCH endpoint currently exposed is treated by the server as a write operation and will accept the header on a best-effort basis:

  • PATCH /api/v1/webhooks/endpoints/{id} - supported

For any future PATCH routes, assume idempotency is not guaranteed unless explicitly documented on that route. Clients integrating new PATCH endpoints should implement client-side deduplication (e.g. a request-in-flight cache keyed by resource id + body hash) until first-class support is confirmed.

Example

curl

IDEM=$(uuidgen)

curl -X POST https://<your-deployment>.convex.site/api/v1/runs/start \
  -H "Authorization: Bearer cc_live_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $IDEM" \
  -d '{ "workflowId": "wf_abc", "input": { "topic": "hello" } }'

TypeScript

import { randomUUID } from "node:crypto";

async function startRun(workflowId: string, input: unknown) {
  const idempotencyKey = randomUUID();
  const attempt = async () =>
    fetch("https://<your-deployment>.convex.site/api/v1/runs/start", {
      method: "POST",
      headers: {
        "Authorization": `Bearer ${process.env.CC_KEY}`,
        "Content-Type": "application/json",
        "Idempotency-Key": idempotencyKey,
      },
      body: JSON.stringify({ workflowId, input }),
    });

  // Retry up to 3x with exponential backoff - safe because of Idempotency-Key
  for (let i = 0; i < 3; i++) {
    const res = await attempt();
    if (res.ok) return res.json();
    if (res.status < 500) throw new Error(await res.text());
    await new Promise((r) => setTimeout(r, 2 ** i * 500));
  }
  throw new Error("exhausted retries");
}

Python

import os, uuid, time, requests

def start_run(workflow_id: str, input_payload: dict):
    idem = str(uuid.uuid4())
    headers = {
        "Authorization": f"Bearer {os.environ['CC_KEY']}",
        "Content-Type": "application/json",
        "Idempotency-Key": idem,
    }
    body = {"workflowId": workflow_id, "input": input_payload}

    for i in range(3):
        r = requests.post(
            "https://<your-deployment>.convex.site/api/v1/runs/start",
            headers=headers, json=body, timeout=30,
        )
        if r.ok:
            return r.json()
        if r.status_code < 500:
            r.raise_for_status()
        time.sleep(2 ** i * 0.5)
    raise RuntimeError("exhausted retries")

Retry Semantics

  • Same key + same body → replay stored response (200 OK if original succeeded, original error otherwise).
  • Same key + different body 409 idempotency_mismatch. Generate a new key for the new operation.
  • In-flight duplicate(two retries racing) → later request blocks briefly, then replays the first's response.

Best Practices

  • Generate the key before the first attempt; reuse it for all retries of that logical operation.
  • Use UUIDv4 or a ULID. Never derive the key from mutable request state.
  • Do not reuse keys across logically different operations.
  • Log the key alongside the response for trace correlation.

Related