No Fianto fee during the beta. Any fee comes with at least 30 days notice. See pricing
Developers9 min read

Idempotency keys and order ids: two guards against charging a customer twice.

An idempotency key is a unique value a client sends with a write so that a retry returns the first result instead of doing the work again; it protects one HTTP request. An order id protects the business event: Fianto refuses a second checkout for an order that is already paid with 409 order_already_paid. Fianto requires an Idempotency-Key on every v1 POST, keeps it for 24 hours, and marks a replay with Idempotent-Replayed: true.

On this page
What is an idempotency key in a payments API?Why does an idempotency key not stop a double charge on its own?How does the Fianto API handle the Idempotency-Key header?Why does the checkout create call need the same key on retry?What happens when you reuse an order id with Fianto?How do Fianto idempotency keys compare with Stripe?Can a customer still pay twice on chain?SourcesQuestions
TL;DR
  • Idempotency-Key: required on all five v1 POST endpoints, 1 to 255 printable ASCII characters, kept 24 hours per application. The SDK adds one for you.
  • order_id: your own id for the thing being paid. A paid order answers 409 order_already_paid; a subscription's order_id serves one completed sign-up, then 409 order_id_in_use.
  • Neither stops a payer sending USDC twice on chain: Fianto records the extra transfer on the order, sends order.duplicate_payment when the order is already paid, and only you can refund it.

What is an idempotency key in a payments API?

An idempotency key in a payments API is a string the client attaches to a write so the server can recognise a retry of the same request and answer it with the stored result. Idempotent means that doing something twice has the same effect as doing it once (the crypto payment glossary defines it with the other payment terms). A GET already is; a POST that creates a checkout is not, and a network timeout leaves you unable to tell whether the first one happened.

The convention has an HTTP header name, Idempotency-Key, and an IETF draft that describes it. Its abstract says the header "can be used to make non-idempotent HTTP methods such as POST or PATCH fault-tolerant" (draft-ietf-httpapi-idempotency-key-header-07, dated 15 October 2025). As of 15 October 2026 the IETF datatracker lists that draft as expired, so it is not an RFC; payment APIs follow it by convention.

Why does an idempotency key not stop a double charge on its own?

An idempotency key does not stop a double charge on its own because it only matches requests, and a second click, a second tab or a restarted worker usually sends a fresh key. To the server, that is a new request. What has to stay the same across all of those is the thing being paid for, which is why Fianto asks for your own order_id (up to 128 characters) on every checkout session and enforces rules on it, not on the key.

Idempotency-Keyorder_id
ProtectsOne HTTP request and its retriesOne order, or one subscription sign-up
Who makes itThe SDK (a UUID per call) or youYou, from your own database
Lifetime24 hours, then forgottenFor as long as the order exists
ScopePer applicationPer application too: each order belongs to one
A repeat getsThe stored response, header Idempotent-Replayed: trueThe open session, a replacement, or a 409

The decision rule follows: derive order_id from your order, never from a timestamp or a random value made per click. Then the key can be random and nothing bad happens when it changes.

How does the Fianto API handle the Idempotency-Key header?

The Fianto API refuses any v1 POST without an Idempotency-Key header, and today there are five: create a checkout session, cancel it, reissue its link, cancel a subscription and send a test event. A key is 1 to 255 printable ASCII characters with no spaces, and keys are separate per application. Within 24 hours, the same key with the same method, path, body and query returns the stored response with Idempotent-Replayed: true. If the first request failed with an error, the key is released and can be used again.

ResponseWhenWhat to do
400 idempotency_key_requiredNo header, or an empty oneSend one; the SDK always does
400 idempotency_key_invalidOver 255 characters, spaces, non-printable characters1 to 255 printable ASCII, no spaces
422 idempotency_key_reusedThe key was used with a different requestA new key for a new request; the SDK never retries this
409 idempotency_request_in_progressThe first request with this key is still runningRetry with the same key; the SDK does
409 idempotency_response_unreadableIt completed, but the saved response cannot be readLook the order up first, e.g. GET /v1/orders/lookup?order_id=

Those status codes match the IETF draft. Its error-handling section says a server SHOULD reply with 400 when a required key is missing, 422 when a key is reused with a different payload, and 409 while the first request with that key is still being processed. It also says: "The resource SHOULD define such expiration policy and publish it in the documentation." Fianto's policy is the 24 hours stated on its idempotency docs page.

Why does the checkout create call need the same key on retry?

The checkout create call needs the same key on retry because its response carries the session's url once, and only a replay returns it again. Every later read of the session shows url: null. So the expensive mistake is not a double charge; it is a lost link. Coding agents make the same mistake, and the Fianto agent skill for AI coding agents states the rule for them.

Walk through a create whose response never arrived:

  1. Your server sends POST /v1/checkout-sessions for order ord_1042 with key A. Fianto creates the session; the response is lost to a timeout.
  2. Retry with key A: Fianto replays the first response, url included, with Idempotent-Replayed: true. Done.
  3. Retry with a fresh key B instead: Fianto finds the open session for ord_1042 and returns it with url: null. No second session exists, so no double charge, but you have no link.
  4. Recover from step 3 with POST /v1/checkout-sessions/{id}/link. You get a new url, and the old one stops working.

The SDK makes step 2 the default. Each POST gets a crypto.randomUUID() made once per call and reused on every automatic retry of that call; by default it retries up to twice. When it gives up on a network failure, the ConnectionError or TimeoutError carries the idempotencyKey it used. That covers a process that keeps running. If your process can crash between creating a session and saving its url, derive the key from data you already hold, so the restarted process sends the same one:

checkout.ts
import { Fianto } from '@fianto/sdk';

const fianto = new Fianto(); // reads FIANTO_APP_ID and FIANTO_APP_SECRET

export async function createCheckout(orderId: string, attempt: number) {
  return fianto.checkoutSessions.create(
    {
      mode: 'payment',
      order_id: orderId,
      amount: '12.50',
      description: 'Coffee beans, 1 kg',
      success_url: 'https://shop.example/thank-you',
      cancel_url: 'https://shop.example/cart',
    },
    // The same order and attempt always send the same key.
    { idempotencyKey: `checkout:${orderId}:${attempt}` },
  );
}

Increment attempt only when you deliberately want a different request, for example after the earlier session expired. If you use the SDK's ready-made checkout route for Next.js, Express or Hono (see accepting USDC with Hono), it does step 4 for you: an open session with the same terms gets its link reissued. With different terms for the same order_id, the route answers 409 order_session_mismatch, so give changed terms a new order id.

Every v1 endpoint, its error codes and the SDKs that call it.

Read the developer overview

What happens when you reuse an order id with Fianto?

Reusing an order id with Fianto gives one of five answers, depending on the state of the order. This is the guard that stops a customer paying twice for the same thing, so know each case before you write the code that picks the id.

State of the order_idCreate again with itWebhooks
Its session is still OPENThe same session, url: null; reissue the link to get a new oneNone
Its session expired or was cancelledA new session replaces it; the order is reopenedcheckout.session.expired for a lapsed one, no order.expired
The order is PAID409 order_already_paidNone
A completed subscription session used it409 order_id_in_useNone
Never usedA new session, with urlNone until the payer acts

Subscriptions are the case people miss. An order id can serve one completed subscription checkout; when the same customer subscribes again after a cancel, the new sign-up needs a new id. A counter per customer, bumped when subscription.created arrives, does it; there is a worked Express version in USDC subscriptions in Express.

How do Fianto idempotency keys compare with Stripe?

Fianto and Stripe share the header, the 255-character limit and the 24-hour window, and differ on whether the key is optional and on what an error does to it. The Stripe column is from Stripe's idempotent requests reference for its v1 API, read 15 October 2026.

Stripe API v1Fianto v1
Which requestsEvery POST accepts a key; GET and DELETE need noneEvery POST requires one: 400 without it
Key lengthUp to 255 characters1 to 255 printable ASCII, no spaces
How long it is keptMay be pruned once at least 24 hours old24 hours
A first request that erroredOnce the endpoint has started, retries get the saved response, 500 errors includedThe key is released; the retry runs again
Different parameters, same keyAn error422 idempotency_key_reused

The error row matters most when you port retry code. On Stripe v1 a 500 is sticky for that key; on Fianto the same key runs the request again after an error, which is what you usually want for a create. Stripe's page also says: "Avoid using sensitive data (for example, email addresses or personal identifiers) as idempotency keys." That holds for any API: build keys from your own order id and an attempt number.

Can a customer still pay twice on chain?

A customer can still pay twice on chain, because neither guard controls what a wallet sends. Every session has a reference that rides on the price transfer, and Fianto keeps looking for transfers carrying it for 24 hours after the session closes. A transfer with that reference that is not the order's own payment is recorded on the order as unmatched; when the order is already PAID, or another payment for it is under way, Fianto also sends order.duplicate_payment, naming the transfer's signature and amount.

That USDC is already in your wallet. Fianto never holds funds and has no refund API, so a refund is a transfer you send back yourself. The dashboard's Needs attention view lists orders with an unmatched transfer; what to do there is the subject of when a customer pays twice, and the refund itself is covered in chargebacks vs crypto refunds. On the receiving side, webhooks have their own duplicate rule: delivery is at least once, so dedupe on the event id as described in verifying webhook signatures.

Sources
Questions

Questions about idempotency keys and order ids.

How long does Fianto keep an idempotency key?

For 24 hours, per application. Within that time the same key with the same request returns the stored response with Idempotent-Replayed: true.

Should an idempotency key be random or derived?

Random is fine while your process stays up, and the SDK makes a UUID per call. Derive it from your order id and an attempt number if a crash could make you resend the request from a fresh process.

What does 422 idempotency_key_reused mean?

The key was already used with a different request body, path or query. Use a new key for a new request; the SDK does not retry this error.

Do GET requests need an Idempotency-Key?

No. Fianto requires the header on v1 POST requests only. Reads change nothing, so repeating them is already safe.

Why did my create return url: null?

The order_id already had an open session, usually because a retry went out with a fresh key. Reissue the link with POST /v1/checkout-sessions/{id}/link, or retry with the original key to get the stored response.

Read next.

Developers10 min read

USDC subscriptions in Express: a checkout route, a webhook route and a gate

To sell a USDC subscription from Express, register two routes from @fianto/express before express.json(): checkout() creates a subscription session for a recurring price, and webhooks() verifies the subscription.* events you store. Your own middleware then lets a user in while their stored status is ACTIVE or PAST_DUE. A subscription never creates an order, so order.paid never fires for it.

Developers8 min read

Add USDC checkout with an AI coding agent: the Fianto agent skill

Run npx skills add fianto-xyz/skills in your project, then ask your coding agent to add Fianto checkout: the skill gives it the real API, the official @fianto/* packages and the rules that keep money safe. It cannot create your merchant account, your API keys or your webhook secret, and it is told to ask you before anything irreversible, such as cancelling a real subscription or moving USDC.

Product9 min read

Customer paid twice in crypto: working through the Needs attention queue

When a customer paid twice in USDC, the second payment is already in your wallet: Fianto never holds funds, so there is nothing to cancel and no refund button. Fianto lists the order under Needs attention, in the table "Orders with an unmatched transfer", and sends order.duplicate_payment to your webhook. Check the transfer on an explorer, then send the extra USDC back from your own wallet to the wallet it came from.

Key the request, id the order.

Then a retry can never become a second charge.

New posts are announced on X and Discord.