Idempotency
UCP clients pass an Idempotency-Key header on mutation operations so that retries — caused by network blips, timeouts, or any other transient failure — don’t duplicate the underlying effect.
How it works on UCP
When the UCP bridge receives a mutation with an Idempotency-Key header:
- The first time a given key is seen, the operation runs normally and the response is cached.
- A subsequent retry with the same key returns the original cached response instead of running the operation a second time.
- If the same key is reused with a different request body, the bridge returns
409with a UCPmessages[]envelope carryingcode: "idempotency_conflict"— a safety check that prevents accidentally reusing a key across different attempts.
Generating keys
One UUID per logical attempt. Reuse on retries; rotate when the attempt is genuinely new.
| Scenario | New key? |
|---|---|
| Network timeout — same body, retry | No — reuse the key |
| 5xx response — retry the same operation | No — reuse the key |
| User changed their mind — new attempt | Yes — generate a new key |
| Different cart / different card | Yes — generate a new key |
import { randomUUID } from 'crypto';const key = randomUUID(); // one key for this logical attempt// On the UCP bridge the Idempotency-Key header is honored today.// On core REST complete-order it is ignored for now — pair this with// the client-side dedup guard below until REST support lands.const completeOrder = () =>fetch(`${PAY_API}/api/v2/payment/domains/${domain}/complete-order`, {method: 'POST',headers: {'x-firmly-authorization': authToken,'Idempotency-Key': key,'Content-Type': 'application/json',},body: JSON.stringify(orderBody),});// Safe to retry on transient failure with the SAME keyfor (let attempt = 0; attempt < 3; attempt++) {const resp = await completeOrder();if (resp.ok) return resp.json();if (resp.status >= 500) {await sleep(1000 * Math.pow(2, attempt)); // 1s, 2s, 4s backoffcontinue;}const err = await resp.json();// On the UCP bridge a key reused with a different body returns a `messages[]`// envelope with code `idempotency_conflict`. Core REST doesn't deduplicate, so it// has no equivalent error — see "Conflict error format" below.throw new Error(`${err.error}: ${err.description}`);}
Key retention
Keys cache for 24 hours. After that, the same key counts as a new call. For a single user clicking “place order” once, 24 hours is more than enough. If your retry window legitimately spans more than a day, treat it as a new attempt and generate a new key.
Client-side dedup for REST
For destinations calling the core REST endpoints directly (not via the UCP bridge), retries currently re-run the operation. Guard against duplicates on your side:
- For
complete-order: if a request times out, re-fetch the cart before retrying. If the cart’scart_statusis alreadysubmitted, the order placed despite the timeout — don’t retry. - For cart mutations: keep a
last_known_cart_revisionand verify it before applying a retry, or accept the rare double-add and let the user remove duplicates. - For payment: never silently retry a declined payment with the same card — surface the decline to the user and offer alternatives.
Conflict error format
{"messages": [{"type": "error","code": "idempotency_conflict","content": "Idempotency key was used with a different request body","severity": "recoverable"}],"ucp": { "version": "2026-04-08" }}
If you see this in production, audit your retry logic — somewhere your code is generating the same UUID for two attempts that have different bodies. Common causes:
- Generating the key from a request hash instead of a fresh UUID per attempt
- Reusing a key across what should be different attempts (e.g., across user sessions)
- Caching a key in shared storage incorrectly
Related
- Errors & Conventions — the broader recovery catalog
- UCP overview
- Rate limits