---
title: "Idempotency"
description: "Retry any POST with the Idempotency-Key header so it never charges twice, and see how an unknown result ended."
url: https://docs.fluveo.com/start/idempotency/
---

> Index of every page: https://docs.fluveo.com/llms.txt. Integrating Fluveo? Read https://docs.fluveo.com/agents/integrate.md first.

> Too long? This page with code in one language only: https://docs.fluveo.com/start/idempotency.curl.md (cURL), https://docs.fluveo.com/start/idempotency.node.md (Node.js), https://docs.fluveo.com/start/idempotency.python.md (Python), https://docs.fluveo.com/start/idempotency.php.md (PHP).

# Idempotency

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:

```bash
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"
```

Node.js:

```javascript
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() };
}
```

Python:

```python
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:

```php
<?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](https://docs.fluveo.com/start/idempotency.md#unknown-result)).

## 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 send | Fluveo answers |
| --- | --- |
| The same key and the same request, after the first one finished | The 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 one | `409` 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 finished | `400` 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

| Answer | Retry with the same key and body? |
| --- | --- |
| No answer: a timeout or a network error | Yes. |
| `409`, `429` | Yes, after a wait. On `429`, wait at least the seconds in `Retry-After`. |
| `5xx` | Yes, after a wait that grows with each try. |
| `500` with `Idempotent-Replayed: true` | No. The result is unknown and the same key gives the same answer. [Reconcile](https://docs.fluveo.com/start/idempotency.md#unknown-result) instead. |
| `400`, `401`, `402`, `403`, `404` | No. 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:

```bash
curl -G https://api.fluveo.com/v1/payment_intents/search \
  -H "Authorization: Bearer sk_test_..." \
  --data-urlencode 'query=metadata["order_id"]:"1042"'
```

Node.js:

```javascript
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;
}
```

Python:

```python
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:

```php
<?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`.

> **For AI agents:**
>
> Send Idempotency-Key on every POST. Derive it from the order and the action (order-1042-pay, order-1042-refund-1), so a retry after a crash rebuilds the same key; a random key is fine only if you save it before the first try. Reuse it on every retry of that same request, and never change the parameters under an existing key. A key lasts 7 days, but only 24 hours on POST /v1/checkout/sessions, POST /v1/checkout/sessions/{session} and POST /v1/webhook_endpoints. Retry with the same key on a timeout, a network error, 409, 429 and 5xx, but stop when a 500 comes back with Idempotent-Replayed: true: mark the order unknown and reconcile it with GET /v1/payment_intents/search and query=metadata["order_id"]:"<order id>", or by its payment_intent.succeeded / payment_intent.payment_failed event. Never send it again under a new key.

## Next

- [Pagination](https://docs.fluveo.com/start/pagination.md): Read every object in a list.
- [Errors](https://docs.fluveo.com/errors.md): Every error type and code, and which ones to retry.
