FinzBooksDevelopers

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/invoices
  • POST /api/public/v1/payments and .../apply
  • POST /api/public/v1/credit_notes and the two apply endpoints
  • POST /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_progress immediately — back off and retry once the first completes.

Key format

  • 1 – 200 characters
  • Alphanumeric plus -, _, or : (Stripe-style customer:create:281832 is 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_id
  • date (within a 7-day look-back window)
  • amount
  • reference_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);
  }
}