Docs
Firmly Agentic Commerce
Set theme to dark (⇧+D)

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:

  1. The first time a given key is seen, the operation runs normally and the response is cached.
  2. A subsequent retry with the same key returns the original cached response instead of running the operation a second time.
  3. If the same key is reused with a different request body, the bridge returns 409 with a UCP messages[] envelope carrying code: "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 key
for (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 backoff
continue;
}
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’s cart_status is already submitted, the order placed despite the timeout — don’t retry.
  • For cart mutations: keep a last_known_cart_revision and 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