Idempotency

A payment request that times out leaves you in the worst possible state: you do not know whether it worked. Retrying blind risks a duplicate charge. Not retrying risks a lost sale. Idempotency keys remove the dilemma. Send the same key with every retry and Flint guarantees the operation runs once, no matter how many times the request arrives.

How It Works#

Add an Idempotency-Key header to any write request. The value is a string you generate, up to 255 characters, that uniquely names the business action you are performing.

Bash
curl -X POST https://api.withflintpay.com/v1/refunds \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Idempotency-Key: refund-ord-8112-full" \
  -d '{
    "order_id": "ord_8112",
    "amount_money": { "amount": 2500, "currency": "USD" },
    "reason": "requested_by_customer"
  }'

The first request executes normally and Flint stores the successful response. If the same request arrives again within 24 hours (same key, same endpoint, same body), Flint does not execute it a second time. It returns the stored response and marks the replay in the headers:

HTTP
HTTP/1.1 201 Created
Idempotency-Key: refund-ord-8112-full
Idempotency-Replayed: true

The response body carries the same marker in meta, and meta.request_id always identifies the current request, so retries stay traceable in your logs:

JSON
{
  "data": {
    "refund_id": "ref_9f2c81",
    "status": "succeeded",
    "amount_money": { "amount": 2500, "currency": "USD" },
    "order_id": "ord_8112"
  },
  "request_id": "d5f2b8c6-4e7a-4c9d-8f21-6b3a9e5c7d04",
  "meta": {
    "request_id": "d5f2b8c6-4e7a-4c9d-8f21-6b3a9e5c7d04",
    "idempotency_replayed": true
  }
}

Three facts define the contract:

  1. Replay identity is the key plus the endpoint plus the request body. All three must match.
  2. Only successful responses are stored. A failed request is never replayed.
  3. Keys expire after 24 hours. After that, the same key executes as a brand-new request.

Every Write Accepts a Key#

Every mutating /v1 endpoint (POST, PATCH, PUT, DELETE) accepts Idempotency-Key. There is no support matrix to memorize; each endpoint's API reference entry documents the header.

If you omit the key, Flint generates one for that request and returns it in the Idempotency-Key response header. A generated key is unique to that single attempt, so it provides no retry protection. Treat it as a debugging aid, and always send your own key on writes that matter, above all on anything that moves money.

Endpoints that mint fresh secrets

A few endpoints deliberately do not replay. POST /v1/account-sessions validates retries like any other write, but a successful retry returns a fresh session with a fresh client secret instead of the previous one. POST /v1/demo-sessions/reset always provisions a fresh demo sandbox. In the other direction, writes that return a one-time secret, such as POST /v1/api-keys, do include the secret again on an identical replay, so a retried create never strands you without the credential.

Choosing a Key#

A key names a business action, not an HTTP attempt. "Refund order 8112 in full" is one action; every retry of it carries the same key.

  • One action, one key. Generate the key at the moment your system decides to perform the action, not when it builds the HTTP request.
  • Persist it first. Store the key with the pending action before the first attempt. If your process crashes mid-request, the recovery path reads the stored key and retries safely.
  • UUID v4 is the right default. Any string up to 255 characters works, and a durable identifier you already have (a job ID, refund-{your-reference}) is equally good, as long as it can never describe two different actions.
  • New action, new key. If you change anything about the request, including fixing a rejected field, that is a new action and needs a fresh key.
JavaScript
import { randomUUID } from "crypto";

// One key per action, created and persisted before the first attempt
const refundJob = await db.refundJobs.create({
  orderId: order.id,
  idempotencyKey: randomUUID(),
});

// Every attempt, first or fifth, sends the same stored key
await flintRequest("v1/refunds", refundBody, refundJob.idempotencyKey);

Keys longer than 255 characters are rejected with a 400 and error.code: INVALID_IDEMPOTENCY_KEY.

Replay, Conflict, or In Progress#

A repeated key resolves in exactly one of three ways:

You sendFlint returnsWhat to do
Same key, same bodyThe original 2xx response with Idempotency-Replayed: trueNothing. You already have the result.
Same key, different body409 with error.code: IDEMPOTENCY_KEY_REUSEDDo not retry. Two different actions shared a key; fix your key generation.
Same key while the first attempt is still executing409 with error.code: IDEMPOTENCY_KEY_IN_PROGRESSBack off briefly and retry with the same key. Once the original completes, retries replay it.

Both conflicts use the standard error envelope:

JSON
{
  "error": {
    "type": "conflict_error",
    "code": "IDEMPOTENCY_KEY_REUSED",
    "message": "This Idempotency-Key was already used with different request parameters.",
    "request_id": "8a6d3f1c-2b9e-4d7a-b4c8-5f0e2a7c9b16",
    "param": "idempotency_key"
  }
}

Only successes replay

Flint stores a response for replay only when the request succeeds. Errors are never replayed; a retry after a failure is processed on its own merits. To send a corrected request after a validation error, generate a new key. The corrected body is a new action, and reusing the old key returns IDEMPOTENCY_KEY_REUSED.

A Production Retry Loop#

Combine a persisted key with backoff and jitter, and retry only what is safe to retry. Network failures, where you never saw a response at all, are exactly the case idempotency keys exist for.

JavaScript
const RETRYABLE_TYPES = new Set([
  "rate_limit_error",
  "external_service_error",
  "timeout_error",
  "unavailable_error",
]);

async function createRefundSafely(refundJob) {
  let delay = 1000;

  for (let attempt = 1; attempt <= 4; attempt++) {
    try {
      return await flintRequest(
        "v1/refunds",
        refundJob.body,
        refundJob.idempotencyKey,
      );
    } catch (err) {
      // No response at all: the key makes this retry safe
      const networkFailure = !err.type;
      // The first attempt is still settling: retry with the same key
      const inProgress = err.code === "IDEMPOTENCY_KEY_IN_PROGRESS";
      const retryable =
        networkFailure || inProgress || RETRYABLE_TYPES.has(err.type);

      if (!retryable || attempt === 4) throw err;

      const waitMs = err.retryAfterMs ?? delay;
      await new Promise((resolve) =>
        setTimeout(resolve, waitMs + Math.random() * 500),
      );
      delay = Math.min(delay * 2, 60_000);
    }
  }
}

flintRequest is the parsing helper from the Error Handling guide, extended to send the Idempotency-Key header. Note that IDEMPOTENCY_KEY_IN_PROGRESS is the one conflict_error worth retrying; every other conflict means stop and investigate.

Scope and Lifetime#

Keys are namespaced, so the collisions you might worry about cannot happen:

  • Per endpoint. The same key sent to POST /v1/refunds and POST /v1/orders never conflicts.
  • Per merchant. Keys never collide across merchants.
  • Per environment. Test mode and live mode are fully independent, and each sandbox has its own key space. A key used in test can never replay in live.

Keys are honored for 24 hours from the original request. Past that window, Flint treats the key as brand new and executes the request again. Keep automated retries well inside 24 hours, and never deliberately recycle an old key for a new action; keys are cheap, so generate fresh ones.

Idempotency Keys and Webhook Deduplication#

Idempotency keys deduplicate the writes you send to Flint. Webhooks need the same discipline in the other direction: Flint retries deliveries until your endpoint acknowledges them, so your handler can receive the same event more than once. Use the X-Flint-Webhook-ID header as your durable deduplication key there. A production integration needs both: keys on requests going in, dedup on events coming out.

Practical Rules#

  • Send your own Idempotency-Key on every write that matters, without exception on anything that moves money.
  • Generate the key when the action is decided, and persist it before the first attempt.
  • Reuse a key only to retry the identical request. Anything you change, including a fixed validation error, gets a new key.
  • Treat IDEMPOTENCY_KEY_REUSED as a bug in your key handling, not a transient error.
  • Treat IDEMPOTENCY_KEY_IN_PROGRESS as a signal to back off and retry the same key.
  • Respect Retry-After on 429, and add jitter to every backoff.

Next Steps#

  • Error Handling: the error envelope, error types, and the base retry strategy.
  • Webhooks: verify signatures and deduplicate deliveries with webhook-id or legacy X-Flint-Webhook-ID.
  • Rate Limits: default limits and how Retry-After behaves.
Rate this doc