Guides
Idempotency and safe retries
Send an Idempotency-Key header with every write and reuse the same value when you retry that operation: Filmo returns the stored result of the first attempt instead of writing twice.
A network timeout does not tell you whether the request arrived. Retrying a create blindly can make the record twice; not retrying can leave it never created. You choose the key, so you can retry as often as you need and still get exactly one effect.
How the key works
Link to this section: How the key worksPOSTwithIdempotency-Key: KOne key per business operation, for the first attempt and every retry of that same operation.
Executes and stores the response
The retry, within 24 h
Same key
K, same bodyThe stored response,
Idempotent-Replay: true, nothing runs againAlso when the first attempt failed after the write was committed: that error is stored and replayed.
Same key
K, different body422 idempotency_key_reused, the handler never runsSame key
Kwhile the first attempt is still running409 conflict(errors[0].:code in_progress): wait and retryAfter 24 h the key expires: it is a new request.
Put one value in the Idempotency-Key header (up to 255 characters; a UUID works) on the first attempt and on every retry of that same operation. A key identifies one operation, never a batch of them.
| Situation | What Filmo does | What you do |
|---|---|---|
| Same key, same body, within 24 hours | Returns the stored response of the first attempt without running anything again, with the Idempotent-Replay: true header. | Treat it as the result of the operation. This is the response you were after. |
| Same key, different body | Answers 422 idempotency_key_reused. The handler never runs. The method, the path and the raw body all belong to the fingerprint of the first request, so reordering JSON keys or sending the same key to another path also answers 422. | Fix the client: reusing a key for something else is almost always a bug. Use a new key only for a genuinely different operation. |
| Same key while the first attempt is still running | Answers 409 conflict, with in_progress in errors[0].. | Wait and retry with the same key. Do not send a second attempt in parallel. |
| The first attempt failed before writing anything | Releases the key. The retry runs again. | Retry with the same key. |
| The first attempt failed after the write was committed (the response could not be built) | Stores that error and replays it like any other response. The retry does not write twice. | Stop retrying and read the resource to see where it stands. |
| More than 24 hours later | The key has expired and the request is new. | If you need a guarantee beyond 24 hours, use a different key and check on your side first. |
Which operations accept it
Link to this section: Which operations accept itOnly operations that write: 93 of them, covering every create, update, delete and action. Reads ignore the header. So do the query-by-body POSTs, such as POST /api and POST /api: they are POST because the question does not fit in a query string, not because they change anything. Each operation that accepts the key declares the header in its own contract, so a generated client already knows about it.
A replayed response is not a second execution, so it emits no events: a repeated Idempotency-Key does not start the account's workflows again. The key does not make every operation a no-op, though. Do not rely on a repeated archive being a no-op: archiving an order or a project that is already archived, under a new key, moves its updatedAt again and fires the archive event again, even though the operation's description promises no additional effect. Check the state before you archive twice.
Four operations that need your own key
Link to this section: Four operations that need your own keyFor most writes a retry without a key is a nuisance. For these four it duplicates something that cannot be undone, so always send a key you generated and persisted, and resend the same one if you retry.
| Operation | What a blind retry duplicates |
|---|---|
POST /api | The return: the same material goes back into the pool twice. |
POST /api | The stock: newStockReceived registers new inventory each time. |
POST /api | The payment: it is recorded twice. |
POST /api | The run: a second run is enqueued instead of returning the first. |
Generate and store keys
Link to this section: Generate and store keys- Generate one UUID per business operation (for example, one per record you are about to create), not one per HTTP attempt.
- Store the key with your own record before the first attempt. If your process crashes between the request and the response, the next run must reuse the same key.
- Reuse the key on every retry of that operation, with exactly the same body. A retry with an edited body is a 422.
- Never generate the key inside the retry loop.
- Remember the 24-hour window. A queue that may retry after a day should read the resource first instead of relying on the key.
Create a record with safe retries
Link to this section: Create a record with safe retriesThis example creates a contact and retries on network failures, 409, 429, 500 and 502, 503 or 504 (a gateway failure without a problem document, treated like a 500) with the same key, waits for Retry-After when the response carries it, and stops when a 500 comes back marked Idempotent-Replay: true or a 409 reports invalid_state.
BASE="${FILMO_BASE_URL:-https://app.filmo.io}"
KEY="$(uuidgen)" # store this with your own record BEFORE the first attempt
for attempt in 1 2 3 4; do
rm -f headers.txt
status=$(curl -sS -o response.json -D headers.txt -w '%{http_code}' \
-X POST "$BASE/api/v1/contacts" \
-H "Authorization: Bearer $FILMO_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-d '{"name":"Acme Rentals","contactType":"company","role":"client","email":"hello@example.com"}') || status=000
case "$status" in
2??) break ;;
500)
if grep -qi '^idempotent-replay: true' headers.txt; then
echo "Stored 500: stop and read the resource before doing anything else" >&2
exit 1
fi ;;
409)
# in_progress or lock_timeout: wait and retry. invalid_state never clears by waiting.
if grep -q '"invalid_state"' response.json; then
echo "Conflict with the state of the resource: read it before doing anything else" >&2
cat response.json >&2
exit 1
fi ;;
000|429|502|503|504) ;; # 502, 503, 504 without a problem document: treat like a 500, same key
*) echo "Not retryable: HTTP $status" >&2; cat response.json >&2; exit 1 ;;
esac
wait=$(grep -i '^retry-after:' headers.txt 2>/dev/null | tr -dc '0-9')
sleep "${wait:-$((2 ** attempt))}"
done
case "$status" in
2??) cat response.json ;;
*) echo "Gave up (last status $status): read the resource before retrying" >&2; exit 1 ;;
esacimport { randomUUID } from "node:crypto";
const base = process.env.FILMO_BASE_URL ?? "https://app.filmo.io";
const sleep = (seconds) => new Promise((resolve) => setTimeout(resolve, seconds * 1000));
// `key` is one UUID per business operation, stored with your own record
// before the first attempt and reused on every retry of that operation.
export async function createContact(body, key) {
for (let attempt = 0; attempt < 4; attempt++) {
let res;
try {
res = await fetch(base + "/api/v1/contacts", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.FILMO_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": key,
},
body: JSON.stringify(body),
});
} catch {
await sleep(2 ** attempt); // network failure: retry with the SAME key
continue;
}
if (res.ok) {
const replayed = res.headers.get("Idempotent-Replay") === "true";
return { contact: (await res.json()).data, replayed };
}
if (res.status === 500 && res.headers.get("Idempotent-Replay") === "true") {
throw new Error("Stored 500: stop and read the resource before doing anything else");
}
if (res.status === 409) {
const problem = await res.json();
// "in_progress" and "lock_timeout" clear by waiting; "invalid_state" never does.
if (problem.errors?.[0]?.code === "invalid_state") {
throw new Error("Conflict with the state of the resource: " + problem.detail);
}
} else if (![429, 500, 502, 503, 504].includes(res.status)) {
throw new Error("Not retryable: HTTP " + res.status + " " + (await res.text()));
}
await sleep(Number(res.headers.get("Retry-After")) || 2 ** attempt);
}
throw new Error("Gave up: read the resource before retrying");
}
const { contact, replayed } = await createContact(
{ name: "Acme Rentals", contactType: "company", role: "client", email: "hello@example.com" },
randomUUID(),
);
console.log(contact.id, replayed ? "(replayed)" : "(created)");import os
import time
import uuid
import requests
BASE = os.environ.get("FILMO_BASE_URL", "https://app.filmo.io")
AUTH = {"Authorization": f"Bearer {os.environ['FILMO_API_KEY']}"}
def create_contact(body, key):
"""key: one UUID per business operation, stored before the first attempt."""
for attempt in range(4):
try:
res = requests.post(
BASE + "/api/v1/contacts",
json=body,
headers={**AUTH, "Idempotency-Key": key},
timeout=30,
)
except requests.RequestException:
time.sleep(2**attempt) # network failure: retry with the SAME key
continue
if res.ok:
return res.json()["data"], res.headers.get("Idempotent-Replay") == "true"
if res.status_code == 500 and res.headers.get("Idempotent-Replay") == "true":
raise RuntimeError("Stored 500: stop and read the resource before doing anything else")
if res.status_code == 409:
# "in_progress" and "lock_timeout" clear by waiting; "invalid_state" never does.
cause = ((res.json().get("errors") or [{}])[0]).get("code")
if cause == "invalid_state":
raise RuntimeError("Conflict with the state of the resource: " + res.json()["detail"])
elif res.status_code not in (429, 500, 502, 503, 504):
raise RuntimeError(f"Not retryable: HTTP {res.status_code} {res.text}")
time.sleep(float(res.headers.get("Retry-After", 2**attempt)))
raise RuntimeError("Gave up: read the resource before retrying")
contact, replayed = create_contact(
{"name": "Acme Rentals", "contactType": "company", "role": "client", "email": "hello@example.com"},
str(uuid.uuid4()),
)
print(contact["id"], "(replayed)" if replayed else "(created)")The 500 that is replayed for 24 hours
Link to this section: The 500 that is replayed for 24 hoursA 500 can happen before the write or after it. If it happened after the write was committed, Filmo stores that 500 and replays it for 24 hours for that key. Retrying with exponential backoff then returns the same 500 every time, and the record already exists.
The signal is the Idempotent-Replay: true header on a 500. When you see it, stop retrying and read the resource. If a retry of a 500 comes back without that header, the first attempt failed before writing and the retry has run for real.
To read the resource after a failed create, look for it with a list filtered by a creation window or by your own reference, or read it by id if you already have one. Never create it again with a new key before you have looked.
A 500 on a write without a key
Without an Idempotency-Key, nothing protects a retried write from creating a duplicate. Read before you retry.
Retry decision table
Link to this section: Retry decision tableOne rule per status code is not enough: the same 409 can mean wait and retry or never retry, and a 500 can repeat for 24 hours. The decision table, with the reference retry helper, is in Errors. For the cases that depend on the key, in short:
- 409
conflictwithin_progressinerrors[0].: your key is still running. Wait and retry with the same key.code - 409
conflictwithlock_timeout: another conditional write held the resource for more than five seconds. Retry with backoff. - 409
conflictwithinvalid_state: the state of the resource forbids the operation. Do not retry; read the resource. - 500
internal_errorwithIdempotent-Replay: true: stop and read the resource. - 422
idempotency_key_reused: a client bug. Use a new key only for a genuinely different operation.
Reads need no key. Retry them freely, within the limits described in the rate limits guide.
Over MCP
Link to this section: Over MCPWrite tools take an idempotencyKey argument with the same rules. If you omit it, nothing protects a retry, except on four tools where the server generates one and returns it in meta., on success and on error; resend that value when you retry. Even then it cannot protect a call whose response never reached you, so for filmo_orders_line_items_return, filmo_purchase_orders_receive, filmo_treasury_payments_create and filmo_workflows_run (the four that generate one) always send your own UUID, and send one on every other write tool too.
Where REST sends Idempotent-Replay: true, a tool result carries meta. when it is the stored response of an earlier call with the same key. If a write fails with internal_error and the result is a replay, stop and read the resource, as with a stored 500 over REST.
Related pages
Link to this section: Related pagesGuides
Errors
Errors are RFC 9457 problem documents. Branch on the stable
code, never on the text, and use the retry table to decide whether to try again.Guides
Rate limits
Each API key has 120 requests per minute, and the three availability checks share their own quota of 30 per minute. Past a limit the API answers 429 with
Retry-After.Guides
Concurrency and optimistic locking
Every detail read returns an
ETag. Send it back in theIf-Matchheader of a write and Filmo rejects the write with 412 if the record changed since you read it, so two integrations cannot silently overwrite each other.Guides
Going live checklist
Go through these eight checks before your integration runs against production data. Each one points to the guide that explains it.
Was this page helpful?