Idempotency
Idempotency-Key header with 24h TTL. Retry POST without duplicating resources.
Write operations (POST, PATCH, DELETE) can be received multiple
times if the connection drops mid-response, your integration retries
after a timeout, or there are automatic retries in an intermediate
gateway. To prevent the same POST from creating two invoices, the API uses the
Idempotency-Key header.
Idempotency-Key is required on operations whose repetition can deliver
an external, paid or irreversible effect. Those operations mark the header
as required: true and document 422 idempotency_key_required in OpenAPI.
It remains optional, but recommended, on reversible internal writes.
How it works
-
The client generates a unique key per operation (a UUID v7 is the recommended choice, for consistency with the API identifiers).
-
Send it as a header on the first request:
POST /v1/invoices Idempotency-Key: 01928f10-7c0e-7c4a-9b7d-2f8a6e3c1d4b -
The API stores the result (status code, headers and body) associated with that key for 24 hours.
-
If a new request arrives with the same key within the TTL, the API returns the cached response without re-executing the handler.
The response returned on a replay includes the Idempotent-Replayed: true
header so you can distinguish it.
Key format
- An opaque string to the server: any unique value is valid (UUID v7, UUID v4, ULID, nanoid, etc.).
- Length between 1 and 255 characters.
- Recommendation: UUID v7 (
Str::uuid7(), or any UUID v7 generator), for consistency with the API identifiers.
KEY=$(uuidgen)
curl -X POST https://api.factuarea.com/v1/invoices \
-H "Authorization: Bearer $FACTUAREA_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-d '{
"client_id": "01931b3e-7c4a-7f2e-9a8b-3c5d6e7f8a01",
"series_id": "01931b3e-7c4a-7f2e-9a8b-3c5d6e7f8a02",
"issued_on": "2026-05-15",
"due_on": "2026-06-15",
"lines": [
{ "description": "Monthly service", "quantity": 1, "unit_price": 99.00, "tax_rate_id": "01931b3e-7c4a-7f2e-9a8b-3c5d6e7f8a03" }
]
}'Automatic with the official SDKs
The TypeScript and PHP SDKs attach an Idempotency-Key to
every mutation automatically and reuse the same key across the retries of
one call, so a retried request never double-creates. Override it per call
when you want app-level deduplication:
// auto-generated key
await factuarea.invoices.create(body);
// pin your own key (e.g. your order id)
await factuarea.invoices.create(body, { idempotencyKey: "order-4711" });// auto-generated key
$factuarea->invoices->publicApiV1InvoicesCreate($body);
// pin your own key
$factuarea->invoices->publicApiV1InvoicesCreate($body, idempotencyKey: 'order-4711');Payload fingerprint
The key is bound not only to the Idempotency-Key but also to a
fingerprint of the request:
fingerprint = sha256(method + " " + path + "\n" + canonicalize(body))Where canonicalize(body) is JSON with keys sorted alphabetically.
If you replay the same key with a different payload, the API responds 409 Conflict:
{
"error": {
"type": "idempotency_error",
"code": "idempotency_key_reused",
"message": "This Idempotency-Key was previously used with a different request body.",
"request_id": "req_..."
}
}This is protection against bugs: no reasonable caller changes the body while keeping the same key. If you need to retry with different data — use a new key.
TTL
Entries are persisted in the idempotency_keys table for
86,400 seconds (24 h). After that they are purged by a daily
schedule. If you reuse a key outside the window it's treated as a new
one.
external_id vs Idempotency-Key
Both protect you from duplicates, but they solve different problems — and you can use them together.
Idempotency-Key | external_id | |
|---|---|---|
| What it is | A header on a single POST. | A business key stored on the resource. |
| Lifetime | Ephemeral — 24h window, then purged. | Durable — permanent, never expires. |
| Scope | Deduplicates transport retries of one call. | Deduplicates by your own integration key (ERP/CRM id). |
| Queryable | No. | Yes — POST /v1/{resource}/find-by-external-id. |
Use the Idempotency-Key to make a retry safe: if the network drops
mid-response, replaying the same key within 24h returns the cached result
instead of creating a second invoice. It is about the delivery of one
request.
Use external_id to tie a Factuarea resource to a record in your own
system (an order id, an ERP document number). Send it in the create body
and the API enforces it is unique per company
(UNIQUE(company_id, external_id)). Later you can look the resource up by
that key, without storing Factuarea's id:
curl -s -X POST https://api.factuarea.com/v1/invoices/find-by-external-id \
-H "Authorization: Bearer $FACTUAREA_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "external_id": "ORDER-4711" }' | jq '.data.id'In short: Idempotency-Key is a short-lived retry guard; external_id is
your permanent, queryable link. A typical integration sets both — a
fresh key per attempt, and a stable external_id per business object.
Required vs recommended
The requirement is derived from the operation, not merely its HTTP method:
| Operation class | Header contract |
|---|---|
| Email, bulk, export, upload, paid AI/third-party, tax-report or billing writes | Required |
Any operation with x-irreversible: true | Required |
| Reversible internal writes, such as creating or editing a draft or catalog record | Optional, recommended |
Read-only POST operations such as find-by-*, calculations and previews | Not applicable |
GET operations | Not applicable |
Do not maintain this classification as a hard-coded list in your integration.
Read the operation's Idempotency-Key.required value from the
OpenAPI contract so new endpoints inherit the right behavior.
When a required operation is called without the header after its transition window closes, the API rejects it before business logic runs:
{
"error": {
"type": "invalid_request_error",
"code": "idempotency_key_required",
"param": "Idempotency-Key",
"message": "Esta operación exige la cabecera Idempotency-Key. Envía una clave única por operación (un UUID sirve) y, si reintentas, repite exactamente la misma clave."
}
}During an announced transition window the same request can still be accepted
and carries Deprecation: true; Sunset appears once an environment-specific
cut-off has been scheduled. Test and live credentials can have different
cut-offs so integrations can migrate in the sandbox first.
Stripe documents the same pattern — if you come from there, the contract is identical.
Safe retry example
import os, uuid, requests
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
def create_invoice(payload):
key = str(uuid.uuid4())
@retry(
stop=stop_after_attempt(5),
wait=wait_exponential(multiplier=1, max=10),
retry=retry_if_exception_type(requests.exceptions.RequestException),
)
def attempt():
return requests.post(
'https://api.factuarea.com/v1/invoices',
json=payload,
headers={
'Authorization': f"Bearer {os.environ['FACTUAREA_API_KEY']}",
'Idempotency-Key': key,
},
timeout=30,
)
return attempt()async function createInvoiceWithRetry(payload, maxAttempts = 5) {
const key = crypto.randomUUID();
let lastError;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const res = await fetch('https://api.factuarea.com/v1/invoices', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.FACTUAREA_API_KEY}`,
'Idempotency-Key': key,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
if (res.ok) return res.json();
if (res.status >= 500) {
await new Promise(r => setTimeout(r, 2 ** attempt * 100));
continue;
}
return res.json();
} catch (err) {
lastError = err;
await new Promise(r => setTimeout(r, 2 ** attempt * 100));
}
}
throw lastError;
}Important: the key must remain constant across retries of the
same operation. If you generate a new key on each retry you lose the
protection. In the Python tenacity example, the key is generated by the
outer function and captured by the retried attempt closure.
What idempotency is NOT
- Not the same as rate limiting: an idempotent key replayed within the TTL doesn't count against your quota; but different keys with the same payload do count, one by one.
- Not a substitute for a distributed lock on your side. If two workers concurrently create invoices with different keys, both will persist — generating the key correctly (e.g. derived from your own ID) is your responsibility.
- Doesn't affect the server's own
4xxresponses: if the first request responded422 invalid_request_error, that 422 is cached. Replaying the key returns the same 422 withIdempotent-Replayed: true.
Store diagnostics and MCP
Store connection-test POSTs and disconnect operations declare Idempotency-Key in the REST contract. Give each intended diagnostic a key and reuse it only for retries of that diagnostic; a later fresh check needs a new key. In MCP the argument is idempotency_key, and irreversible-tool policy may require it even when discovery marks the field optional. See the MCP protocol.