---
title: "Idempotency & Retries · COUNT Partner API"
description: "Retry a failed COUNT Partner API write without creating it twice: the Idempotency-Key header, what a replay looks like, the two different 409s, and how to…"
canonical: "https://developers.getcount.com/guides/idempotency-and-retries"
source: "https://developers.getcount.com/guides/idempotency-and-retries"
---
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

```javascript
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
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.

Rate limit headers

```http
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](https://developers.getcount.com/guides/ledger-semantics) for which operations cannot be undone once they land.
