Building
Idempotency & retries
Networks fail in the most inconvenient place: after a request left your server but before the response arrived. Idempotency keys turn that ambiguity into a safe retry.
The problem
You call startPayment(). The connection drops. Did OBH create a payment? You cannot tell. If you retry naively, you might create two payments for one order. If you give up, the customer may have a valid checkout you never learned about.
An idempotency key resolves this. It tells OBH: “this is the same attempt as before; give me its result instead of creating another payment.”
How idempotency keys work
Every create request carries an Idempotency-Key header, scoped to the authenticated service.
- Same key, same request — OBH returns the original payment. Replay is marked with
Idempotent-Replayed: trueon the HTTP response. - Same key, different request — OBH returns
409 IDEMPOTENCY_CONFLICT. A consumed key can never be reused for a different financial request. - Different key, same order — OBH compares the external order id. If the details match, it returns the existing payment; otherwise
409 ORDER_CONFLICT.
POST /api/v1/payments HTTP/1.1Authorization: Bearer svc_test_<key-id>.<secret>Idempotency-Key: order-IG-8821-attempt-1Content-Type: application/json {"externalOrderId":"IG-8821","amount":"245000000","description":"Annual design workspace","currency":"IRR"}The ledger is durable. A key that was accepted — even one that replayed an existing order — stays consumed. That is the point: a retry must land on the original payment, not create a second one.
Choosing a key
The SDK uses your key verbatim when supplied. When omitted, it generates sdk_<uuid>, which is fine for a single attempt but cannot be reproduced by a retry.
| Pattern | When to use it |
|---|---|
order-{id}-attempt-{n} | Recommended. Matches one checkout attempt. A genuine new attempt (after a failed or expired payment) gets a new n. |
{idempotencyKey from client} | Use when the browser already has a per-checkout UUID. Validate and scope it to the authenticated user on your side. |
order-{id} | Acceptable only if an order can never legitimately be paid twice. It prevents ever retrying checkout for that order. |
random UUID per request | Avoid. It looks safe but provides no protection, because each retry gets a new key. |
Keys are 1–191 visible ASCII characters. Store the key with the order attempt before sending the request, so a crash after sending still leaves you able to retry with the same key.
const attempt = await db.paymentAttempt.create({ data: { orderId: order.id, idempotencyKey: `order-${order.id}-attempt-${order.attempt}`, status: "started", },}) const payment = await obh.startPayment({ externalOrderId: order.number, amount: order.total, description: order.title, idempotencyKey: attempt.idempotencyKey,})What is safe to retry
| Outcome | retryable | Action |
|---|---|---|
TIMEOUT | true | The result is unknown. Retry the create with the same key, or retrieve by reference if you have it. |
NETWORK_ERROR | true | Same as timeout: retry with the same key. |
429 RATE_LIMITED | true | Back off, then retry with the same key. |
5xx (for example PROVIDER_ERROR) | true | Back off and retry with the same key. Provider failures are sanitized, not necessarily permanent. |
4xx validation or auth | false | Fix the request or the credential. Retrying unchanged will fail again. |
ORDER_CONFLICT / IDEMPOTENCY_CONFLICT | false | Do not retry. Reconcile the existing payment or investigate the key. |
Reads are always retryable
getPayment() has no side effects. Network errors and timeouts on reads can be retried with backoff regardless of the key rules.
A retry loop
Keep retries short and bounded. Honour retryable rather than pattern-matching on every code, and always reuse the key.
import { isOBHPaymentsError } from "@obhco/payments" const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) export async function startPaymentSafely( input: StartPaymentInput, idempotencyKey: string) { let lastError: unknown for (let attempt = 0; attempt < 3; attempt += 1) { try { return await obh.startPayment({ ...input, idempotencyKey }) } catch (error) { lastError = error if (!isOBHPaymentsError(error) || !error.retryable) { throw error } // 250ms, 500ms, 1000ms with jitter await sleep(250 * 2 ** attempt + Math.random() * 100) } } throw lastError}Do not add a retry loop to startPayment that generates a key internally. The key must be the same across attempts; that is the entire mechanism.
Conflicts
IDEMPOTENCY_CONFLICT
You reused a key for a request whose financial details differ — amount, currency, order, or another fingerprinted field. Treat it as a bug: either the key derivation is too coarse (for example, reusing an order-level key for a second attempt) or the request changed when it should not have.
if (error.code === "IDEMPOTENCY_CONFLICT") { // The key belongs to a different request. Do not "fix" it by // generating a new key mid-flight — that can double-charge. logger.error("idempotency key reused", { requestId: error.requestId, orderId: order.id, }) throw new CheckoutError("This checkout attempt is already in use.")}ORDER_CONFLICT
The external order id already exists with different details. This usually means two checkout attempts for the same order used different amounts, or the order was edited after the first payment was created. Retrieve the existing payment and reconcile; do not create a parallel one.
Pitfalls
- Generating the key too late. If the key is created inside the request function, a retry cannot reuse it. Create it before the first send and persist it.
- Reusing an order-level key forever. A failed or expired attempt needs a new key if the customer genuinely starts again. Include an attempt counter.
- Treating a timeout as a failure. A timeout is an unknown outcome, not a declined payment. Never tell the customer it failed until you have read a terminal status.
- Changing the request on retry. Changing the amount or metadata and keeping the key turns a retry into a conflict. Keep the request byte-for-byte stable for one key.
Continue with Errors to see how every failure is represented.