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/companies/{company}/invoices Idempotency-Key: 01928f10-7c0e-7c4a-9b7d-2f8a6e3c1d4bAll the routes in this guide hang off
/v1/companies/{company}/…:{company}is theid(UUID v7) of the tax ID you operate on, and in thecurlexamples it is$COMPANY_ID. Reach a tax ID explains how to resolve it. -
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/companies/$COMPANY_ID/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 422 Unprocessable Entity:
{
"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/companies/{company}/{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/companies/$COMPANY_ID/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.
Unattended checkout: external_id is the retry guard
A terminal that issues a paid simplified invoice (a POST /v1/companies/{company}/invoices with
type: F2, a payment block or options.register_verifactu; see
Unattended checkout invoicing) may need to retry
days after the first attempt, long after a 24-hour key has been purged. For
that flow, external_id does the job of an idempotency key and never expires.
Only requests that are checkouts behave this way; any other POST /v1/companies/{company}/invoices
keeps the upsert by external_id described above.
When the external_id already belongs to an invoice issued in your company, the
request is a replay:
| Situation | Answer |
|---|---|
Same external_id, same type and total | 200 with Idempotent-Replayed: true and the invoice already issued. Before answering, the API completes what was missing (issue a draft, register the payment, generate the VeriFactu alta). It creates nothing new, consumes no number and does not email again an invoice whose email is already queued or delivered. |
Same external_id, different type or total | 409 unattended_replay_mismatch, error.type: idempotency_error, subcode: unattended_replay_mismatch and param: external_id. Nothing is changed. It is not the error of a reused Idempotency-Key (422 idempotency_key_reused): it has its own code and status, so handle it on its own. The external_id identifies ONE operation, so use a new one for a new operation. |
Another request with the same external_id is still running | The second request waits up to 20 seconds for the first and then replays it. If the first has not finished, it answers 409 resource_locked with param: external_id and has written nothing: send the same request again. |
Two simultaneous requests with the same external_id therefore produce one
invoice, one number and one payment.
{
"error": {
"type": "idempotency_error",
"code": "unattended_replay_mismatch",
"subcode": "unattended_replay_mismatch",
"message": "La operación con external_id «KIOSK-0042-20260601-000187» ya está emitida con un tipo o un total distintos y no puede reenviarse con datos diferentes.",
"param": "external_id",
"doc_url": "https://docs.factuarea.com/guides/errors#unattended_replay_mismatch",
"request_id": "req_01J8Y3M2Q4CF0000000000URM1"
}
}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.
PDF library, previews and artifacts
The operations of the PDF template library and of PDF previews and artifacts follow the same rule: the family of the operation decides the contract.
| Operation | Idempotency-Key | Family |
|---|---|---|
POST /v1/companies/{company}/pdf-template-previews | Required | Export |
POST /v1/companies/{company}/pdf-artifacts | Required | Export |
POST /v1/companies/{company}/pdf-artifacts/{artifact}/render | Required | Export |
POST /v1/companies/{company}/pdf-templates/assets | Required | Upload |
POST and PATCH on /v1/companies/{company}/pdf-templates and /v1/companies/{company}/pdf-commercial-identities (create, rename, revisions, duplicate, archive, restore) | Optional, recommended | Document write |
PATCH /v1/companies/{company}/pdf-templates/defaults and PATCH /v1/companies/{company}/pdf-templates/contact-defaults/... | Optional, recommended | Document write |
PATCH /v1/companies/{company}/series/{series}/pdf-template | Optional, recommended | Document write |
PATCH .../presentation and POST .../presentation-revisions of invoices, quotes, proformas and delivery notes, and POST /v1/companies/{company}/invoices/{invoice}/administrative-revisions | Optional, recommended | Document write |
The four required operations return 422 idempotency_key_required without the header,
like any other required operation. Reads do not take the key. Remember that for the
optional ones a repeated POST without a key repeats its effect.
What is different about them:
- Replay before conditions. The stored response is returned before
If-Match,expected_versionand the other version checks, and before any identifier is generated. A retry of aPATCHthat already succeeded gets the original success withIdempotent-Replayed: true, not a412or409caused by your own first attempt. The caller's authority is verified first: if the role or the access was revoked, you get the authorization error, not the cached response. - Exact body for the three PDF operations. For
pdf-template-previews,pdf-artifactsandrender, the fingerprint is computed over the raw body as you sent it. The same key with a body that differs by a single byte (key order and whitespace included) returns422 idempotency_key_reused. Two different keys with equivalent bodies lead to the same artifact. - Multipart upload. For
POST /v1/companies/{company}/pdf-templates/assetsthe fingerprint covers the form fields and a digest of the file, so a retry must send the same file. - A new key for a new attempt. The stored answer of the first call is replayed for
24 hours, even if the artifact changed state afterwards. To retry a retryable
failure on purpose, send
renderagain with a new key.
The same key is still running
Some operations protect the key while they are executing, for example creating a
contact or updating its customer profile. If a second request with the same key arrives
before the first one finished, it does not run: the API answers
409 idempotency_key_in_use (error.type: idempotency_error) with param: Idempotency-Key, because the result is not known yet. Wait a moment and send the
same request again with the same key: you receive the stored result once the first
one completes. Do not change the key, or you would run the operation twice. Do not
confuse it with idempotency_key_reused, where the key already belongs to a
different body.
Safe retry example
import os, uuid, requests
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
COMPANY_ID = os.environ['COMPANY_ID']
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(
f'https://api.factuarea.com/v1/companies/{COMPANY_ID}/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();
const COMPANY_ID = process.env.COMPANY_ID;
let lastError;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const res = await fetch(`https://api.factuarea.com/v1/companies/${COMPANY_ID}/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.
The PDF tools (preview_pdf_template, prepare_pdf_artifact, render_pdf_artifact
and upload_pdf_template_asset) and the write tools of the library take the same
idempotency_key argument, and replay it before the version check just like the REST
operations.