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
supports the Idempotency-Key header.
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 64 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.
Per-endpoint recommendations
| Endpoint | Idempotency recommended |
|---|---|
POST /v1/invoices | Yes (critical) |
POST /v1/quotes | Yes |
POST /v1/clients | Yes |
POST /v1/invoices/{id}/send | Yes |
POST /v1/invoices/{id}/mark-paid | Yes |
POST /v1/invoices/{id}/payments | Yes (a retried payment would double-count) |
POST /v1/purchase_invoices/{id}/payments | Yes (a retried payment would double-count) |
GET /v1/... | N/A (no effect) |
PATCH /v1/... | Optional (PATCH is idempotent by definition) |
DELETE /v1/... | Optional |
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
@retry(
stop=stop_after_attempt(5),
wait=wait_exponential(multiplier=1, max=10),
retry=retry_if_exception_type(requests.exceptions.RequestException),
)
def create_invoice(payload):
key = str(uuid.uuid4())
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,
)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 POST. If you generate a new key on each retry you lose the
protection. In the Python tenacity example, the key is generated
outside the closure and reused across all retries.
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.