Guides
Idempotency & Retries
Retry a failed write without creating it twice, and back off correctly when you hit the rate limit.
Why this matters
A request that times out tells you nothing about whether it applied. The connection dropped, but the write may well have committed on COUNT's side. Retrying blindly posts the invoice twice; not retrying may leave it missing. Neither is acceptable when the records are someone's books.
The Partner API solves this with an Idempotency-Key header. Send the same key on a retry and COUNT replays the original response instead of performing the write again.
Sending a key
Generate one key per logical operation — a UUID is ideal — before your first attempt, and reuse it for every retry of that same operation. A new operation gets a new key.
import { randomUUID } from 'node:crypto';
// One key per logical operation, generated before the first attempt and reused
// for every retry of that same operation.
const idempotencyKey = randomUUID();
async function createInvoiceWithRetries(body) {
for (let attempt = 0; attempt < 3; attempt++) {
const response = await fetch('https://api.getcount.com/partners/invoices', {
method: 'POST',
headers: {
...signedHeaders('POST', '/invoices', body),
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify(body),
});
if (response.status === 429) {
const retryAfter = Number(response.headers.get('Retry-After') ?? 1);
await new Promise((resolve) => setTimeout(resolve, retryAfter * 1000));
continue;
}
if (response.status === 409) {
// Still running, or the first attempt's outcome is unknown. Read the body
// to tell which — only one of them is safe to retry.
await new Promise((resolve) => setTimeout(resolve, 2000));
continue;
}
return response;
}
}Keys are scoped to your clientId and the workspace, so your key can never collide with another partner's. They are retained for 24 hours and may be at most 255 characters.
Where it applies
Idempotency covers POST only — the verb where a retry creates a duplicate record. PATCH, PUT and DELETE are already safe to repeat, so sending the header on one of those is rejected with 400 rather than silently ignored. Reads never need a key.
Not available on file uploads
Multipart upload endpoints rejectIdempotency-Key with a 400. The request body is parsed after the idempotency check, so the payload cannot be fingerprinted and the key would offer no real protection. Refusing it is honest about that.What a replay looks like
A replayed response carries the original status code and body, plus an Idempotent-Replay: true header so you can tell it apart from a fresh write.
HTTP/1.1 201 Created
Idempotent-Replay: true
RateLimit-Limit: 100
RateLimit-Remaining: 97
RateLimit-Reset: 42
Content-Type: application/json
{ "status": "success", "data": { "invoice": { "id": "f6a7b8c9-..." } } }Occasionally a stored response is too large to retain in full. Those replays carry Idempotent-Replay-Body-Omitted: true — the status code is still authoritative, but re-read the record if you need its fields.
The responses you must handle
Three failure modes are specific to idempotency, and they mean different things.
| Status | Meaning | What to do |
|---|---|---|
| 422 | The key was already used for a different payload. | A genuine bug in your key generation. Use a new key for a new request. |
| 409 | The first request under this key is still running. | Wait and retry the same key. |
| 409 | The first request failed without a definitive outcome. | Do not retry this key. Read back whether the record exists, and only re-send under a new key if it is missing. |
The two 409s are not the same
Both say 409, and the message tells them apart. “Still in progress” is safe to retry under the same key. “Failed without a definitive outcome” means the original write may have committed before the failure — replaying it is the one move that can still double-post. Reconcile first.Rate limits
The Partner API allows 100 requests per minute per clientId. Every response carries the state of your current window, so you never have to guess.
RateLimit-Limit: 100 # requests allowed per window
RateLimit-Remaining: 97 # requests left in this window
RateLimit-Reset: 42 # seconds until the window refills
# Sent only with a 429:
Retry-After: 42 # seconds to wait before retryingExceeding the limit returns 429 with a Retry-After value in seconds. Honour it rather than retrying immediately — the window is fixed, and a tight retry loop simply burns the budget it is waiting on.
A 429 does not consume an idempotency key. The request never reached the handler, so nothing was written, and your backoff retry runs for real under the same key.
Bulk writes
Bulk endpoints accept up to 100 rows and apply partial success: some rows can commit while others fail. A retry without a key re-posts the rows that already succeeded, which is exactly the duplication this header exists to prevent — so send one on every bulk call.
Read the per-row results array rather than trusting the top-level status, and see Ledger Semantics for which operations cannot be undone once they land.
