Docs
API 2026-05-27.dahlia Dashboard

Get started

Retry any POST with the Idempotency-Key header so it never charges twice, and see how an unknown result ended.

A network error leaves you not knowing whether Fluveo got your request. With an Idempotency-Key, you can send the same request again and Fluveo runs it at most once. The retry gets the first answer back.

Send an Idempotency-Key on every POST. It matters most on the ones that move money: creating or confirming a PaymentIntent, capturing it, creating a refund or a payout.

Choose the key#

Build the key from your own record and the action: order-1042-pay, order-1042-refund-1. Such a key needs no storage of its own: after a crash, your code builds the same key again from the order and the retry reuses it. Use one key per order and per action, and a new suffix for each new attempt you mean as a separate operation, such as a second refund (order-1042-refund-2).

A random key, such as a UUID, also works when the operation has no record of its own to name it. Save it with the operation before the first try and read it back for every retry; a key generated again inside the retry is a new operation.

Create a PaymentIntent with an Idempotency-Key
curl https://api.fluveo.com/v1/payment_intents \
  -H "Authorization: Bearer sk_test_..." \
  -H "Idempotency-Key: order-1042-pay" \
  -d amount=2000 \
  -d currency=usd \
  -d "payment_method_types[]=card" \
  -d payment_method=pm_card_visa \
  -d confirm=true \
  -d "metadata[order_id]=1042"
async function pay(orderId) {
  const response = await fetch("https://api.fluveo.com/v1/payment_intents", {
    method: "POST",
    headers: { Authorization: "Bearer sk_test_...", "Idempotency-Key": `order-${orderId}-pay` },
    body: new URLSearchParams({
      amount: "2000",
      currency: "usd",
      "payment_method_types[]": "card",
      payment_method: "pm_card_visa",
      confirm: "true",
      "metadata[order_id]": String(orderId),
    }),
  });
  return { status: response.status, replayed: response.headers.get("Idempotent-Replayed"), body: await response.json() };
}
import requests

def pay(order_id):
    response = requests.post(
        "https://api.fluveo.com/v1/payment_intents",
        headers={"Authorization": "Bearer sk_test_...", "Idempotency-Key": f"order-{order_id}-pay"},
        data={
            "amount": 2000,
            "currency": "usd",
            "payment_method_types[]": "card",
            "payment_method": "pm_card_visa",
            "confirm": "true",
            "metadata[order_id]": order_id,
        },
        timeout=30,
    )
    return response.status_code, response.headers.get("Idempotent-Replayed"), response.json()
<?php
function pay(string $orderId): array
{
    $ch = curl_init("https://api.fluveo.com/v1/payment_intents");
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_POST => true,
        CURLOPT_HTTPHEADER => ["Authorization: Bearer sk_test_...", "Idempotency-Key: order-$orderId-pay"],
        CURLOPT_POSTFIELDS => http_build_query([
            "amount" => 2000,
            "currency" => "usd",
            "payment_method_types" => ["card"],
            "payment_method" => "pm_card_visa",
            "confirm" => "true",
            "metadata" => ["order_id" => $orderId],
        ]),
    ]);
    $body = json_decode(curl_exec($ch), true);
    return [curl_getinfo($ch, CURLINFO_HTTP_CODE), $body];
}

Put your order id in metadata too, as above. It is how you find the payment when its result is unknown (see below).

How Fluveo treats a key#

  • POST only. Fluveo reads the header on POST requests. GET and DELETE ignore it, because reading twice changes nothing.
  • Length. A key has 1 to 255 characters. An empty or longer key answers 400 with param Idempotency-Key.
  • Scope. A key belongs to your store and to the mode of the key you sent (test or live). Another store can use the same string without any effect on yours.
  • Lifetime. Fluveo keeps the answer for a key for 7 days. Three requests keep it only 24 hours: POST /v1/checkout/sessions, POST /v1/checkout/sessions/{session} and POST /v1/webhook_endpoints. After the window, the same key starts a new request, and a create makes a new object.
  • Webhook endpoints. Fluveo also keeps at most the 32 newest webhook endpoint keys of your store in each mode. An older key starts a new request, even inside 24 hours. A replay of an endpoint you changed or deleted since comes back without its secret.
  • Same request. Fluveo compares the method, the path and every parameter of the two requests.
  • No key sent. A POST without an Idempotency-Key gets a key Fluveo makes for it, shown in the answer's Idempotency-Key header and in request.idempotency_key of the events the request causes. That key is not kept, so sending the request again runs it again.
  • File uploads. POST /v1/files ignores the key: sending an upload again, with the same key or not, makes another file.

What a retry gets back#

You sendFluveo answers
The same key and the same request, after the first one finishedThe first answer again, with the same status and body, plus the header Idempotent-Replayed: true. Nothing runs twice.
The same key, while the first request still runs, with the same request or a different one409 with code idempotency_key_in_use. Nothing runs. Wait a moment and retry with the same key.
The same key with a different request, after the first one finished400 with type idempotency_error. Nothing runs. Use a new key for a new operation.

A replayed answer can be an error. If the first try was declined with 402, the retry gets the same 402. To try another card, make a new request with a new key.

When to retry#

AnswerRetry with the same key and body?
No answer: a timeout or a network errorYes.
409, 429Yes, after a wait. On 429, wait at least the seconds in Retry-After.
5xxYes, after a wait that grows with each try.
500 with Idempotent-Replayed: trueNo. The result is unknown and the same key gives the same answer. Reconcile instead.
400, 401, 402, 403, 404No. Read the error, fix the request, and send the fixed request with a new key.

When the result is unknown#

Sometimes Fluveo cannot tell at once whether a payment went through, for example when a payment network does not answer. Fluveo then answers 500 with Fluveo-Should-Retry: false and keeps that answer for the key, with no time limit: a retry with the same key gets the same 500 back with Idempotent-Replayed: true. It keeps checking the payment on its own and sends the events once it knows.

Your own timeout can leave you in the same place: you sent the request and got nothing back. Send it again with the same key and body first. When it answers, that answer is the result. When it still answers nothing, or answers the 500 with Idempotent-Replayed: true, mark the order as unknown and reconcile it:

  1. Never send the operation under a new key. That can charge the buyer twice.
  2. Find the PaymentIntent. Search for your order id with GET /v1/payment_intents/search and query=metadata["order_id"]:"1042". A PaymentIntent can be found as soon as its write returns. Its status tells you where the payment is.
  3. Or wait for its event. payment_intent.succeeded or payment_intent.payment_failed arrives on your webhook, or shows in GET /v1/events with type (or up to 20 types[]) and created. Events are kept 30 days. Match data.object.metadata.order_id. An event made during your request also carries your key in request.idempotency_key; an event Fluveo makes later, after it checked the payment on its own, has request.id and request.idempotency_key set to null, so match on the metadata.
  4. Decide on the final state only. Fulfil on succeeded. Leave the order unknown while the PaymentIntent is still processing.
Find the PaymentIntent of order 1042
curl -G https://api.fluveo.com/v1/payment_intents/search \
  -H "Authorization: Bearer sk_test_..." \
  --data-urlencode 'query=metadata["order_id"]:"1042"'
async function findPayment(orderId) {
  const params = new URLSearchParams({ query: `metadata["order_id"]:"${orderId}"` });
  const response = await fetch(`https://api.fluveo.com/v1/payment_intents/search?${params}`, {
    headers: { Authorization: "Bearer sk_test_..." },
  });
  const result = await response.json();
  return result.data[0] ?? null;
}
import requests

def find_payment(order_id):
    result = requests.get(
        "https://api.fluveo.com/v1/payment_intents/search",
        headers={"Authorization": "Bearer sk_test_..."},
        params={"query": f'metadata["order_id"]:"{order_id}"'},
        timeout=30,
    ).json()
    return result["data"][0] if result["data"] else None
<?php
function findPayment(string $orderId): ?array
{
    $params = http_build_query(["query" => 'metadata["order_id"]:"' . $orderId . '"']);
    $ch = curl_init("https://api.fluveo.com/v1/payment_intents/search?" . $params);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER => ["Authorization: Bearer sk_test_..."],
    ]);
    $result = json_decode(curl_exec($ch), true);
    return $result["data"][0] ?? null;
}

Test it#

  1. Create a PaymentIntent with Idempotency-Key: test-idem-1. Note its id.
  2. Send the exact same request again. You get the same id and Idempotent-Replayed: true.
  3. Send it again with amount=3000 and the same key. You get 400 and idempotency_error.
  4. List your PaymentIntents. Only one was created.
  5. List your events with type=payment_intent.succeeded. The newest one has request.idempotency_key test-idem-1.

Next#