Idempotency & rate limits

Make Tokeflow API mutations safe to retry with idempotency keys, and design resilient clients that respect per-key rate limits.

Networks fail. Requests time out. Clients retry. On any payment surface, a retry that silently creates a second charge is a production incident.

Tokeflow gives you two primitives to build resilient integrations:

  • Idempotency — safely retry create and charge mutations without duplicating work.
  • Rate limits — predictable per-key throughput, with clear signals so your client can back off gracefully.

This page covers both, plus a copy-paste retry helper.

Tokeflow is the orchestration layer. It standardizes the API surface, routing, and replay semantics described here; the connected payment providers perform the actual processing and settlement.


Idempotency

Why idempotency matters

Consider a checkout that calls POST /api/v1/transactions. The request reaches Tokeflow, the charge is initiated downstream, but the connection drops before the response gets back to your server. Your client doesn't know whether the charge succeeded. If it retries blindly, you risk charging the customer twice.

An idempotency key removes that ambiguity. You attach a unique key to the mutation. Tokeflow records the outcome against that key. If the same key arrives again — whether from an automatic retry, a double-click, or a queue redelivery — Tokeflow returns the original result instead of performing the operation a second time.

The idempotency key

Provide the key one of two ways. The header takes precedence if both are present.

LocationFieldNotes
HTTP headerIdempotency-Key: <unique>Recommended. Case-insensitive.
Request body"idempotency_key": "<unique>"Useful when the header is hard to set (e.g. some queue/webhook relays).

The value is an opaque string you generate. It must be unique per logical operation — that is, the same key should map to exactly one intended action.

:::tip Recommended key construction Derive the key deterministically from the business operation, not randomly. A good pattern combines a stable business identifier with an attempt counter:

  • order_12345_attempt_1
  • cart-a1b2c3:checkout
  • A UUID v4 generated once per logical operation and reused across that operation's retries.

The rule: every retry of the same intended action reuses the same key. A genuinely new action (e.g. the customer chooses to pay again after a failure) gets a new key. :::

Do not generate a fresh random key on every HTTP attempt. That defeats the mechanism — each attempt would be treated as a new, distinct charge.

Replay semantics

Idempotency is scoped per merchant and per key. The first request with a given key executes normally; subsequent requests with the same key replay the stored result.

ScenarioHTTP statusBody
First request with key K201 CreatedThe created resource
Replay with the same key K200 OKThe same body as the first response

The status code is the signal: 201 means you created something new, 200 means you hit a replay. The response body is identical in both cases, so your code can treat them uniformly and use the status only for observability.

Replay returns the original stored response — it does not re-run routing or re-contact the provider. A replayed 200 is a guarantee that no second charge occurred.

Which endpoints accept idempotency keys

Use idempotency keys on create and charge mutations — any POST that has a side effect you would not want duplicated. The most important one is creating a transaction:

curl -X POST https://api.tokeflow.com/api/v1/transactions \
  -H "Authorization: Bearer sk_live_mer_xxx" \
  -H "Idempotency-Key: order_12345_attempt_1" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 15000,
    "currency": "BRL",
    "payment_method": "credit_card",
    "customer_id": "cust_7h2k9",
    "payment_instrument_id": "pi_4a1c8",
    "capture": true,
    "metadata": { "campaign": "black_friday" }
  }'

First response (201 Created):

{
  "success": true,
  "data": {
    "id": "tx_9a8b7c6d",
    "status": "authorized",
    "amount": 15000,
    "currency": "BRL",
    "payment_method": "credit_card",
    "charge_type": "payment",
    "customer_id": "cust_7h2k9",
    "metadata": { "campaign": "black_friday" },
    "created_at": "2026-01-15T12:30:00.000Z"
  },
  "request_id": "req_8f3c2a91",
  "timestamp": "2026-01-15T12:30:00.000Z"
}

Replaying the same Idempotency-Key returns the identical data with HTTP 200 OK.

Idempotency-Key is required on POST /api/v1/transactions. Omitting it returns 400 validation_error. This is intentional — payment creation must always be replay-safe.

{
  "error": {
    "type": "validation_error",
    "code": "IDEMPOTENCY_KEY_REQUIRED",
    "message": "Idempotency key is required. Provide it via Idempotency-Key header or idempotency_key in request body.",
    "details": {},
    "request_id": "req_1b2c3d4e",
    "timestamp": "2026-01-15T12:30:00.000Z"
  }
}

TTL guidance

Idempotency keys are retained for a limited window, not forever. Treat the guarantee as covering the active lifetime of an operation and its retries — typically minutes to hours, well within any reasonable retry budget.

Practical implications:

  • Retry an operation promptly. Don't reuse a key days later and expect a replay.
  • For a genuinely new attempt after a long gap, mint a new key.
  • Never depend on idempotency keys as a long-term deduplication store. Use your own order/transaction records for that.

See Errors for the full error-type reference and Transactions for the create-transaction contract.


Rate limits

How limits are counted

Every limit is a count of requests in a 60-second window, kept per credential and per endpoint: each API key (or public key, for the Bridge SDK) has its own budget on each route, so a burst on one endpoint never uses up another's, and an Organization key and a Merchant key never share a budget. What the ceiling is depends on the kind of traffic the route carries.

Route classRoutesDefault limit per key, per route
PaymentMoney-moving and state-changing writes: creating transactions, captures, voids and refunds; checkout sessions and their items; customers, payment instruments and subscription actions.600 requests / minute
ReadListings and lookups: transactions, refunds, orders, checkout sessions, customers, payment instruments, subscriptions and their transitions.600 requests / minute
Checkout eventsPOST /checkout-sessions/:id/events. Also limited per checkout session, so one noisy session cannot exhaust the key.1,200 requests / minute per key; 60 / minute per session
Bridge SDKRoutes the browser calls with a public key — sessions, tokens, payer tokens, checkout context. Also limited per client IP, so one visitor cannot exhaust the merchant's budget.600 requests / minute per public key
Card verification (SDK)The SDK's zero-amount card check. Kept low and separate, because it is what a card tester would target.60 requests / minute per public key
Everything elseRoutes without a class.100 requests / minute

POST /checkout-sessions/:id/identify is a payment-class route that is also limited per checkout session.

These are defaults. If a workload needs more — bulk imports, high-volume checkouts, reconciliation jobs — contact the Tokeflow team: limits can be raised for a specific key, per route class. A revoked or expired key loses its budget at once.

When you are limited

A limited request is rejected with 429 Too Many Requests before it reaches the resource. The response says which budget blocked it and when to come back:

HeaderMeaning
X-RateLimit-LimitThe limit of the budget that blocked the request.
X-RateLimit-Remaining0 on a rejected request.
X-RateLimit-ResetSeconds until that budget's counter empties.
Retry-AfterSeconds until you may retry.
{
  "error": {
    "type": "rate_limit_error",
    "code": "RATE_LIMIT_EXCEEDED",
    "message": "Too many requests. Please retry after a short delay.",
    "details": { "retry_after_seconds": 12, "limit": 600 },
    "request_id": "req_9z8y7x6w",
    "timestamp": "2026-01-15T12:30:00.000Z"
  }
}

Designing for rate limits

A robust client treats 429 as an expected, recoverable signal rather than a failure.

  • Honor Retry-After. When a 429 carries it, wait at least that many seconds before the next attempt.
  • Back off exponentially. Wait progressively longer between retries (e.g. 0.5s, 1s, 2s, 4s) instead of hammering immediately.
  • Add jitter. Randomize each delay so concurrent clients don't retry in lockstep and re-collide ("thundering herd").
  • Cap attempts. Give up after a bounded number of retries and surface the error.
  • Combine with idempotency. When you retry a POST after a 429, reuse the same Idempotency-Key so the eventual success can't double-charge.
  • Spread load. Batch where the API supports it, and avoid tight polling loops — prefer webhooks for state changes.

Retry with exponential backoff (Node)

This helper retries on 429 (and transient 5xx) using exponential backoff with full jitter. Because it forwards the same headers — including Idempotency-Key — on each attempt, retried mutations stay safe.

/**
 * fetch wrapper with exponential backoff + jitter for 429 / 5xx.
 * Pass a stable Idempotency-Key in `init.headers` so retried POSTs are replay-safe.
 */
async function tokeflowFetch(url, init = {}, { maxRetries = 5, baseMs = 500 } = {}) {
  for (let attempt = 0; ; attempt++) {
    const res = await fetch(url, init);

    const retryable = res.status === 429 || (res.status >= 500 && res.status <= 599);
    if (!retryable || attempt >= maxRetries) {
      return res; // success, non-retryable error, or out of attempts
    }

    // Exponential backoff with full jitter: random delay in [0, base * 2^attempt].
    const delay = Math.floor(Math.random() * baseMs * 2 ** attempt);
    await new Promise((resolve) => setTimeout(resolve, delay));
  }
}

// Usage — idempotency key generated once and reused across all retries.
const res = await tokeflowFetch("https://api.tokeflow.com/api/v1/transactions", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.TOKEFLOW_SECRET_KEY}`,
    "Idempotency-Key": "order_12345_attempt_1",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    amount: 15000,
    currency: "BRL",
    payment_method: "credit_card",
    customer_id: "cust_7h2k9",
    payment_instrument_id: "pi_4a1c8",
    capture: true,
  }),
});

const { data } = await res.json();

Keep the secret key (sk_*) server-side only. The retry logic above belongs in your backend. Never embed a secret key in browser or mobile client code.


Putting it together

GoalUse
Prevent duplicate charges on retryIdempotency-Key on POST /api/v1/transactions
Distinguish new vs replayed result201 (new) vs 200 (replay)
Survive bursts and 429sExponential backoff + jitter
Avoid double-charging during backoffSame idempotency key across all retries

Related reading:

  • Authentication — key types, scopes, and the Authorization header.
  • Errors — every error type and HTTP-status mapping.
  • Transactions — the full create-transaction contract.
  • Webhooks — react to state changes instead of polling.

On this page