Idempotency
Safe retries for POSTs. Repeating the same key within 24 hours replays the original response — even across crashes, network blips, or webhook re-deliveries. Always use one for mutating calls.
⚠️ Now mandatory on money-moving POSTs
As of 2026-05-21, these endpoints reject requests without an Idempotency-Key header — they return 400 idempotency.key_required:
POST /api/public/v1/invoicesPOST /api/public/v1/paymentsand.../applyPOST /api/public/v1/credit_notesand the two apply endpointsPOST /api/public/v1/debit_notes
Every other POST keeps the header optional. The server still caches replays when a key IS sent — it just doesn't require one.
How to use it
Generate a unique key per logical operation and send it in the Idempotency-Key header. The server caches the response keyed on (idempotency_key, method, path, auth_subject) so the same key is safe to reuse across resources without collisions.
POST /api/public/v1/invoices
Authorization: Bearer aibk_pat_…
Idempotency-Key: bf3e1b08-9a4d-4f12-b9d3-8c8a52d1fa01
Content-Type: application/json
{
"customer_id": "ct_acme",
"date": "2026-05-19",
"place_of_supply": "IN-KA",
"line_items": [{ "item_id": "it_widget", "quantity": 2, "rate": 1000 }]
}If the request times out and you retry with the same key, the server returns the original response — same status, same body — without creating a duplicate.
Response caching rules
- 2xx / 3xx / 4xx: cached for 24 hours. Retries replay the same response.
- 5xx: NOT cached. Server errors must run for real on retry.
- Concurrent in-flight requests with the same key: the second caller gets a
409 idempotency.in_progressimmediately — back off and retry once the first completes.
Key format
- 1 – 200 characters
- Alphanumeric plus
-,_, or:(Stripe-stylecustomer:create:281832is fine) - UUID v4 is a good default
TTL
Keys live for 24 hours. Retry within that window and you'll hit the cached response. After 24 hours the key is forgotten — sending the same key again will create a new resource.
When to use it
Always, for every POST/PUT/PATCH/DELETE that mutates state. Especially on:
- Background jobs that retry on failure (cron, Celery, BullMQ, etc.)
- Mobile clients with flaky networks
- Bulk imports where one row may fail mid-batch
- Webhook receivers — payment-provider webhooks are deliberately re-delivered
Server-side safety nets
We layer a few server-side checks on top so missing the key doesn't silently produce duplicates. These are safety nets, not contracts — always send the key.
Payments — duplicate guard
POST /api/public/v1/payments rejects a request that matches a recent payment on all four of:
contact_iddate(within a 7-day look-back window)amountreference_number(non-empty)
Response is 409 validation.duplicate_resource with the existing payment_id in details. To force- create (legitimate same-amount payments from the same customer on the same day), pass a unique Idempotency-Key header — it bypasses the dedupe guard since the middleware short-circuits before the route runs.
Disable per-org via the strictPaymentDedupe: false in org preferences. Default is on.
Example rejection:
HTTP/1.1 409 Conflict
{
"code": "validation.duplicate_resource",
"message": "A payment with the same contact, date, amount, and reference_number already exists (e8bf7b71-…). To force-create, pass a unique Idempotency-Key header or change the reference_number.",
"details": {
"existing_payment_id": "e8bf7b71-7437-4248-9b3e-cbb4ec48e945",
"existing_date": "2026-05-19T00:00:00",
"existing_amount": 11800.00,
"existing_reference": "UTR-25051209"
}
}Invoices — unique number per branch
POST /invoicesenforces a unique (branch, invoice_number) constraint (CGST Rule 46 — each GSTIN's sequence is unique). Posting the same explicit invoice_number twice returns 400 validation.invalid_value.
Recommended client pattern
const idemKey = crypto.randomUUID();
for (let attempt = 0; attempt < 3; attempt++) {
try {
const res = await fetch("https://api.finzbooks.com/api/public/v1/payments", {
method: "POST",
headers: {
Authorization: `Bearer ${pat}`,
"Content-Type": "application/json",
"Idempotency-Key": idemKey, // ← same key across retries
},
body: JSON.stringify(payload),
});
if (res.status < 500) return res; // 2xx/3xx/4xx → final
await sleep(1000 * 2 ** attempt); // 5xx → exponential backoff
} catch (networkErr) {
await sleep(1000 * 2 ** attempt);
}
}