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-Keyheader 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:
GETthe single item and read theETagresponse header.- Send that value as
If-Matchon yourPATCH. - 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 # doneStatus 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":[...]}.