Guides
Concurrency and optimistic locking
Every detail read returns an ETag. Send it back in the If-Match header 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.
ETag and If-Match
Link to this section: ETag and If-MatchGET /api/v1 /<resource> /{id} 200with a strongETagPATCH/PUT/DELETE/ action withIf-Match: <ETag>The comparison and the write run under a per-resource lock.
200: nothing changed since you read itIf the resource changed in between
412 precondition_failed, before doing anything: read it again and retry409 conflict(lock_timeout) if the write waited more than 5 s for the lock: retryIf-Match: *only requires that the resource exists. Without the header the write always proceeds.
The ETag is a strong tag: a hash of the representation, so it changes whenever anything you can see in the record changes. The flow has four steps.
Read the record
Call the detail read (for example
GET /api) and keep the/v1 /contacts /{contactId} ETagresponse header.Change it in your code
Work out the new values from what you read.
Write with If-Match
Send the PATCH, PUT, DELETE or action (such as
POST /api) with/v1 /orders /{orderId} /reserve If-Match: <the ETag you read>.Handle the answer
On success, an operation that returns the record gives you its new
ETagin the response. On 412precondition_failed, nothing was written: read the record again, redo the change and retry.
The comparison and the write run under a per-resource lock, so two concurrent writes carrying the same tag end 200 and 412, exactly as if they had arrived one after the other. A write that waits more than five seconds for that lock is answered 409 conflict with lock_timeout in errors[0]., and can be retried.
- Without
If-Matchthe write always proceeds: the last write wins. If-Match: *only requires that the resource exists. It does not protect against concurrent changes.- Expansions you request on a read, such as the stage history of an opportunity, are not part of the tag. The tag of an expanded read still matches.
BASE="${FILMO_BASE_URL:-https://app.filmo.io}"
# CONTACT_ID: the id of a contact in your account
# 1. Read the contact and keep its ETag.
ETAG=$(curl -sS -D - -o contact.json "$BASE/api/v1/contacts/$CONTACT_ID" \
-H "Authorization: Bearer $FILMO_API_KEY" | awk 'tolower($1)=="etag:" {print $2}' | tr -d '\r')
# 2. Write with If-Match. HTTP 412 means someone changed the contact since step 1.
curl -sS -w '\nHTTP %{http_code}\n' -X PATCH "$BASE/api/v1/contacts/$CONTACT_ID" \
-H "Authorization: Bearer $FILMO_API_KEY" \
-H "Content-Type: application/json" \
-H "If-Match: $ETAG" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"position":"Rental manager"}'Which operations accept If-Match
Link to this section: Which operations accept If-MatchWrites on a resource that has a detail read accept it. The table lists every operation that declares the header.
Writes on resources that have no detail read yet do not accept If-Match: tags, notes, unavailabilities, collections, planner assignments and workflow runs. Neither does the line-item returns batch, because it spans several orders. On a create (a POST to a collection) the header is not a precondition: only If-Match: * passes, and any other value answers 412. For those, protect against duplicates with an idempotency key and read before you write.
What each answer means
Link to this section: What each answer means| Response | Meaning | What to do |
|---|---|---|
412 precondition_failed | The ETag you sent no longer matches. Nothing was written. | Read the record again, redo the change on the new version and retry. |
409 conflict | errors[0]. says why. lock_timeout: the write waited more than five seconds for the per-resource lock. in_progress: a request with the same Idempotency-Key is still running. invalid_state: the state of the record forbids the operation. | Retry lock_timeout and in_progress with backoff. For invalid_state, read the record and check its state; waiting does not help. See the retry decisions in the Errors guide. |
404 not_found | The record does not exist or is outside your key's tenancy. | Check the id. |
A read-modify-write loop
Link to this section: A read-modify-write loopLists in a PATCH body often replace the whole list. This loop adds a phone number to a contact with PATCH /api: it reads the contact, appends the number, writes with If-Match, and starts again on 412 or on a 409 lock_timeout. Set CONTACT_ID to a contact in your account.
import { randomUUID } from "node:crypto";
const base = process.env.FILMO_BASE_URL ?? "https://app.filmo.io";
const auth = { Authorization: `Bearer ${process.env.FILMO_API_KEY}` };
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// Adds a phone number without overwriting a change someone else made in between.
export async function addPhone(contactId, number) {
const url = base + "/api/v1/contacts/" + contactId;
for (let attempt = 0; attempt < 5; attempt++) {
const read = await fetch(url, { headers: auth });
if (!read.ok) throw new Error("Read failed: HTTP " + read.status);
const etag = read.headers.get("ETag");
const { data } = await read.json();
if (data.phoneNumbers.some((p) => p.number === number)) return data;
// phoneNumbers replaces the whole list, so send the existing entries back too.
const phoneNumbers = data.phoneNumbers.map((p) => ({
number: p.number,
isPrimary: p.isPrimary,
...(p.label ? { label: p.label } : {}),
...(p.countryCode ? { countryCode: p.countryCode } : {}),
}));
phoneNumbers.push({ number, isPrimary: false });
const write = await fetch(url, {
method: "PATCH",
headers: {
...auth,
"Content-Type": "application/json",
"If-Match": etag,
// A new key per attempt: each attempt sends a different body.
"Idempotency-Key": randomUUID(),
},
body: JSON.stringify({ phoneNumbers }),
});
if (write.ok) return (await write.json()).data;
const failure = await write.text();
const cause = write.status === 409 ? JSON.parse(failure).errors?.[0]?.code : null;
// 412: the contact changed since the read. 409 "lock_timeout": another write held the lock for 5 seconds.
if (write.status === 412 || cause === "lock_timeout") {
await sleep(100 * 2 ** attempt);
continue;
}
throw new Error("Write failed: HTTP " + write.status + " " + failure);
}
throw new Error("Too many concurrent changes; try again later");
}
const contact = await addPhone(process.env.CONTACT_ID, "+34 600 000 000");
console.log(contact.phoneNumbers.length, "phone numbers");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 add_phone(contact_id, number):
"""Adds a phone number without overwriting a change made in between."""
url = BASE + "/api/v1/contacts/" + contact_id
for attempt in range(5):
read = requests.get(url, headers=AUTH, timeout=30)
read.raise_for_status()
etag = read.headers["ETag"]
data = read.json()["data"]
if any(p["number"] == number for p in data["phoneNumbers"]):
return data
# phoneNumbers replaces the whole list, so send the existing entries back too.
phone_numbers = []
for p in data["phoneNumbers"]:
entry = {"number": p["number"], "isPrimary": p["isPrimary"]}
if p.get("label"):
entry["label"] = p["label"]
if p.get("countryCode"):
entry["countryCode"] = p["countryCode"]
phone_numbers.append(entry)
phone_numbers.append({"number": number, "isPrimary": False})
write = requests.patch(
url,
json={"phoneNumbers": phone_numbers},
headers={
**AUTH,
"If-Match": etag,
# A new key per attempt: each attempt sends a different body.
"Idempotency-Key": str(uuid.uuid4()),
},
timeout=30,
)
if write.ok:
return write.json()["data"]
# 412: the contact changed since the read. 409 "lock_timeout": another write held the lock for 5 seconds.
cause = None
if write.status_code == 409:
cause = ((write.json().get("errors") or [{}])[0]).get("code")
if write.status_code == 412 or cause == "lock_timeout":
time.sleep(0.1 * 2**attempt)
continue
raise RuntimeError(f"Write failed: HTTP {write.status_code} {write.text}")
raise RuntimeError("Too many concurrent changes; try again later")
contact = add_phone(os.environ["CONTACT_ID"], "+34 600 000 000")
print(len(contact["phoneNumbers"]), "phone numbers")Conditional reads
Link to this section: Conditional readsDetail reads, and GET /api, also accept If-None-Match. Send the ETag of the copy you already hold: if it is still current, the answer is 304 with no body and the ETag header, and your copy is current. If the record changed, you get the full 200 with its new ETag.
BASE="${FILMO_BASE_URL:-https://app.filmo.io}"
# CONTACT_ID: the id of a contact in your account
# ETAG: the ETag header of an earlier read of that contact, for example
ETAG=$(curl -sS -D - -o /dev/null "$BASE/api/v1/contacts/$CONTACT_ID" \
-H "Authorization: Bearer $FILMO_API_KEY" | awk 'tolower($1)=="etag:" {print $2}' | tr -d '\r')
# Send the ETag you already have. HTTP 304 means your copy is current.
curl -sS -o /dev/null -w 'HTTP %{http_code}\n' "$BASE/api/v1/contacts/$CONTACT_ID" \
-H "Authorization: Bearer $FILMO_API_KEY" \
-H "If-None-Match: $ETAG"A 304 saves bandwidth, not quota: plan on it counting against your limit, like the other responses of an authenticated key (see Rate limits).
Over MCP
Link to this section: Over MCPDetail tools return the tag in meta.. Pass it as ifMatch on a write to that resource to get the same 412 behaviour. Omit it to write unconditionally.
Related pages
Link to this section: Related pagesGuides
Idempotency and safe retries
Send an
Idempotency-Keyheader 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.Guides
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
Responses
Successful responses wrap their payload in
data; lists addpagination; a few operations addmeta; errors are RFC 9457 problems. API responses share one set of headers.
Was this page helpful?