Idempotency & Optimistic Concurrency

Safe creates with Idempotency-Key; conflict-free updates with If-Match/ETag.

Idempotent creates (POST)

Every POST create requires an Idempotency-Key header (a client-generated UUID):

  • 201 — first call with that key creates the resource.
  • 200 — replay: same key + same body returns the original resource (no duplicate).
  • 422 — same key, different body (a key may not be reused for a different payload).
  • 400 — the Idempotency-Key header is missing.

Keys are retained for 24 hours.

cURL
curl -X POST https://app.canopymethod.ai/api/v1/issues \
  -H "Authorization: Bearer $CANOPY_TOKEN" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{"projectId":"prj_123","title":"Login broken"}'
Python
import uuid, requests
key = str(uuid.uuid4())
res = requests.post(
    f"{BASE}/issues",
    headers={**HEAD, "Idempotency-Key": key},
    json={"projectId": "prj_123", "title": "Login broken"},
)
# Safe to retry with the SAME key on a network failure — you get 201 once,
# then 200 with the same resource on replay.

Optimistic concurrency (PATCH)

To update a resource without clobbering a concurrent change:

  1. GET the single item and read the ETag response header.
  2. Send that value as If-Match on your PATCH.
  3. If the resource changed since, you get 409 (with the current ETag). Re-fetch and retry.
TypeScript — read ETag, PATCH with If-Match, retry on 409
async function patchIssue(id: string, patch: object) {
  for (let attempt = 0; attempt < 3; attempt++) {
    const get = await fetch(`${BASE}/issues/${id}`, { headers: HEAD });
    const etag = get.headers.get("ETag")!;
    const res = await fetch(`${BASE}/issues/${id}`, {
      method: "PATCH",
      headers: { ...HEAD, "If-Match": etag, "Content-Type": "application/json" },
      body: JSON.stringify(patch),
    });
    if (res.status !== 409) return res;   // success (or a non-conflict error)
    // 409: someone else updated it — loop refetches the new ETag and retries
  }
  throw new Error("too many conflicts");
}
Python — the same 409 retry loop
for _ in range(3):
    cur = requests.get(f"{BASE}/issues/{id}", headers=HEAD)
    etag = cur.headers["ETag"]
    res = requests.patch(
        f"{BASE}/issues/{id}",
        headers={**HEAD, "If-Match": etag},
        json={"severity": "critical"},
    )
    if res.status_code != 409:
        break  # done
Status transitions are separate. The status field is ignored on PATCH /issues/{id}. Use POST /issues/{id}/transition — an invalid pair returns 422 {"error":"invalid_transition","allowed":[...]}.