Source: https://docs.debitura.com/developer-concepts/idempotency

# Idempotency

Network calls fail in ambiguous ways: a request times out, but you don't know whether the server processed it. Retrying naively risks creating a duplicate case, division, or chat message. Debitura gives you three complementary tools to retry safely:

1.  **The `Idempotency-Key` header** — an optional, opt-in key you attach to a write request so a retry replays the original outcome instead of acting twice. This is the primary mechanism and the focus of this article.
2.  **Business-key idempotency** — for a few operations, a natural identifier you already control (`externalTenantId`, `creditorReference`) prevents duplicates without any header.
3.  **Webhook deduplication** — the stable event `id` lets you make inbound processing idempotent.

<a id="the-idempotency-key-header"></a>

## The `Idempotency-Key` header

Send an `Idempotency-Key` request header on a supported write endpoint. The first request is processed normally and its terminal response is stored against the key. If you repeat the request with the **same key and the same body**, Debitura replays the stored response — its status code and body — instead of performing the operation again.

Create a case with an idempotency key

```http
POST /cases
Content-Type: application/json
XApiKey: your-api-key
Idempotency-Key: 9f3c1b7e-2a4d-4f6a-8c11-0b2d6e5a7f10

{
  "currencyCode": "EUR",
  "amountToRecover": 4000.00,
  "creditorReference": "INV-2024-00789",
  "debtor": { ... }
}
```

Generate a fresh key (a UUID is ideal) per logical operation, store it client-side, and reuse the **same** key for every retry of that operation.

<a id="semantics"></a>

### Semantics

-   **Optional** — omitting the header preserves the endpoint's normal behaviour.
-   **Format** — a non-empty, non-whitespace string up to **255 characters**. Invalid values are rejected with `400`.
-   **Replay on match** — same key + identical body returns the original stored response (status code and body), so a retry never creates a second resource.
-   **Conflict on mismatch** — reusing a key with a _different_ body returns `422 Unprocessable Entity` with `businessErrors[0].type: "IdempotencyConflict"`. A key is bound to the exact request it was first used with.
-   **In-flight contention** — if a request with the same key is still being processed, the retry returns `409 Conflict` with a `Retry-After: 2` header. Wait and retry. The error envelope depends on the endpoint: `POST /clients` returns `businessErrors[0].type: "IdempotencyKeyInProgress"`. Treat any `409` with `Retry-After` as in-flight contention rather than assuming a top-level `Type`. The chat-send endpoint is the exception — it does not perform in-flight reservation, so concurrent retries there rely on the stored record instead of a `409`.
-   **Only terminal outcomes after reservation are stored** — successful creates and terminal business-rule rejections (`422`) are stored and replayed. On Referral Partner API `POST /clients`, terminal onboarding or linking responses (`409`) are stored too. Early input or business-precheck rejections before reservation are not stored; in-flight `409` responses with `Retry-After` are not terminal.
-   **Server errors (`5xx`) depend on how far the request got** — if the operation failed _before_ the resource was created, nothing is stored and a retry genuinely re-attempts the work. If the resource was already created and a _later_ step failed, the `5xx` **is** stored and replayed on retry. This is deliberate: once the resource exists, a retry must never create a duplicate.
-   **Field validation is never stored** — field-level `400` validation errors are not persisted. You can fix the payload and retry with the **same** key.
-   **Keys expire after a 24-hour replay window** — ordinary completed operations are replayable for 24 hours. Long-running operations may refresh their active reservation while work is in flight, so the window is not guaranteed to end exactly 24 hours after initial receipt. Reusing the same key after its stored window expires starts a **fresh** operation and may create a new resource, so keep normal retries well within 24 hours.

<a id="response-scenarios"></a>

### Response scenarios

| Scenario | Response |
| --- | --- |
| First request, succeeds | Normal success (e.g. `200`/`201`) — response is stored |
| Retry, same key + same body | Original stored response (status code and body), replayed |
| Retry, same key + **different** body | `422` · `businessErrors[0].type: "IdempotencyConflict"` |
| Concurrent retry while first is in flight | `409` · `Retry-After: 2`; on `POST /clients`, `businessErrors[0].type: "IdempotencyKeyInProgress"` |
| Key longer than 255 characters | `400` validation error |
| Key empty or whitespace-only | `400` validation error |
| First request fails field validation (`400`) | Not stored — fix and retry with the same key |
| Request fails with `5xx` _before_ the resource is created | Not stored — retry re-attempts the operation |
| Request fails with `5xx` _after_ the resource is created | Stored — retry replays the `5xx`, never re-creates |
| Retry after the stored key's 24h replay window | Key has expired — treated as a new request and may create a new resource |

<a id="supported-endpoints"></a>

### Supported endpoints

| API | Endpoint | Operation |
| --- | --- | --- |
| Customer API | `POST /cases` | Create a collection case |
| Customer API | `POST /divisions` | Create a division |
| Collection Partner API | `POST /managed-cases` | Create a case as managing partner |
| Collection Partner API | `POST /cases/{id}/payments` | Record a payment on a case |
| Collection Partner API | `POST /cases/{id}/chats` | Send a chat message (see [variant below](https://docs.debitura.com/developer-concepts/idempotency.md#chat-message-sending-variant)) |
| Collection Partner API | `POST /cases/{id}/files` | Upload a file to a case |
| Collection Partner API | `POST /managed-cases/{id}/files` | Upload a file to a managed case |
| Collection Partner API | `POST /managed-clients` | Create a represented client (header **required** here: exactly one value of 1–255 non-whitespace characters; missing or invalid → 400 `InvalidIdempotencyKey`) |
| Referral Partner API | `POST /clients` | Create or link a client and optionally create cases |
| Referral Partner API | `POST /clients/{externalTenantId}/kyc-verification` | Submit KYC verification |
| Referral Partner API | `POST /cases/{caseId}/files` | Upload a file to a case |

Each supported operation lists the `Idempotency-Key` header in its [API reference](https://docs.debitura.com/api.md). The table lists the documented integration endpoints that support this header; check the API reference for each operation.

Webhooks excluded by design

`POST /webhooks` (subscription creation) does **not** support `Idempotency-Key`. The signing secret it returns is shown once and cannot be replayed, and the subscription URL is already unique per endpoint, so duplicate subscriptions are prevented without a key.

<a id="chat-message-sending-variant"></a>

### Chat message-sending variant

`POST /cases/{id}/chats` on the Collection Partner API honours `Idempotency-Key` but uses a lighter, in-memory mechanism rather than the durable store described above:

-   A duplicate key with the **same message body** within **24 hours** returns the originally created chat message instead of posting a second one.
-   The key is scoped per **partner + case + message body**. Reusing the same key with a _different_ message body posts a **new** message — it does **not** return a `422` conflict.
-   The key is treated as an opaque cache entry. Unlike the durable endpoints, its length is **not** validated here, so the 255-character limit shown in the API reference is not enforced for this endpoint.
-   Because the cache is in-memory and expires after 24 hours, treat this as best-effort de-duplication for rapid retries, not a durable guarantee.

<a id="business-key-idempotency"></a>

## Business-key idempotency

Some operations are naturally idempotent through an identifier you already supply — no header required.

<a id="client-creation-referral-partner-api"></a>

### Client creation (Referral Partner API)

Without `Idempotency-Key`, `POST /clients` uses `externalTenantId` only as a client/link business key:

-   The first call creates the client; repeat calls with the same `externalTenantId` return the existing client's current status rather than creating a duplicate.
-   The response code reflects the API's SDCA onboarding status (`201` complete, `202` incomplete). `409` reports a conflict, including client-link conflicts or a referral partner that is not accepting new referrals; inspect the typed conflict response.

This business key prevents duplicate clients and links, but it does **not** make an unkeyed case-bearing request safe to retry and it does not replay the original response. Case-safe retry and exact response replay require `Idempotency-Key`: it binds the complete effective request body, replays the exact original response, and prevents duplicate case creation when `cases` is present. Use one key per client-creation attempt and reuse it for unchanged retries within 24 hours. See [Client onboarding conflicts](https://docs.debitura.com/referral-partners/integration-guides/client-onboarding-conflicts.md) for the conflict cases.

“Effective” follows what `POST /clients` actually executes. A single user is always inferred as the onboarding user; omitted and empty `cases` or `claimLines` collections are equivalent. Within a case, changing `skipCreationEmails`, `allowPendingContracts`, or `assignedUserEmail` does not create a new effective request on this endpoint. Unsafe `returnUrl` values are dropped, debtor `type` casing is normalized, `contactPerson` is ignored for a private debtor, and `state` is ignored when `stateAlpha2` is supplied. Retrying with the same key across any of those syntax-only differences replays the first terminal response; changing a field that affects creation still returns `IdempotencyConflict`.

<a id="case-reference-uniqueness-customer-api"></a>

### Case reference uniqueness (Customer API)

Independently of the `Idempotency-Key` header, `creditorReference` must be unique per creditor and case classification, so test and live cases may share a reference. A case closed as `CaseNeverStartedInternal` under an archived, non-deleted referral-partner link no longer reserves it. Other duplicates return `422`. For safe retries, combine a unique reference with an `Idempotency-Key`.

<a id="webhook-consumption"></a>

## Webhook consumption

Idempotency also applies to events you _receive_. Debitura retries failed webhook deliveries with exponential backoff, so your endpoint may see the same event more than once. Deduplicate on the event `id`:

Webhook event payload

```json
{
  "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "event": "case.created",
  "timestamp": "2026-01-15T10:30:00Z",
  "data": { ... }
}
```

1.  Extract `id` from the payload.
2.  Atomically claim that `id` in the same transaction as your business-side effect.
3.  If the claim already exists, return `200 OK` without re-acting; otherwise commit the effect and claim together.

If your storage cannot make the claim and business effect atomic, make the effect itself idempotent and record the event ID before acknowledging the delivery.

See [Webhooks](https://docs.debitura.com/developer-concepts/webhooks.md) for delivery and retry mechanics.

<a id="safe-retry-checklist"></a>

## Safe retry checklist

| Operation | How to retry safely |
| --- | --- |
| Any supported write endpoint | Attach a stable `Idempotency-Key`; reuse the same key on every retry within 24h |
| Create client (Referral Partner API) | Send an `Idempotency-Key` when the request includes cases or exact response replay matters; `externalTenantId` still prevents duplicate clients when the header is omitted |
| Create case (Customer API) | Send a unique `creditorReference`; optionally add an `Idempotency-Key` |
| Send chat message | Reuse the same `Idempotency-Key` within 24h for identical retries |
| Process inbound webhook | Deduplicate locally on the event `id` |

When in doubt, generate one `Idempotency-Key` per operation up front, persist it alongside the work you're trying to do, and send it on the first attempt and every retry within 24 hours. That single habit makes the ambiguous-timeout case safe across every supported endpoint.
