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.

Your system
API
  1. POST with Idempotency-Key: K

    One key per business operation, for the first attempt and every retry of that same operation.

  2. Executes and stores the response

  3. The retry, within 24 h

  4. Same key K, same body

  5. The stored response, Idempotent-Replay: true, nothing runs again

    Also when the first attempt failed after the write was committed: that error is stored and replayed.

  6. Same key K, different body

  7. 422 idempotency_key_reused, the handler never runs

  8. Same key K while the first attempt is still running

  9. 409 conflict (errors[0].code: in_progress): wait and retry

  10. After 24 h the key expires: it is a new request.

Idempotency: what each retry gets

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.

SituationWhat Filmo doesWhat you do
Same key, same body, within 24 hoursReturns 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 bodyAnswers 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 runningAnswers 409 conflict, with in_progress in errors[0].code.Wait and retry with the same key. Do not send a second attempt in parallel.
The first attempt failed before writing anythingReleases 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 laterThe 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.

Only 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/v1/availability/check and POST /api/v1/ai/ask: 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.

OperationScope
POST /api/v1/contactscontacts:write
PATCH /api/v1/contacts/{contactId}contacts:write
DELETE /api/v1/contacts/{contactId}contacts:write
PATCH /api/v1/contacts/{contactId}/preferencescontacts:write
POST /api/v1/contacts/{contactId}/addressescontacts:write
PATCH /api/v1/contacts/{contactId}/addresses/{addressId}contacts:write
DELETE /api/v1/contacts/{contactId}/addresses/{addressId}contacts:write
POST /api/v1/contacts/{contactId}/related-contactscontacts:write
PATCH /api/v1/contacts/{contactId}/related-contacts/{relatedContactId}contacts:write
DELETE /api/v1/contacts/{contactId}/related-contacts/{relatedContactId}contacts:write
POST /api/v1/itemsitems:write
PATCH /api/v1/items/{itemId}items:write
DELETE /api/v1/items/{itemId}items:write
PATCH /api/v1/items/{itemId}/physical-propertiesitems:write
PATCH /api/v1/items/{itemId}/pricingitems:write
POST /api/v1/ordersorders:write
PATCH /api/v1/orders/{orderId}orders:write
POST /api/v1/projectsprojects:write
PUT /api/v1/projects/{projectId}/archiveprojects:write
PATCH /api/v1/projects/{projectId}projects:write
PATCH /api/v1/serialized-items/{serializedItemId}items:write
PATCH /api/v1/spaces/{spaceId}spaces:write
DELETE /api/v1/spaces/{spaceId}spaces:write
PUT /api/v1/spaces/{spaceId}/amenitiesspaces:write
PATCH /api/v1/spaces/{spaceId}/pricingspaces:write
POST /api/v1/repair-ticketsrepair-tickets:write
PATCH /api/v1/repair-tickets/{ticketId}repair-tickets:write
POST /api/v1/vehiclesvehicles:write
PATCH /api/v1/vehicles/{vehicleId}vehicles:write
DELETE /api/v1/vehicles/{vehicleId}vehicles:write
POST /api/v1/staffstaff:write
PATCH /api/v1/staff/{staffId}staff:write
DELETE /api/v1/staff/{staffId}staff:write
PATCH /api/v1/subrents/{subrentId}subrents:write
PATCH /api/v1/purchase-orders/{purchaseOrderId}purchase-orders:write
POST /api/v1/tagstags:write
DELETE /api/v1/tags/{tagId}tags:write
POST /api/v1/tags/{tagId}/assignmentstags:write
DELETE /api/v1/tags/{tagId}/assignmentstags:write
POST /api/v1/kitskits:write
PATCH /api/v1/kits/{kitId}kits:write
POST /api/v1/collectionscollections:write
PATCH /api/v1/collections/{collectionId}collections:write
POST /api/v1/servicesservices:write
PATCH /api/v1/services/{serviceId}services:write
POST /api/v1/internal-servicesinternal-services:write
PATCH /api/v1/internal-services/{internalServiceId}internal-services:write
POST /api/v1/planner/assignmentsplanner:write
POST /api/v1/planner/assignments/removeplanner:write
POST /api/v1/availability/unavailabilitiesavailability:write
PATCH /api/v1/availability/unavailabilities/{unavailabilityId}availability:write
DELETE /api/v1/availability/unavailabilities/{unavailabilityId}availability:write
POST /api/v1/incidentsincidents:write
PATCH /api/v1/incidents/{incidentId}incidents:write
POST /api/v1/incidents/{incidentId}/commentsincidents:write
POST /api/v1/notesnotes:write
POST /api/v1/taskstasks:write
PATCH /api/v1/tasks/{taskId}tasks:write
DELETE /api/v1/tasks/{taskId}tasks:write
POST /api/v1/crm/opportunitiescrm:write
PATCH /api/v1/crm/opportunities/{opportunityId}crm:write
PATCH /api/v1/crm/opportunities/{opportunityId}/stagecrm:write
POST /api/v1/crm/opportunities/{opportunityId}/closecrm:write
POST /api/v1/crm/opportunities/{opportunityId}/linkscrm:write
POST /api/v1/purchase-orders/{purchaseOrderId}/approvepurchase-orders:write
POST /api/v1/purchase-orders/{purchaseOrderId}/receivepurchase-orders:write
POST /api/v1/treasury/paymentstreasury:write
PATCH /api/v1/treasury/payments/{paymentId}treasury:write
POST /api/v1/treasury/payments/{paymentId}/linkstreasury:write
POST /api/v1/orders/{orderId}/cancelorders:write
PUT /api/v1/orders/{orderId}/archiveorders:write
PUT /api/v1/items/{itemId}/accessoriesitems:write
PUT /api/v1/items/{itemId}/alternativesitems:write
PATCH /api/v1/items/{itemId}/variants/{variantId}items:write
POST /api/v1/orders/line-items/returnsorders:write
POST /api/v1/workflows/{workflowId}/runsworkflows:run
POST /api/v1/orders/{orderId}/reserveorders:write
POST /api/v1/orders/{orderId}/unreserveorders:write
POST /api/v1/projects/{projectId}/orders/{orderId}projects:write
DELETE /api/v1/projects/{projectId}/orders/{orderId}projects:write
POST /api/v1/invoices/{invoiceId}/validateinvoices:write
POST /api/v1/invoices/{invoiceId}/voidinvoices:write
PUT /api/v1/invoices/external/{originSystem}/{originExternalId}invoices:write
POST /api/v1/credit-notes/{creditNoteId}/validateinvoices:write
POST /api/v1/credit-notes/{creditNoteId}/voidinvoices:write
PATCH /api/v1/proformas/{proformaId}/statusinvoices:write
PATCH /api/v1/quotes/{quoteId}/statusquotes:write
POST /api/v1/contracts/{contractId}/validatecontracts:write
POST /api/v1/contracts/{contractId}/signcontracts:write
POST /api/v1/contracts/{contractId}/voidcontracts:write
POST /api/v1/contracts/{contractId}/cancelcontracts:write
POST /api/v1/supplier-invoices/{supplierInvoiceId}/validatesupplier-invoices:write
POST /api/v1/supplier-invoices/{supplierInvoiceId}/voidsupplier-invoices:write

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.

For 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.

OperationWhat a blind retry duplicates
POST /api/v1/orders/line-items/returnsThe return: the same material goes back into the pool twice.
POST /api/v1/purchase-orders/{purchaseOrderId}/receiveThe stock: newStockReceived registers new inventory each time.
POST /api/v1/treasury/paymentsThe payment: it is recorded twice.
POST /api/v1/workflows/{workflowId}/runsThe run: a second run is enqueued instead of returning the first.
  • 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.

This 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 ;;
esac

A 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.

One 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 conflict with in_progress in errors[0].code: your key is still running. Wait and retry with the same key.
  • 409 conflict with lock_timeout: another conditional write held the resource for more than five seconds. Retry with backoff.
  • 409 conflict with invalid_state: the state of the resource forbids the operation. Do not retry; read the resource.
  • 500 internal_error with Idempotent-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.

Write 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.idempotencyKey, 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.idempotentReplay: true 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.

  • 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

    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 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.

  • 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.