Skip to main content

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 Location and ETag. The mutation does not run again, and no duplicate notifications are sent.
  • Same key + different body409 with code idempotency_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

  1. 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.
  2. Never reuse a key for a different body. You will get 409 idempotency_conflict and it means your key management is broken.
  3. Do not encode meaning in keys. They are compared verbatim, nothing more. crypto.randomUUID() / uuid.uuid4() is ideal.
  4. Do not rely on replay beyond 24 hours. Reconcile long-lived pending operations by querying the current state instead.