Idempotency
Networks fail at the worst moment: you POST a card, the connection drops, and
you have no idea whether the card exists. The Kanban API solves this the
standard way — every mutating POST requires an Idempotency-Key header,
covering creation, move, archive, and restore operations.
Idempotency-Key: create-card-7f3a1c8e
A key is any string of 1–128 printable ASCII characters. Generate one per logical operation (a UUID is a fine choice), keep it with the pending operation, and reuse it verbatim on every retry of that operation.
The guarantee
For a given token, method, path, and key:
- Same key + same body → the stored response of the original successful
request is replayed: same status, same body, and the contract headers such
as
LocationandETag. The mutation does not run again, and no duplicate notifications are sent. - Same key + different body →
409with codeidempotency_conflict. The stored operation is not touched. This is a bug signal: two different operations shared a key. - Keys are scoped to the token and the exact method + path, and expire after 24 hours. After expiry the same key executes as a brand-new request.
Only successful responses are stored. Validation failures, authorization
failures, 412/428 precondition failures, 429, and 5xx are never
recorded — you may retry them with the same key after fixing the cause
or waiting, and the retry can still succeed as the first stored execution.
Replays are also access-checked: if your access was revoked (or the created
resource is gone) between the original call and the replay, you get the
current 401/403/404, not the stored body.
PUT, PATCH, and DELETE do not use idempotency keys — PUT/DELETE
relationship endpoints are naturally idempotent
(desired-state), and PATCH/DELETE
on versioned resources are guarded by
If-Match instead.
A safe retry loop
cURL (run it twice — the second run replays the original 201 instead of
creating a second board):
IDEMPOTENCY_KEY="create-board-$(date +%s)"
curl -sS -i -X POST "$KANBAN_API_BASE_URL/boards" \
-H "Authorization: Bearer $KANBAN_API_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $IDEMPOTENCY_KEY" \
-d '{"name": "Retry-safe board"}'
# Retry with the SAME key and SAME body: replays the original response.
curl -sS -i -X POST "$KANBAN_API_BASE_URL/boards" \
-H "Authorization: Bearer $KANBAN_API_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $IDEMPOTENCY_KEY" \
-d '{"name": "Retry-safe board"}'
JavaScript:
const crypto = require("node:crypto");
const baseUrl =
process.env.KANBAN_API_BASE_URL ?? "https://app.filetag.ai/api/kanban/v1";
const token = process.env.KANBAN_API_TOKEN;
async function createBoardWithRetry(body, attempts = 3) {
// One key for the whole logical operation, reused across retries.
const idempotencyKey = crypto.randomUUID();
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
const response = await fetch(`${baseUrl}/boards`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify(body),
});
const payload = await response.json();
if (response.ok) {
return payload.data;
}
if (response.status >= 500 || response.status === 429) {
// Retryable with the same key; fall through to the backoff below.
} else {
throw new Error(
`${payload.error.code}: ${payload.error.message} (request ${payload.error.request_id})`
);
}
} catch (error) {
if (error instanceof TypeError === false) {
throw error; // Application error, not a network failure.
}
// Network failure: the request may or may not have committed.
// Retrying with the same key is safe either way.
}
await new Promise((resolve) => setTimeout(resolve, 500 * 2 ** attempt));
}
throw new Error("create board did not succeed after retries");
}
createBoardWithRetry({ name: "Retry-safe board" })
.then((board) => console.log("board", board.id))
.catch((error) => {
console.error(error);
process.exit(1);
});
Python:
import json
import os
import time
import urllib.error
import urllib.request
import uuid
BASE_URL = os.environ.get(
"KANBAN_API_BASE_URL", "https://app.filetag.ai/api/kanban/v1"
)
TOKEN = os.environ["KANBAN_API_TOKEN"]
def create_board_with_retry(body, attempts=3):
# One key for the whole logical operation, reused across retries.
idempotency_key = str(uuid.uuid4())
for attempt in range(1, attempts + 1):
request = urllib.request.Request(
f"{BASE_URL}/boards",
data=json.dumps(body).encode("utf-8"),
method="POST",
headers={
"Authorization": f"Bearer {TOKEN}",
"Content-Type": "application/json",
"Idempotency-Key": idempotency_key,
},
)
try:
with urllib.request.urlopen(request) as response:
return json.load(response)["data"]
except urllib.error.HTTPError as exc:
if exc.code >= 500 or exc.code == 429:
pass # Retryable with the same key.
else:
error = json.load(exc)["error"]
raise RuntimeError(
f"{error['code']}: {error['message']} "
f"(request {error['request_id']})"
) from exc
except urllib.error.URLError:
# Network failure: the request may or may not have committed.
# Retrying with the same key is safe either way.
pass
time.sleep(0.5 * (2**attempt))
raise RuntimeError("create board did not succeed after retries")
board = create_board_with_retry({"name": "Retry-safe board"})
print("board", board["id"])
Practical rules
- One key per logical operation, not per HTTP attempt. Minting a fresh key on retry recreates the duplicate-write problem the header exists to prevent.
- Never reuse a key for a different body. You will get
409 idempotency_conflictand it means your key management is broken. - Do not encode meaning in keys. They are compared verbatim, nothing
more.
crypto.randomUUID()/uuid.uuid4()is ideal. - Do not rely on replay beyond 24 hours. Reconcile long-lived pending operations by querying the current state instead.