ClientCasa
Integrations

Idempotent writes for safe retries

Every POST to the ClientCasa API should send an Idempotency-Key header. Here's why, how it works under the hood, and how it interacts with payments and refunds.

Networks fail. Servers restart. Your code retries. Without idempotency keys, a retry can create duplicate invoices, duplicate payments, duplicate clients. Every POST and PATCH to the ClientCasa API supports an Idempotency-Key header — and you should always send one.

The 30-second version

Generate a UUID per logical operation. Send it as Idempotency-Key. Retry the exact same request with the exact same key and ClientCasa returns the original response instead of creating a duplicate.

import { ClientCasa } from '@clientcasa/sdk'
import { randomUUID } from 'node:crypto'

const cc = new ClientCasa()
const security = { apiKey: process.env.CLIENTCASA_API_KEY ?? '' }
const idempotencyKey = randomUUID()

await cc.invoices.createInvoice(
  security,
  {
    clientId: 'cli_...',
    issueDate: '2026-05-19',
    dueDate: '2026-06-18',
    lineItems: [{ description: 'Consult', quantity: 1, unitPrice: 500, taxable: false }],
  },
  { headers: { 'Idempotency-Key': idempotencyKey } },
)
curl -s "https://www.clientcasa.com/api/v1/invoices" \
  -X POST \
  -H "x-api-key: $CLIENTCASA_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d @invoice.json

How it works under the hood

When your request includes an Idempotency-Key:

  1. ClientCasa computes a content hash of method + path + body
  2. It claims (your_org_id, idempotency_key) before anything runs — the key is a reservation, not a receipt written afterwards
  3. Claim taken: the request runs, and its response is recorded under the claim
  4. Claim held by a request that is still running: returns 409 request_in_progress with a Retry-After header — the write happens once
  5. Claim already complete, matching hash: returns the recorded response. No DB write happens.
  6. Claim already complete, DIFFERENT hash: returns 409 idempotency_key_reused — you tried to reuse a key with different data

If the request fails, the claim is released, so an honest retry re-runs instead of replaying a failure you cannot act on.

Recorded responses live for 24 hours. After that the key is eligible for GC and reuse with new data.

When to generate a fresh key

SituationSame key?
Network timeout, retry the SAME request✅ same
5xx error from ClientCasa, retry✅ same
429 rate-limited, retry after Retry-After✅ same
409 request_in_progress, retry after Retry-After✅ same
User clicked "Submit" again on the form✅ same (or skip the second submit entirely)
Creating a different invoice❌ new key
Retrying after fixing a 400 validation error❌ new key (your data is different now)

Where this matters most: payments

The Payments resource is immutable after creation — you can't delete one, and the only fields you can update are reference and notes. That means a duplicate POST creates a duplicate money event, which is a real reconciliation mess.

// IDEMPOTENCY ESPECIALLY CRITICAL HERE
await cc.payments.createPayment(
  security,
  {
    clientId: 'cli_...',
    amount: 1250.00,
    receivedDate: '2026-05-19',
    method: 'check',
    reference: 'Check #4421',
  },
  { headers: { 'Idempotency-Key': paymentEntryUUID } },
)

Best practice: persist the idempotency key alongside your business record before the API call. If your process crashes and retries from scratch, it finds the existing key in your DB and reuses it.

// pseudocode
const key = await db.getOrCreateIdempotencyKey('record-' + recordId)
await cc.payments.createPayment(security, paymentInput, {
  headers: { 'Idempotency-Key': key },
})

Refunds: the same pattern

Refunds are recorded as negative-amount Payments with kind: 'refund' and refundOfId referencing the original payment. They need idempotency too:

await cc.payments.createPayment(
  security,
  {
    clientId: 'cli_...',
    amount: -250.00,           // negative
    receivedDate: '2026-05-19',
    method: 'check',
    kind: 'refund',
    refundOfId: 'pay_abc123',  // the original payment
  },
  { headers: { 'Idempotency-Key': refundUUID } },
)

A duplicate refund without idempotency = your customer gets paid twice.

Conflict responses

If you send the same key with a different body, you get a 409:

{
  "error": {
    "code": "idempotency_key_reused",
    "message": "This Idempotency-Key was already used with a different request body.",
    "requestId": "req_abc..."
  }
}

This is intentional — it surfaces bugs in your retry logic (e.g., regenerating the body on each attempt but reusing the key). Retrying the identical request will never succeed, so either:

  • Generate a new key if the body genuinely should be different
  • Pin the body so retries send the exact same data

The other 409 is the opposite instruction. If your retry arrives while the first attempt is still running, you get:

{
  "error": {
    "code": "request_in_progress",
    "message": "A request with this Idempotency-Key is already in flight. Retry shortly.",
    "requestId": "req_abc..."
  }
}

Wait for the Retry-After interval and send the same request again — once the first attempt finishes, that retry replays its response.

What doesn't need idempotency

  • GET requests — read-only; safe to retry without a key
  • DELETE requests — idempotent by definition (deleting twice is a no-op). We don't reject duplicate DELETEs
  • PATCH on most resources — generally safe because the operation is "set to this value," but for cases like incrementing counters via PATCH, idempotency keys still help

We accept the Idempotency-Key header on PATCH because some user PATCH operations are not truly idempotent (e.g., status transitions that fire webhooks).

Key naming and storage

  • Use UUID v4 (built-in: crypto.randomUUID() in modern Node and browsers)
  • Keys are scoped to your organization — the same key in two orgs is unrelated
  • Store the key in your DB before the API call, not after — survives crashes mid-request
  • Don't reuse keys for unrelated operations — collisions return 409

See also

On this page