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.jsonHow it works under the hood
When your request includes an Idempotency-Key:
- ClientCasa computes a content hash of
method + path + body - Looks up
(your_org_id, idempotency_key)in the idempotency store - Cache hit + matching hash: returns the cached response. No DB write happens.
- Cache hit + DIFFERENT hash: returns
409 conflict— you tried to reuse a key with different data - Cache miss: proceeds normally, caches the response under that key
Cached responses live for 24 hours. After that the key is eligible for GC and reuse with new data.
When to generate a fresh key
| Situation | Same key? |
|---|---|
| Network timeout, retry the SAME request | ✅ same |
| 5xx error from ClientCasa, retry | ✅ same |
429 rate-limited, 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": "conflict",
"message": "Idempotency key reused with different payload",
"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). Either:
- Generate a new key if the body genuinely should be different
- Pin the body so retries send the exact same data
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
- Idempotency reference — the API-level spec
- Errors — full error code list including
conflict - Payments API — where this matters most
Webhook receiver with HMAC verification
Build a webhook receiver that verifies HMAC-SHA256 signatures, rejects replays, dedupes by event ID, and handles retries. Works on Node, Edge runtimes, and Cloudflare Workers.
Zapier Integration
Connect ClientCasa with 6,000+ apps using our native Zapier integration. Instant triggers for invoices, proposals, clients, and more.