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. Successful responses and deterministic terminal failures are stored. Failures that happen before side effects, and failures explicitly safe to retry, release the key for another attempt.
  3. Keys expire after 24 hours. After that, the same key executes as a brand-new request. Inventory quantity commands are the exception: see below.

Every Write Accepts a Key, and Some Require One#

Every mutating /v1 endpoint (POST, PATCH, PUT, DELETE) accepts Idempotency-Key. Each endpoint's API reference entry documents the header and says whether it is required.

On most writes the key is optional. If you omit it, 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.

Commands that require your key#

Every command that changes an inventory quantity rejects a request with no Idempotency-Key, returning 400 IDEMPOTENCY_KEY_REQUIRED. Flint will not generate one for you, because a generated key cannot protect the case that actually matters: your request succeeded, the response never reached you, and you retry.

That covers inventory adjustments, safety stock changes, counts, reservations and their transitions, reallocations, transfers and their transitions, and POST /v1/inventory-return-receipts. Customer Returns use the idempotency rules documented in the Returns guide. POST /v1/payment-links/{payment_link_id}/resolve also requires one.

These keys are also retained differently. An inventory key stays bound to the movements it produced for at least as long as those movements exist, well past the 24-hour window above, so a warehouse or webhook replay that arrives days late still cannot double-count. Replaying one returns the original result, including the inventory levels as they were when the command committed rather than whatever the quantities happen to be now.

Endpoints that mint fresh secrets

A few endpoints deliberately do not replay. POST /v1/merchant-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. POST /v1/delivery-previews and POST /v1/checkout-sessions/{checkout_session_id}/query-pickup-availability are compute-only queries whose buyer-location input and result are never persisted, so each request performs a fresh evaluation. 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 or deterministic terminal error with Idempotency-Replayed: trueTreat the replay exactly like the original 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",
    "doc_url": "https://developers.withflintpay.com/docs/errors",
    "request_log_url": "/v1/developer/request-logs?request_id=8a6d3f1c-2b9e-4d7a-b4c8-5f0e2a7c9b16",
    "error_source": "merchant",
    "param": "idempotency_key",
    "remediation": {
      "retryable": false,
      "next_steps": "Use a new idempotency key when the logical request changes."
    }
  }
}

Failure replay follows side effects

Flint stores a deterministic terminal error when the same immutable request would fail the same way again. A failure before durable side effects releases the key, as does a failure whose downstream operation is independently idempotent and safe to retry. An unclassified failure after side effects fails closed: the key remains reserved so a blind retry cannot duplicate work. 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 webhook-id header as your durable deduplication key there; the legacy X-Flint-Webhook-ID header carries the same value. 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.
  • Rate Limits: default limits and how Retry-After behaves.
  • CLI: --idempotency-key sets the header on any write, which matters most for the hand-run commands that are easiest to repeat by accident.
Rate this doc