# Idempotency and Retries

Spike's write endpoints are idempotent. A retry that uses the same identifying information returns the same resource instead of creating a duplicate. Network timeouts and partner-side restarts will not create a duplicate exchange, deposit address, or refund.

This page describes the two layers of idempotency, the per-endpoint rules, and the partner-side retry pattern.

## Two layers of idempotency

Spike uses two complementary mechanisms.

### `Idempotency-Key` header

A UUID that the partner attaches to a request to bind a specific retry attempt to its original. If the same `Idempotency-Key` is replayed, Spike returns the original response.

The header is **optional** on every endpoint that accepts it. If omitted, Spike generates one server-side. Always sending your own key is recommended — it lets you bind retries to a specific intent without relying on Spike's bookkeeping.

Use `Idempotency-Key` for **transport-level retries**: the same logical request the partner intended to send once but ended up sending more than once because of a timeout or restart.

### Business identifiers

Some endpoints additionally enforce uniqueness on a tuple of business fields — for example, the partner's own `externalReference.externalId` for an exchange, or `(externalUserId, fromCurrency, toCurrency, network)` for an active deposit address. If a second request arrives with the same business identifier, Spike returns the existing resource rather than creating a new one.

Use business identifiers for **end-to-end deduplication**: the partner's order management system maps one business order to exactly one Spike resource regardless of how many times the request is sent.

## Per-endpoint rules

| Endpoint | `Idempotency-Key` | Business identifier | Same key replay | Different key, same business id |
|---|---|---|---|---|
| `POST /v1/exchanges` | Optional; server-generated if omitted | `(partnerId, externalReference.externalId)` | `200` with the existing exchange | `409` with the existing exchange in the body |
| `POST /v1/deposit-addresses` | Optional | `(partnerId, externalUserId, fromCurrency, toCurrency, network)` while the address is `ACTIVE` | `200` with the existing address | `200` with the existing address — business identifier wins |
| `POST /v1/exchanges/refund` | Optional; server-generated if omitted | `(functionalAccount, idempotencyKey)` — no `(amount, address)` tuple | `200/202` with the existing refund | A new refund is created (eligibility checks still apply) |
| `POST /v1/exchanges/{id}/authorization/approve` | n/a | On-ramp: `(exchangeId, commitmentHash)`. Off-ramp: only valid while the exchange is pending authorization. | On-ramp same hash → `200` no-op. Off-ramp re-approve after `AUTHORIZED` → `400 exchange_wrong_state` | On-ramp different hash → `400 commitment_conflict` |
| `POST /v1/exchanges/{id}/authorization/reject` | n/a | Only valid while pending | Re-reject after rejected → `400 exchange_wrong_state` | Reject after approve → `400 exchange_wrong_state` |

A few subtleties worth highlighting:

- **`POST /v1/exchanges/refund` does not deduplicate on `(exchangeId, amount, refundAddress)`.** Multiple refunds against the same exchange are supported when the partner uses different `Idempotency-Key` values. To dedupe a retry, send the same key. To create a deliberate second refund, send a fresh key.
- **Deposit-address creation has no `409` path** today. A repeat creation against the same active business tuple returns the existing address with `200`. The OpenAPI may describe a `409` response for completeness; in current behavior the business identifier wins silently.
- **Authorization endpoints don't accept `Idempotency-Key`.** Their idempotency comes from the (immutable) commitment material on approve and from the exchange state on both endpoints.

## The partner retry pattern

A robust partner client uses the same shape on every write:

1. Generate a UUID `Idempotency-Key` for the operation.
2. Issue the request.
3. On `2xx`, persist the result. Done.
4. On `4xx` other than `429`, do not retry — the request is malformed or rejected by business rules. Surface to the user.
5. On `5xx`, `429`, or transport error (timeout, connection drop), retry with **the same `Idempotency-Key` and the same body**. Use exponential backoff (~1s, 2s, 4s, 8s, capped at 30s). Stop after a bounded number of attempts (5–7 is typical) and treat as a partner-side incident.
6. After exhausting retries, query for the resource by business identifier (`GET /v1/exchanges/by-id?externalId=…`, or `GET /v1/deposit-addresses?externalUserId=&fromCurrency=&toCurrency=`) before deciding whether the operation actually succeeded.

A successful response on a retry is indistinguishable from a successful first attempt. The integration does not need to know the difference.

## Common questions

**Should I send my own `Idempotency-Key` if Spike generates one?**
Yes. Spike will generate one server-side, but you lose the ability to bind a retry to its original intent — your retries cannot dedupe transparently. Always send your own UUID.

**What if I lose the `Idempotency-Key` between retries?**
Re-issue with a fresh key but keep the same `externalReference.externalId`. Spike returns the existing exchange and your business order is still safe.

**Are reads idempotent?**
Yes. `GET` endpoints are inherently idempotent and safe to retry without any header.

**What about webhook delivery?**
Webhook delivery is at-least-once. Partner webhook handlers must be idempotent on the event ID, available as `X-Webhook-Event-Id` and inside the payload as `eventId`. See [Events and Webhooks](events-and-webhooks.md).
