On this page
- 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_ (up to 128 characters) on every checkout session and enforces rules on it, not on the key.
| Idempotency-Key | order_id | |
|---|---|---|
| Protects | One HTTP request and its retries | One order, or one subscription sign-up |
| Who makes it | The SDK (a UUID per call) or you | You, from your own database |
| Lifetime | 24 hours, then forgotten | For as long as the order exists |
| Scope | Per application | Per application too: each order belongs to one |
| A repeat gets | The stored response, header Idempotent-Replayed: true | The open session, a replacement, or a 409 |
The decision rule follows: derive order_ 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.
| Response | When | What to do |
|---|---|---|
400 idempotency_ | No header, or an empty one | Send one; the SDK always does |
400 idempotency_ | Over 255 characters, spaces, non-printable characters | 1 to 255 printable ASCII, no spaces |
422 idempotency_ | The key was used with a different request | A new key for a new request; the SDK never retries this |
409 idempotency_ | The first request with this key is still running | Retry with the same key; the SDK does |
409 idempotency_ | It completed, but the saved response cannot be read | Look 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:
- Your server sends POST /v1/checkout-sessions for order
ord_with key A. Fianto creates the session; the response is lost to a timeout.1042 - Retry with key A: Fianto replays the first response, url included, with Idempotent-Replayed: true. Done.
- Retry with a fresh key B instead: Fianto finds the open session for
ord_and returns it with url: null. No second session exists, so no double charge, but you have no link.1042 - 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. 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:
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_, the route answers 409 order_, so give changed terms a new order id.
Every v1 endpoint, its error codes and the SDKs that call it.
Read the developer overviewWhat 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_id | Create again with it | Webhooks |
|---|---|---|
| Its session is still OPEN | The same session, url: null; reissue the link to get a new one | None |
| Its session expired or was cancelled | A new session replaces it; the order is reopened | checkout.session.expired for a lapsed one, no order.expired |
| The order is PAID | 409 order_already_paid | None |
| A completed subscription session used it | 409 order_id_in_use | None |
| Never used | A new session, with url | None 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. 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 v1 | Fianto v1 | |
|---|---|---|
| Which requests | Every POST accepts a key; GET and DELETE need none | Every POST requires one: 400 without it |
| Key length | Up to 255 characters | 1 to 255 printable ASCII, no spaces |
| How long it is kept | May be pruned once at least 24 hours old | 24 hours |
| A first request that errored | Once the endpoint has started, retries get the saved response, 500 errors included | The key is released; the retry runs again |
| Different parameters, same key | An error | 422 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., 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.