Factuarea API
Core concepts

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

  1. The client generates a unique key per operation (a UUID v7 is the recommended choice, for consistency with the API identifiers).

  2. Send it as a header on the first request:

    POST /v1/invoices
    Idempotency-Key: 01928f10-7c0e-7c4a-9b7d-2f8a6e3c1d4b
  3. The API stores the result (status code, headers and body) associated with that key for 24 hours.

  4. 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-Keyexternal_id
What it isA header on a single POST.A business key stored on the resource.
LifetimeEphemeral — 24h window, then purged.Durable — permanent, never expires.
ScopeDeduplicates transport retries of one call.Deduplicates by your own integration key (ERP/CRM id).
QueryableNo.YesPOST /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

EndpointIdempotency recommended
POST /v1/invoicesYes (critical)
POST /v1/quotesYes
POST /v1/clientsYes
POST /v1/invoices/{id}/sendYes
POST /v1/invoices/{id}/mark-paidYes
POST /v1/invoices/{id}/paymentsYes (a retried payment would double-count)
POST /v1/purchase_invoices/{id}/paymentsYes (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 4xx responses: if the first request responded 422 invalid_request_error, that 422 is cached. Replaying the key returns the same 422 with Idempotent-Replayed: true.

On this page