# Idempotency keys in payment APIs: how not to charge or pay twice

> Why retries create duplicate payments and payouts, and how CryptoLike prevents them: Idempotency-Key, payout_id, order_id and webhook event_id, with code.

Source: https://cryptolike.net/blog/idempotency-in-payment-apis
Published: 2026-09-24 · Updated: 2026-09-25 · Author: CryptoLike team

Key takeaways:

- A timeout does not tell you whether the request was executed. Retrying without an idempotency key can create a second payment or a second payout.
- Send an Idempotency-Key with every POST: a repeat within 24 hours returns the stored response with Idempotent-Replayed: true.
- For payouts, also send your own payout_id: it is unique per account without a time limit, so a repeat returns the same payout.
- Webhooks are delivered at least once and in no fixed order: store event_id and apply each event once.

A payment API call can fail after it has already done its work: the payment is created, but the response is lost to a timeout, and your code cannot tell whether to try again. Idempotency makes the retry safe. In CryptoLike you send an `Idempotency-Key` header with each `POST`, a `payout_id` with each payout and, if you like, `order_id` with each payment; a repeat of the same request returns the original result instead of creating a second one. Webhooks need the same care on your side, because they can arrive more than once.

## Why duplicates happen

A duplicate payment or payout is almost never a bug in the payment platform; it is a retry of a request whose first attempt succeeded. Networks drop responses, load balancers time out, workers restart mid-request, and customers press "Pay" twice. Each of these leaves your code with the same question: did the first request go through?

| Situation | What went wrong | Without idempotency | With CryptoLike idempotency |
|---|---|---|---|
| Your HTTP client times out on `POST /v1/invoices` | The payment was created; the response was lost | A retry creates a second payment for the order | The retry returns the first payment |
| A job worker restarts while sending a payout | The payout may or may not exist | A retry may pay the recipient twice | `payout_id` returns the existing payout |
| A customer double-clicks "Pay with crypto" | Two requests for one order | Two open payments, two addresses | `idempotency_by_order_id` returns the open payment |
| Your endpoint answers a webhook after 12 s | CryptoLike counts it as failed and retries | The order is processed twice | You skip an `event_id` you already applied |

Money makes retries unavoidable — you cannot simply give up on a payout — so the only safe design is one where retrying is harmless.

## How Idempotency-Key works in CryptoLike

CryptoLike accepts an `Idempotency-Key` header on every `POST` of the public API: creating and cancelling payments, creating and archiving static addresses, creating, estimating and cancelling payouts, and managing webhook endpoints and retries. The header is optional; without it every request is executed as new. With it, the first successful response is stored for 24 hours and returned for every repeat.

| Rule | Value |
|---|---|
| Format | 1–128 printable ASCII characters (`!` to `~`, no spaces); a longer value or other characters give `400 validation_error` with `details.field: Idempotency-Key`. An empty header is ignored |
| Scope | Per API key: two keys can use the same value independently |
| How long a response is kept | 24 hours |
| What counts as the same request | The same method, path with query and body |
| Repeat of a completed request | The stored status and body, with the header `Idempotent-Replayed: true` |
| Same key, different body | `422 idempotency.mismatch` |
| Same key while the first request still runs | `409 idempotency.in_progress`; retry after a short pause |
| Error responses (4xx, 5xx) | Not stored: the key is released and a retry runs again |

Checked against the CryptoLike API on 2026-09-24; the header is described in the [API reference](https://cryptolike.net/docs/api), request signing in [API authentication](https://cryptolike.net/docs/auth).

Two details matter in practice. Only successful responses are stored, so fixing a request after a `400` and sending it with the same key works as expected. And the in-flight lock expires after 60 seconds, so a request that crashed mid-way does not block its key for a day.

## How to choose the key

An idempotency key must identify the business operation, not the HTTP attempt. Generate it once, when you decide to create the payment or payout, save it with the operation, and reuse it on every retry. A new random key for each attempt defeats the purpose: each attempt becomes a new operation.

Good keys come from your own data:

- **Payment for an order:** `order-A-1002`, or `order-A-1002-2` if the customer deliberately starts a second payment after the first one expired.
- **Payout:** the ID of the row in your payouts table, created before the API call.
- **Static address for a user:** `addr-user-8812-usdt-tron`, although this call is already safe: a second address for the same customer and asset is `409 conflict`.

Keep the key and the body together. If your code rebuilds the body on retry — with a new timestamp in `description`, for example — the repeat is a different request, and CryptoLike answers `422 idempotency.mismatch` instead of creating a duplicate.

## A retry wrapper in TypeScript

This wrapper retries a CryptoLike call on network errors, `409 idempotency.in_progress`, `429` and `5xx`, with the same idempotency key and the same body every time, but a fresh signature for each attempt. Each repeat waits at least until the next second: timestamps are whole seconds, and a repeat signed in the same second is refused as `401 auth.replay`. The official SDKs already do this. The wrapper uses the signed-request helper from the [Node.js guide](https://cryptolike.net/guides/accept-crypto-payments-nodejs), which signs on every call; give that helper a way to return the HTTP status, the error code and the `Retry-After` value.

```ts
type ApiError = Error & { status?: number; code?: string; retryAfter?: number }; // retryAfter: seconds from the Retry-After header

const RETRYABLE = (e: ApiError) =>
  e.status === undefined ||                       // network error or timeout
  e.status === 429 ||
  e.status >= 500 ||
  e.code === "idempotency.in_progress";

export async function withRetry<T>(call: () => Promise<T>, attempts = 5): Promise<T> {
  for (let i = 0; ; i++) {
    try {
      return await call(); // call() signs afresh: a new X-Timestamp and signature each attempt
    } catch (err) {
      const e = err as ApiError;
      if (i + 1 >= attempts || !RETRYABLE(e)) throw e;
      // Timestamps are whole seconds: a repeat signed in the same second is refused
      // as 401 auth.replay, so wait at least 1 s before the next attempt,
      // and never less than the Retry-After of a 429.
      const wait = Math.max(1000 * 2 ** i, (e.retryAfter ?? 0) * 1000); // 1 s, 2 s, 4 s, 8 s
      await new Promise((r) => setTimeout(r, wait));
    }
  }
}

// The key and the body are fixed before the first attempt.
const body = { amount: "49.90", currency: "USD", order_id: "A-1002", idempotency_by_order_id: true };
const payment = await withRetry(() => cryptolike("POST", "/v1/invoices", body, "order-A-1002"));
```

Do not retry `400`, `401`, `403`, `404` or `422`: they will fail the same way until you change the request. A `401 auth.invalid_signature` usually means the body you hashed is not the body you sent.

## Payouts: send your own payout_id

For payouts, CryptoLike has a second guard that does not expire: `payout_id`, your own identifier of up to 128 characters, unique per account. `POST /v1/withdrawals` with a `payout_id` that already exists returns the existing payout instead of creating another — with `201` if it was created less than 5 seconds ago, with `200` after that — provided the asset, amount and address are the same. Different parameters with the same `payout_id` answer `409 conflict` with `details.field: payout_id`.

```json
POST /v1/withdrawals
Idempotency-Key: payout-7731

{
  "coin_id": "<id of USDT on tron from GET /v1/currencies>",
  "amount": "250.00",
  "to_address": "T…",
  "payout_id": "payout-7731"
}
```

Use both: the header covers the 24-hour window of network retries, and `payout_id` covers a retry days later — a job that is re-run after an outage, or the same row in a CSV uploaded twice. In a [mass payout CSV](https://cryptolike.net/guides/crypto-mass-payouts), a `payout_id` that repeats inside the file or was already used is a row error, and the file is rejected before anything is sent.

## Payments: one open payment per order

For payments, the business key is your order. Pass `order_id` together with `idempotency_by_order_id: true`, and CryptoLike returns the open payment of that order with `200` instead of creating another one — a double click, a page reload or a retry all land on the same payment page.

- **Same order, different amount or currency:** `409 conflict` with `details.reason: order_invoice_mismatch` and the ID of the existing payment. If the order really changed and the payment is still `new`, with nothing sent to it, cancel it first; once a transfer is on its way, it can no longer be cancelled.
- **Order already received funds:** `409 conflict` with `details.reason: order_has_payment`. Do not open a second payment; check the first one.
- **Previous payment expired:** a new payment is created, with a new rate if the price is in fiat.

Without the flag, `order_id` is only a reference and is not unique. Every CryptoLike CMS plugin uses this mode, which is why a customer who presses "Pay" twice sees one payment page.

## Webhooks: apply each event once

Idempotency works in the other direction too. CryptoLike delivers webhooks at least once: a delivery that does not get a 2xx within 10 seconds is retried after 1, 5 and 15 minutes, 1, 6 and 24 hours — 7 attempts over about 31 hours — and a crash between your processing and your answer means the event comes again. Deliveries also arrive in no fixed order.

Three rules make a handler safe:

1. **Deduplicate by `event_id`.** It is in the body and in the `X-Event-Id` header, and it is the same on every delivery of an event.
2. **Record the event and apply it in one transaction**, then answer `200`. If the transaction fails, answer with an error and let CryptoLike retry.
3. **Never move a status backwards.** A late `invoice.pending` must not undo `invoice.paid`; rank the statuses or compare timestamps.

```sql
-- One row per applied event; the unique key makes the second delivery a no-op.
INSERT INTO cryptolike_events (event_id, type, received_at)
VALUES ($1, $2, now())
ON CONFLICT (event_id) DO NOTHING;
-- 1 row inserted → apply the event to the order in the same transaction.
-- 0 rows → already applied: commit and answer 200.
```

Verify the signature before anything else — the [webhook verification guide](https://cryptolike.net/guides/verify-crypto-payment-webhooks) has code in four languages. A delivery that ran out of attempts can be sent again with `POST /v1/webhooks/deliveries/{id}/retry` once your endpoint is fixed; `GET /v1/webhooks/deliveries` lists them.

## Errors and what to do

| HTTP · code | What happened | What to do |
|---|---|---|
| `409 idempotency.in_progress` | The first request with this key is still running | Retry after a short pause with the same key and body |
| `422 idempotency.mismatch` | The key was used with a different body | Rebuild the original body, or use a new key for a new operation |
| `400 validation_error`, field `Idempotency-Key` | The key is longer than 128 characters or has characters outside printable ASCII (an empty header is ignored) | Use 1–128 printable ASCII characters without spaces |
| `409 conflict`, field `payout_id` | The `payout_id` exists with other parameters | Check the existing payout; use a new ID for a new payout |
| `409 conflict`, `order_invoice_mismatch` | The order has an open payment with another amount | Cancel it while it is `new`, or reuse it |
| `503 unavailable` | The idempotency store is temporarily unavailable | Retry with backoff and the same key |

The full list of error codes is in the [API reference](https://cryptolike.net/docs/api).

## FAQ

### Is the Idempotency-Key header required?

No, it is optional on every POST of the CryptoLike API. Without it each request is executed as new, so a retry after a timeout can create a duplicate. Send it on every call that creates something, especially payments and payouts.

### How long does CryptoLike remember an idempotency key?

24 hours for a successful response, per API key. Error responses are not stored, so a corrected request with the same key is executed. For payouts, payout_id has no time limit, which covers retries days later.

### What does Idempotent-Replayed: true mean?

The response is a copy of the first successful response to the same request with the same key; nothing new was created. Treat it exactly like the original response. The header helps in logs to tell a replay from a new operation.

### Can a webhook arrive twice?

Yes. CryptoLike delivers webhooks at least once: a delivery without a 2xx within 10 seconds is retried, up to 7 attempts over about 31 hours, and a crash can repeat a delivery that you already processed. Deduplicate by event_id and apply each event in one database transaction.

### Should I use a random UUID as the idempotency key?

A UUID is fine if you generate it once per operation and store it before the first attempt. A new UUID on every retry makes each attempt a new operation and defeats idempotency. Keys derived from your order or payout ID are easier to trace.
