COUNTCOUNT
Sign Up

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.

Retry with an idempotency 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 reject Idempotency-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.

Replayed response
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.

StatusMeaningWhat to do
422The key was already used for a different payload.A genuine bug in your key generation. Use a new key for a new request.
409The first request under this key is still running.Wait and retry the same key.
409The 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.

Rate limit headers
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 retrying

Exceeding 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.