---
title: "Integrate Fluveo with an AI agent"
description: "The playbook an AI agent follows in test mode, from the key to a verified payment, webhook and refund."
url: https://docs.fluveo.com/agents/integrate/
---

> 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/agents/integrate.curl.md (cURL), https://docs.fluveo.com/agents/integrate.node.md (Node.js), https://docs.fluveo.com/agents/integrate.python.md (Python), https://docs.fluveo.com/agents/integrate.php.md (PHP).

# Integrate Fluveo with an AI agent

The playbook an AI agent follows in test mode, from the key to a verified payment, webhook and refund.

This is the playbook an AI coding agent follows to add Fluveo payments to an app. It works in test mode from start to end. People can read it too: it is the shortest complete path through the API.

Follow the steps in order. Step 12 lists the checks that prove the integration works.

> **For AI agents:**
>
> Do every step yourself, in order. Never skip step 12: run each check and report its result.
> Ask a person before you use a live key, change a production setting or move real money. Everything in this playbook runs in test mode.
> If a call answers something this page does not describe, read the error's `message` and `param`, fix the request, and do not invent a feature from another payments API to work around it.

## 1. Know what Fluveo is, and what it is not

- Fluveo is a payments processor with its own `/v1` API: PaymentIntents, Charges, Refunds, Disputes, Payouts, Events, lists and errors.
- You call it over plain HTTP at `https://api.fluveo.com/v1` with form-encoded bodies (`application/x-www-form-urlencoded`). No server SDK is supported: install none.
- Each merchant has its own Fluveo account (`mch_...`) and its own keys. A call always acts on the account of the key that sent it, never on another account, and no body field sets a fee.
- **One API for every store.** Every store makes the same calls and gets the same answers. Card, Pix and Boleto are payment methods of one call, `POST /v1/payment_intents`; the currencies each method charges in are in [the methods table](https://docs.fluveo.com/payments.md#payment-methods).
- Checkout [platforms](https://docs.fluveo.com/platforms.md) are only places where a merchant pastes its own Fluveo key.

Fluveo refuses these on purpose. Each refusal is a normal error object (see step 11):

| If you send | Fluveo answers |
| --- | --- |
| A header that acts for another account | 400 `invalid_request_error` with that header in `param`: payment routing and fee fields are server-controlled |
| `transfer_data`, `transfer_group`, `on_behalf_of`, `application_fee_amount`, `application_fee` or `account` in a body | 400 `invalid_request_error` with that name in `param` and the same message |
| Raw card data: `card[number]`, `payment_method_data[card][number]`, any `cvc` | 402 (raw card data is not accepted), or 400 naming the field |
| A `Fluveo-Version` header that names any version but `2026-05-27.dahlia` | 400 `invalid_request_error` with no code: Invalid Fluveo API version, then the value sent |
| A `metadata` key that starts with `fluveo_` | 400 `invalid_request_error`: the prefix is reserved |
| A parameter the endpoint does not take | 400 `invalid_request_error`, code `parameter_unknown` |
| An endpoint Fluveo does not serve | an error (404 or 403), never a result |
| A key that starts with `sk_live_` | 403: live mode is not open yet |

Send no `Fluveo-Version` header: every request uses `2026-05-27.dahlia`, Fluveo's only API version.

> **For AI agents:**
>
> Before you call an endpoint or send a parameter, find it in https://docs.fluveo.com/openapi.json. If it is not there, it does not exist on Fluveo, even when another payments API has it. Install no server SDK from npm, PyPI or Packagist. In the browser the only packages are @fluveo/fluveo-js and @fluveo/react-fluveo-js, which only load fluveo.js; find them in https://docs.fluveo.com/payments/fluveo-js.md#packages.

The Fluveo MCP server is not available in production yet.

## 2. Get the merchant's test key

A person creates the key; the agent never does.

1. The merchant signs in to the [Fluveo Dashboard](https://dash.fluveo.com) and opens **Settings > API keys**.
2. They click **Create secret key** while the bottom of the sidebar shows **Test mode**, and name the key after the app. The key starts with `sk_test_`. The Dashboard shows it only once.
3. They store it as an environment variable, for example `FLUVEO_SECRET_KEY`, in the app's local `.env` file (kept out of git) and in the server's secret settings.

The secret key is for server code only. It never goes into a browser, a mobile app, a log line or a commit.

> **For AI agents:**
>
> If `FLUVEO_SECRET_KEY` is not set, stop and ask the person to set it. Never ask them to paste the key into the chat, and never write a key into a source file, a test fixture or a commit.

## 3. Check the key with a first call

`GET /v1/account` changes nothing. It answers with the merchant's account: its `id` (`mch_...`) and its `default_currency`, a currency the account accepts. Use it as the `currency` of a payment unless the order is in another currency the account accepts.

**Read the account**

cURL:

```bash
curl https://api.fluveo.com/v1/account \
  -H "Authorization: Bearer $FLUVEO_SECRET_KEY"
```

Node.js:

```javascript
// Node 18 or later: fetch is built in.
const API = "https://api.fluveo.com/v1";
const KEY = process.env.FLUVEO_SECRET_KEY; // sk_test_...

async function fluveo(method, path, params, idempotencyKey) {
  const headers = { Authorization: `Bearer ${KEY}` };
  if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey;
  const query = method === "GET" && params ? "?" + new URLSearchParams(params) : "";
  const body = method === "POST" ? new URLSearchParams(params || {}) : undefined;
  const res = await fetch(API + path + query, { method, headers, body });
  const json = await res.json();
  if (!res.ok) throw Object.assign(new Error(json.error.message), { status: res.status, error: json.error, headers: res.headers });
  return json;
}

const account = await fluveo("GET", "/account");
console.log(account.id, account.default_currency);
```

Python:

```python
import os
import requests

API = "https://api.fluveo.com/v1"
KEY = os.environ["FLUVEO_SECRET_KEY"]  # sk_test_...

class FluveoError(Exception):
    def __init__(self, status, error, retry_after=None, replayed=None):
        super().__init__(f"{status} {error}")
        self.status = status
        self.error = error
        self.retry_after = retry_after
        self.replayed = replayed

def fluveo(method, path, params=None, idempotency_key=None):
    headers = {"Authorization": f"Bearer {KEY}"}
    if idempotency_key:
        headers["Idempotency-Key"] = idempotency_key
    response = requests.request(
        method,
        API + path,
        headers=headers,
        params=params if method == "GET" else None,
        data=params if method == "POST" else None,
        timeout=30,
    )
    body = response.json()
    if response.status_code >= 400:
        raise FluveoError(
            response.status_code,
            body["error"],
            response.headers.get("Retry-After"),
            response.headers.get("Idempotent-Replayed"),
        )
    return body

account = fluveo("GET", "/account")
print(account["id"], account["default_currency"])
```

PHP:

```php
<?php
class FluveoException extends RuntimeException
{
    public function __construct(
        public int $status,
        public ?array $error,
        public ?int $retryAfter = null,
        public ?string $replayed = null,
    )
    {
        parent::__construct($status . ' ' . json_encode($error), $status);
    }
}

function fluveo(string $method, string $path, array $params = [], ?string $idempotencyKey = null): array
{
    $url = 'https://api.fluveo.com/v1' . $path;
    $headers = ['Authorization: Bearer ' . getenv('FLUVEO_SECRET_KEY')];
    if ($idempotencyKey !== null) {
        $headers[] = 'Idempotency-Key: ' . $idempotencyKey;
    }
    $ch = curl_init();
    if ($method === 'POST') {
        curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($params));
    } elseif ($params) {
        $url .= '?' . http_build_query($params);
    }
    $retryAfter = null;
    $replayed = null;
    curl_setopt_array($ch, [
        CURLOPT_URL => $url,
        CURLOPT_CUSTOMREQUEST => $method,
        CURLOPT_HTTPHEADER => $headers,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => 30,
        CURLOPT_HEADERFUNCTION => function ($ch, string $line) use (&$retryAfter, &$replayed): int {
            if (preg_match('/^Retry-After:\s*(\d+)/i', $line, $m)) {
                $retryAfter = (int) $m[1];
            }
            if (preg_match('/^Idempotent-Replayed:\s*(\w+)/i', $line, $m)) {
                $replayed = strtolower($m[1]);
            }
            return strlen($line);
        },
    ]);
    $raw = curl_exec($ch);
    if ($raw === false) {
        throw new FluveoException(0, ['message' => curl_error($ch)]);
    }
    $body = json_decode($raw, true);
    $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    if ($status >= 400) {
        throw new FluveoException($status, $body['error'], $retryAfter, $replayed);
    }
    return $body;
}

$account = fluveo('GET', '/account');
echo $account['id'], ' ', $account['default_currency'], "\n";
```

The later steps reuse this `fluveo` helper. A 401 means the key is missing or wrong; a 403 on a `sk_live_` key means live mode is not open yet.

## 4. Create a payment

A payment is a PaymentIntent (`pi_...`). Create it on your server with the amount in the smallest currency unit and an `Idempotency-Key` that names the business action, such as `order-1042-pay`. Use one key per order and per action, and keep it with the order so a retry can reuse it.

### One call for every method

Card, Pix and Boleto go through the same call. Only the payment method fields change: `payment_method_types[]` and either `payment_method` (a card) or `payment_method_data` (a Pix or a Boleto). Send a `currency` your account accepts and the method charges in ([methods table](https://docs.fluveo.com/payments.md#payment-methods)); any other answers 400 with `param` set to `currency`.

|  | Card | Pix |
| --- | --- | --- |
| `currency` | `usd` in this example | `brl` in this example |
| `payment_method_types[]` | `card` | `pix` |
| The method | `payment_method=pm_card_visa` (test) | `payment_method_data[type]=pix` and the payer's name, email, phone and CPF (or CNPJ) in `payment_method_data[billing_details]` |
| The answer | `status: "succeeded"` | `status: "requires_action"` and a Pix code to show the buyer (step 5) |

**Create and confirm a payment: card or Pix**

cURL:

```bash
# Card
METHOD=(-d currency=usd -d "payment_method_types[]=card" -d payment_method=pm_card_visa)
# Pix: use this line instead
# METHOD=(-d currency=brl -d "payment_method_types[]=pix" -d "payment_method_data[type]=pix" --data-urlencode "payment_method_data[billing_details][name]=Ada Lima" --data-urlencode "payment_method_data[billing_details][email]=ada@example.com" --data-urlencode "payment_method_data[billing_details][phone]=+5511999999999" -d "payment_method_data[billing_details][tax_id]=12345678909")

curl https://api.fluveo.com/v1/payment_intents \
  -H "Authorization: Bearer $FLUVEO_SECRET_KEY" \
  -H "Idempotency-Key: order-1042-pay" \
  -d amount=2000 \
  "${METHOD[@]}" \
  -d confirm=true \
  -d "metadata[order_id]=1042"
```

Node.js:

```javascript
const METHODS = {
  card: {
    currency: "usd",
    "payment_method_types[]": "card",
    payment_method: "pm_card_visa",
  },
  pix: {
    currency: "brl",
    "payment_method_types[]": "pix",
    "payment_method_data[type]": "pix",
    "payment_method_data[billing_details][name]": "Ada Lima",
    "payment_method_data[billing_details][email]": "ada@example.com",
    "payment_method_data[billing_details][phone]": "+5511999999999",
    "payment_method_data[billing_details][tax_id]": "12345678909",
  },
};

async function pay(orderId, method) {
  const response = await fetch("https://api.fluveo.com/v1/payment_intents", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.FLUVEO_SECRET_KEY}`,
      "Idempotency-Key": `order-${orderId}-pay`,
    },
    body: new URLSearchParams({
      amount: "2000",
      ...METHODS[method],
      confirm: "true",
      "metadata[order_id]": orderId,
    }),
  });
  return response.json();
}

const intent = await pay("1042", "card");
console.log(intent.id, intent.status); // pi_... succeeded; with "pix": requires_action
```

Python:

```python
METHODS = {
    "card": {
        "currency": "usd",
        "payment_method_types[]": "card",
        "payment_method": "pm_card_visa",
    },
    "pix": {
        "currency": "brl",
        "payment_method_types[]": "pix",
        "payment_method_data[type]": "pix",
        "payment_method_data[billing_details][name]": "Ada Lima",
        "payment_method_data[billing_details][email]": "ada@example.com",
        "payment_method_data[billing_details][phone]": "+5511999999999",
        "payment_method_data[billing_details][tax_id]": "12345678909",
    },
}

intent = fluveo("POST", "/payment_intents", {
    "amount": 2000,
    **METHODS["card"],
    "confirm": "true",
    "metadata[order_id]": "1042",
}, idempotency_key="order-1042-pay")
print(intent["id"], intent["status"])  # pi_... succeeded; with "pix": requires_action
```

PHP:

```php
$methods = [
    'card' => [
        'currency' => 'usd',
        'payment_method_types[]' => 'card',
        'payment_method' => 'pm_card_visa',
    ],
    'pix' => [
        'currency' => 'brl',
        'payment_method_types[]' => 'pix',
        'payment_method_data[type]' => 'pix',
        'payment_method_data[billing_details][name]' => 'Ada Lima',
        'payment_method_data[billing_details][email]' => 'ada@example.com',
        'payment_method_data[billing_details][phone]' => '+5511999999999',
        'payment_method_data[billing_details][tax_id]' => '12345678909',
    ],
];

$intent = fluveo('POST', '/payment_intents', [
    'amount' => 2000,
    ...$methods['card'],
    'confirm' => 'true',
    'metadata[order_id]' => '1042',
], 'order-1042-pay');
echo $intent['id'], ' ', $intent['status'], "\n"; // pi_... succeeded; with 'pix': requires_action
```

`2000` is $20.00 in `usd` and R$ 20,00 in `brl`. A card answers `status: "succeeded"`. It also shows `capture_method: "automatic_async"`: that is the default when you send no `capture_method`. The card is charged in full as soon as it is approved, exactly as with `automatic`, so a `succeeded` PaymentIntent is already captured and needs no capture call. Only `capture_method=manual` holds the money for a later capture (see below).

A Pix code lasts up to 24 hours; add `payment_method_options[pix][expires_after_seconds]` (1 to 86400) to make it shorter.

Test PaymentMethods (test mode only):

| PaymentMethod | Result |
| --- | --- |
| `pm_card_visa`, `pm_card_visa_debit`, `pm_card_mastercard`, `pm_card_amex` | The payment succeeds. |
| `pm_card_visa_chargeDeclined` | 402 `card_error`, code `card_declined`, `decline_code` `generic_decline` |
| `pm_card_visa_chargeDeclinedInsufficientFunds` | 402 `card_error`, code `card_declined`, `decline_code` `insufficient_funds` |
| `pm_card_chargeDeclinedExpiredCard` | 402 `card_error`, code `expired_card` |
| `pm_card_chargeDeclinedIncorrectCvc` | 402 `card_error`, code `incorrect_cvc` |
| `pm_card_chargeDeclinedProcessingError` | 402 `card_error`, code `processing_error` |
| `pm_card_refundFail` | The payment succeeds; a refund of it fails later (`refund.failed`). |

Any other `pm_card_...` name is answered with 404. To authorize now and capture later, add `capture_method=manual`: the PaymentIntent stops at `requires_capture` until you call `POST /v1/payment_intents/{id}/capture`. Real buyers' cards are collected on Fluveo's hosted pages, never by your server; see [Cards](https://docs.fluveo.com/payments/cards.md).

### Boleto: the same call

A Boleto needs the payer's CPF or CNPJ in `payment_method_data[boleto][tax_id]`, a name, an email and an address in Brazil. The amount is at least `200` (R$ 2,00). Set `payment_method_options[boleto][expires_after_days]` (0 to 60) for the due date. The answer has `status: "requires_action"` and the Boleto's details (step 5). The payer always goes in the request: a Customer never stands in for it. Now and then the answer is `status: "processing"` with `next_action: null`, because the line is not ready yet. Do not create another Boleto: wait for `payment_intent.requires_action`, or read the PaymentIntent again, and show the Boleto then.

**Create a R$ 15,00 Boleto**

cURL:

```bash
curl https://api.fluveo.com/v1/payment_intents \
  -H "Authorization: Bearer $FLUVEO_SECRET_KEY" \
  -H "Idempotency-Key: order-2002-pay" \
  -d amount=1500 \
  -d currency=brl \
  -d "payment_method_types[]=boleto" \
  -d confirm=true \
  -d "payment_method_data[type]=boleto" \
  -d "payment_method_data[boleto][tax_id]=12345678909" \
  --data-urlencode "payment_method_data[billing_details][name]=Ada Lima" \
  --data-urlencode "payment_method_data[billing_details][email]=ada@example.com" \
  --data-urlencode "payment_method_data[billing_details][address][line1]=1000, Av. Paulista, Bela Vista" \
  --data-urlencode "payment_method_data[billing_details][address][line2]=Bela Vista" \
  --data-urlencode "payment_method_data[billing_details][address][city]=Sao Paulo" \
  -d "payment_method_data[billing_details][address][state]=SP" \
  -d "payment_method_data[billing_details][address][postal_code]=01310100" \
  -d "payment_method_data[billing_details][address][country]=BR" \
  -d "payment_method_options[boleto][expires_after_days]=3"
```

Node.js:

```javascript
const boleto = await fluveo("POST", "/payment_intents", {
  amount: "1500",
  currency: "brl",
  "payment_method_types[]": "boleto",
  confirm: "true",
  "payment_method_data[type]": "boleto",
  "payment_method_data[boleto][tax_id]": "12345678909",
  "payment_method_data[billing_details][name]": "Ada Lima",
  "payment_method_data[billing_details][email]": "ada@example.com",
  "payment_method_data[billing_details][address][line1]": "1000, Av. Paulista, Bela Vista",
  "payment_method_data[billing_details][address][line2]": "Bela Vista",
  "payment_method_data[billing_details][address][city]": "Sao Paulo",
  "payment_method_data[billing_details][address][state]": "SP",
  "payment_method_data[billing_details][address][postal_code]": "01310100",
  "payment_method_data[billing_details][address][country]": "BR",
  "payment_method_options[boleto][expires_after_days]": "3",
}, "order-2002-pay");
console.log(boleto.status, boleto.next_action.boleto_display_details.number);
```

Python:

```python
boleto = fluveo("POST", "/payment_intents", {
    "amount": 1500,
    "currency": "brl",
    "payment_method_types[]": "boleto",
    "confirm": "true",
    "payment_method_data[type]": "boleto",
    "payment_method_data[boleto][tax_id]": "12345678909",
    "payment_method_data[billing_details][name]": "Ada Lima",
    "payment_method_data[billing_details][email]": "ada@example.com",
    "payment_method_data[billing_details][address][line1]": "1000, Av. Paulista, Bela Vista",
    "payment_method_data[billing_details][address][line2]": "Bela Vista",
    "payment_method_data[billing_details][address][city]": "Sao Paulo",
    "payment_method_data[billing_details][address][state]": "SP",
    "payment_method_data[billing_details][address][postal_code]": "01310100",
    "payment_method_data[billing_details][address][country]": "BR",
    "payment_method_options[boleto][expires_after_days]": 3,
}, idempotency_key="order-2002-pay")
print(boleto["status"], boleto["next_action"]["boleto_display_details"]["number"])
```

PHP:

```php
$boleto = fluveo('POST', '/payment_intents', [
    'amount' => 1500,
    'currency' => 'brl',
    'payment_method_types[]' => 'boleto',
    'confirm' => 'true',
    'payment_method_data[type]' => 'boleto',
    'payment_method_data[boleto][tax_id]' => '12345678909',
    'payment_method_data[billing_details][name]' => 'Ada Lima',
    'payment_method_data[billing_details][email]' => 'ada@example.com',
    'payment_method_data[billing_details][address][line1]' => '1000, Av. Paulista, Bela Vista',
    'payment_method_data[billing_details][address][line2]' => 'Bela Vista',
    'payment_method_data[billing_details][address][city]' => 'Sao Paulo',
    'payment_method_data[billing_details][address][state]' => 'SP',
    'payment_method_data[billing_details][address][postal_code]' => '01310100',
    'payment_method_data[billing_details][address][country]' => 'BR',
    'payment_method_options[boleto][expires_after_days]' => 3,
], 'order-2002-pay');
echo $boleto['status'], ' ', $boleto['next_action']['boleto_display_details']['number'], "\n";
```

For a Boleto, send the payer's tax id only in `payment_method_data[boleto][tax_id]`; a `billing_details[tax_id]` is refused.

### Card numbers

Card data never goes through your server: never put raw card numbers in a `/v1` call. Confirm with a PaymentMethod id, or send the buyer to [the hosted checkout page](https://docs.fluveo.com/payments/checkout.md). See [Cards](https://docs.fluveo.com/payments/cards.md).

### Test payments with Pix and Boleto

In test mode Fluveo's test bank pays a Pix or a Boleto through the same path as a real payment, by the payer email in `payment_method_data[billing_details][email]`: a local part ending in `succeed_immediately` is paid within seconds, any other email about 3 minutes after the confirm, and one ending in `expire_immediately`, `expire_with_delay` or `fill_never` is never paid; a Pix with no email is never paid on its own. So an agent proves the success path with `succeed_immediately@example.com` and waits for `payment_intent.succeeded`, and proves expiry with `expire_immediately@example.com` on a Pix created with `payment_method_options[pix][expires_after_seconds]=60`, which expires unpaid a few minutes later; that email itself only means never paid.

A person can also pay a waiting one by hand. Open the `hosted_instructions_url` of a Pix, or the `hosted_voucher_url` of a Boleto, follow **simular o pagamento** and press **Autorizar pagamento de teste**. Once paid, canceled or expired, it is never paid again.

> **For AI agents:**
>
> Do not call any `/__mock/...` or `/v1/test_helpers/...` path to pay a test Pix or Boleto. They are not part of the merchant API.

## 5. Show the buyer what `next_action` asks for

When `status` is `requires_action`, the PaymentIntent's `next_action.type` tells you what the buyer must do:

| `next_action.type` | Fields | Show the buyer |
| --- | --- | --- |
| `pix_display_qr_code` | `data`, `expires_at`, `image_url_png`, `image_url_svg`, `hosted_instructions_url` | The QR image and the copy-and-paste code in `data`. Stop showing it after `expires_at` (Unix seconds). |
| `boleto_display_details` | `number`, `expires_at`, `hosted_voucher_url`, `pdf` | The Boleto line (`number`), a link to the voucher or the PDF, and the due date. |

The fields that may be `null` (the image, voucher and PDF links) are optional; always show `data` or `number`. An expired unpaid Pix goes back to `requires_payment_method`. A Boleto remains in `requires_action` until payment fails after its due date. Never treat the buyer's return to your site as proof of payment: wait for the event in step 8.

## 6. Create a webhook endpoint

Create the endpoint by API once per environment and store the `secret` it returns (`whsec_...`) as an environment variable, such as `FLUVEO_WEBHOOK_SECRET`. The secret appears in the create response, and again in a replay of that create: the same `Idempotency-Key` and body within 24 hours answer the same `we_` with its `secret` (without it once the endpoint was changed or deleted). After 24 hours the key is forgotten and the same call makes a second endpoint, so create the endpoint once and keep its `we_` id. The URL must be public: Fluveo never delivers to private addresses. A live-mode endpoint needs HTTPS; a test-mode endpoint also takes plain HTTP. A merchant can have up to 16 endpoints.

**Create a webhook endpoint**

cURL:

```bash
curl https://api.fluveo.com/v1/webhook_endpoints \
  -H "Authorization: Bearer $FLUVEO_SECRET_KEY" \
  -H "Idempotency-Key: webhook-endpoint-production-v1" \
  -d url=https://example.com/webhooks/fluveo \
  -d "enabled_events[]=payment_intent.succeeded" \
  -d "enabled_events[]=payment_intent.payment_failed" \
  -d "enabled_events[]=charge.refunded" \
  -d "enabled_events[]=refund.failed"
```

Node.js:

```javascript
const params = new URLSearchParams({ url: "https://example.com/webhooks/fluveo" });
for (const type of ["payment_intent.succeeded", "payment_intent.payment_failed", "charge.refunded", "refund.failed"]) {
  params.append("enabled_events[]", type);
}
const endpoint = await fluveo("POST", "/webhook_endpoints", params, "webhook-endpoint-production-v1");
console.log(endpoint.id); // we_...
// Store endpoint.secret (whsec_...) as FLUVEO_WEBHOOK_SECRET now.
```

Python:

```python
endpoint = fluveo("POST", "/webhook_endpoints", [
    ("url", "https://example.com/webhooks/fluveo"),
    ("enabled_events[]", "payment_intent.succeeded"),
    ("enabled_events[]", "payment_intent.payment_failed"),
    ("enabled_events[]", "charge.refunded"),
    ("enabled_events[]", "refund.failed"),
], idempotency_key="webhook-endpoint-production-v1")
print(endpoint["id"])  # we_...
# Store endpoint["secret"] (whsec_...) as FLUVEO_WEBHOOK_SECRET now.
```

PHP:

```php
// http_build_query cannot repeat a key, so this call builds the body by hand.
$body = 'url=' . urlencode('https://example.com/webhooks/fluveo');
foreach (['payment_intent.succeeded', 'payment_intent.payment_failed', 'charge.refunded', 'refund.failed'] as $type) {
    $body .= '&' . urlencode('enabled_events[]') . '=' . urlencode($type);
}
$ch = curl_init('https://api.fluveo.com/v1/webhook_endpoints');
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_POSTFIELDS => $body,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer ' . getenv('FLUVEO_SECRET_KEY'),
        'Idempotency-Key: webhook-endpoint-production-v1',
    ],
    CURLOPT_RETURNTRANSFER => true,
]);
$endpoint = json_decode(curl_exec($ch), true);
echo $endpoint['id'], "\n"; // we_...
// Store $endpoint['secret'] (whsec_...) as FLUVEO_WEBHOOK_SECRET now.
```

Fluveo posts each event as JSON. A 2xx answer within 25 seconds counts as delivered. In test mode, a failed delivery is tried again after 30 minutes, 1 hour and 2 hours.

> **For AI agents:**
>
> Never print the `whsec_` secret or commit it. If it was lost, a person rotates it in the Dashboard; there is no API call for that.

## 7. Verify the `Fluveo-Signature` header

Every delivery carries `Fluveo-Signature: t=<unix seconds>,v1=<hex>`. `v1` is the HMAC-SHA256, keyed with the endpoint's whole `whsec_...` secret, of the timestamp, a dot and the raw request body: `t + "." + body`. To check it:

1. Read the body as raw bytes, before any JSON parser touches it.
2. Split the header on `,` and take `t` and every `v1`.
3. Reject the event when `t` is more than 5 minutes away from your clock.
4. Compute the HMAC of `t.body` with your secret and compare it with each `v1` in constant time.
5. Record the event's `id` (`evt_...`) durably and skip it when it is already there, then handle the event and answer 2xx quickly. If handling fails, remove the record and answer 500 so Fluveo delivers it again.

Check `Fluveo-Signature`.

Each handler below listens on port 4242 at `/webhooks/fluveo`, the same port and path as the handlers in [webhook signatures](https://docs.fluveo.com/webhooks/signatures.md), so the endpoint URL of step 6 ends in `/webhooks/fluveo`.

**Verify and handle a webhook (no SDK)**

Node.js:

```javascript
import http from "node:http";
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";

const SECRET = process.env.FLUVEO_WEBHOOK_SECRET; // whsec_...
const TOLERANCE_SECONDS = 300;
// One file per handled evt_ id survives a restart. In production, use a table with a unique evt_ id instead,
// written in the same transaction as the order change.
const HANDLED_DIR = process.env.FLUVEO_HANDLED_DIR || "./fluveo-handled-events";
fs.mkdirSync(HANDLED_DIR, { recursive: true });

function claim(eventId) {
  if (!/^evt_[A-Za-z0-9_]+$/.test(eventId)) throw new Error("not an event id");
  try {
    fs.closeSync(fs.openSync(path.join(HANDLED_DIR, eventId), "wx"));
    return true;
  } catch (err) {
    if (err.code === "EEXIST") return false;
    throw err;
  }
}

function release(eventId) {
  fs.rmSync(path.join(HANDLED_DIR, eventId), { force: true });
}

function handleEvent(event) {
  const object = event.data.object;
  switch (event.type) {
    case "payment_intent.succeeded":
      console.log("paid: mark the order paid and fulfil it", object.metadata.order_id, object.id);
      break;
    case "payment_intent.payment_failed":
      console.log("failed: tell the buyer to try again", object.metadata.order_id, object.id);
      break;
    case "charge.refunded":
      console.log("refunded: record the refund on the order", object.payment_intent, object.id);
      break;
    case "refund.failed":
      console.log("refund failed: flag the order for a person", object.payment_intent, object.id);
      break;
  }
}

function verify(rawBody, header, now = Math.floor(Date.now() / 1000)) {
  let t = null;
  const signatures = [];
  for (const part of (header || "").split(",")) {
    const [key, value] = part.trim().split("=");
    if (key === "t") t = value;
    if (key === "v1") signatures.push(value);
  }
  if (!t || !/^\d+$/.test(t) || Math.abs(now - Number(t)) > TOLERANCE_SECONDS) return false;
  const expected = crypto.createHmac("sha256", SECRET).update(t + ".").update(rawBody).digest();
  return signatures.some((hex) => {
    const given = Buffer.from(hex, "hex");
    return given.length === expected.length && crypto.timingSafeEqual(given, expected);
  });
}

http.createServer((req, res) => {
  if (req.method !== "POST" || req.url !== "/webhooks/fluveo") {
    res.writeHead(404).end();
    return;
  }
  const chunks = [];
  req.on("data", (chunk) => chunks.push(chunk));
  req.on("end", () => {
    const raw = Buffer.concat(chunks);
    if (!verify(raw, req.headers["fluveo-signature"])) {
      res.writeHead(400).end("invalid signature");
      return;
    }
    const event = JSON.parse(raw.toString("utf8"));
    if (claim(event.id)) {
      try {
        handleEvent(event);
      } catch (err) {
        release(event.id);
        res.writeHead(500).end();
        return;
      }
    }
    res.writeHead(200).end();
  });
}).listen(4242);
```

Python:

```python
import hashlib
import hmac
import json
import os
import re
import time
from http.server import BaseHTTPRequestHandler, HTTPServer

SECRET = os.environ["FLUVEO_WEBHOOK_SECRET"].encode()  # whsec_...
TOLERANCE_SECONDS = 300
# One file per handled evt_ id survives a restart. In production, use a table with a unique evt_ id instead,
# written in the same transaction as the order change.
HANDLED_DIR = os.environ.get("FLUVEO_HANDLED_DIR", "./fluveo-handled-events")
os.makedirs(HANDLED_DIR, exist_ok=True)


def claim(event_id: str) -> bool:
    if not re.fullmatch(r"evt_[A-Za-z0-9_]+", event_id):
        raise ValueError("not an event id")
    try:
        os.close(os.open(os.path.join(HANDLED_DIR, event_id), os.O_CREAT | os.O_EXCL | os.O_WRONLY))
        return True
    except FileExistsError:
        return False


def release(event_id: str) -> None:
    try:
        os.remove(os.path.join(HANDLED_DIR, event_id))
    except FileNotFoundError:
        pass


def handle_event(event: dict) -> None:
    obj = event["data"]["object"]
    if event["type"] == "payment_intent.succeeded":
        print("paid: mark the order paid and fulfil it", obj["metadata"].get("order_id"), obj["id"])
    elif event["type"] == "payment_intent.payment_failed":
        print("failed: tell the buyer to try again", obj["metadata"].get("order_id"), obj["id"])
    elif event["type"] == "charge.refunded":
        print("refunded: record the refund on the order", obj["payment_intent"], obj["id"])
    elif event["type"] == "refund.failed":
        print("refund failed: flag the order for a person", obj["payment_intent"], obj["id"])


def verify(raw_body: bytes, header: str, now: int = None) -> bool:
    t, signatures = None, []
    for part in (header or "").split(","):
        key, _, value = part.strip().partition("=")
        if key == "t":
            t = value
        elif key == "v1":
            signatures.append(value)
    if not t or not t.isdigit():
        return False
    now = int(time.time()) if now is None else now
    if abs(now - int(t)) > TOLERANCE_SECONDS:
        return False
    expected = hmac.new(SECRET, t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, signature) for signature in signatures)


class Webhook(BaseHTTPRequestHandler):
    def do_POST(self):
        if self.path != "/webhooks/fluveo":
            self.send_response(404)
            self.end_headers()
            return
        raw = self.rfile.read(int(self.headers.get("Content-Length", 0)))
        if not verify(raw, self.headers.get("Fluveo-Signature")):
            self.send_response(400)
            self.end_headers()
            return
        event = json.loads(raw)
        if claim(event["id"]):
            try:
                handle_event(event)
            except Exception:
                release(event["id"])
                self.send_response(500)
                self.end_headers()
                return
        self.send_response(200)
        self.end_headers()


HTTPServer(("", 4242), Webhook).serve_forever()
```

PHP:

```php
<?php
// php -S 0.0.0.0:4242 webhook.php
if ($_SERVER['REQUEST_METHOD'] !== 'POST' || parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) !== '/webhooks/fluveo') {
    http_response_code(404);
    exit;
}
$secret = getenv('FLUVEO_WEBHOOK_SECRET'); // whsec_...
$raw = file_get_contents('php://input');
$header = $_SERVER['HTTP_FLUVEO_SIGNATURE'] ?? '';

$t = null;
$signatures = [];
foreach (explode(',', $header) as $part) {
    [$key, $value] = array_pad(explode('=', trim($part), 2), 2, '');
    if ($key === 't') {
        $t = $value;
    } elseif ($key === 'v1') {
        $signatures[] = $value;
    }
}

$valid = false;
if ($t !== null && ctype_digit($t) && abs(time() - (int) $t) <= 300) {
    $expected = hash_hmac('sha256', $t . '.' . $raw, $secret);
    foreach ($signatures as $signature) {
        if (hash_equals($expected, $signature)) {
            $valid = true;
        }
    }
}
if (!$valid) {
    http_response_code(400);
    exit('invalid signature');
}

$event = json_decode($raw, true);

// One file per handled evt_ id survives a restart. In production, use a table with a unique evt_ id instead,
// written in the same transaction as the order change.
$handledDir = getenv('FLUVEO_HANDLED_DIR') ?: __DIR__ . '/fluveo-handled-events';
if (!is_dir($handledDir)) {
    mkdir($handledDir, 0700, true);
}
if (!preg_match('/^evt_[A-Za-z0-9_]+$/', $event['id'] ?? '')) {
    http_response_code(400);
    exit('not an event id');
}
$claimFile = $handledDir . '/' . $event['id'];
$claim = @fopen($claimFile, 'x');
if ($claim === false) {
    http_response_code(file_exists($claimFile) ? 200 : 500);
    exit;
}
fclose($claim);

function handleEvent(array $event): void
{
    $object = $event['data']['object'];
    switch ($event['type']) {
        case 'payment_intent.succeeded':
            error_log('paid: mark the order paid and fulfil it ' . ($object['metadata']['order_id'] ?? '') . ' ' . $object['id']);
            break;
        case 'payment_intent.payment_failed':
            error_log('failed: tell the buyer to try again ' . ($object['metadata']['order_id'] ?? '') . ' ' . $object['id']);
            break;
        case 'charge.refunded':
            error_log('refunded: record the refund on the order ' . $object['payment_intent'] . ' ' . $object['id']);
            break;
        case 'refund.failed':
            error_log('refund failed: flag the order for a person ' . $object['payment_intent'] . ' ' . $object['id']);
            break;
    }
}

try {
    handleEvent($event);
} catch (Throwable $err) {
    unlink($claimFile);
    http_response_code(500);
    exit;
}
http_response_code(200);
```

**Test it without a delivery.** Sign a sample body yourself with the same secret and send it to your handler: build `t` from the current time, compute the HMAC of `t.body`, and post the body with `Fluveo-Signature: t=<t>,v1=<hex>`. The handler must answer 200. Change one byte of the body, or use a `t` older than 5 minutes, and it must answer 400.

## 8. Fulfil only on the success event

Ship the goods, grant access or mark the order paid only when a verified event says the payment succeeded. A visit to your thank-you page is not a payment: that address can be typed by anyone, and a buyer who paid may never load it.

| Event | What to do |
| --- | --- |
| `payment_intent.succeeded` | Mark the order paid and fulfil it. `data.object` is the PaymentIntent; find your order through its `metadata` or its `id`. |
| `payment_intent.payment_failed` | Tell the buyer and let them try again. |
| `charge.refunded` | Record the refund on the order. `data.object` is the Charge: `amount_refunded` is the total refunded so far, and `refunded` is `true` once all of it is. |
| `refund.failed` | The refund did not go through; flag the order for a person. |

Events can arrive more than once and in any order. Keep the handler idempotent: check the order's state before you change it, and key every side effect on the `evt_` or `pi_` id. To confirm a state, you may read the object again with `GET /v1/payment_intents/{id}`.

## 9. Refund

Refund with `POST /v1/refunds`, the `payment_intent` and an `Idempotency-Key` of its own. Leave `amount` out to refund everything that is left, or send a smaller amount for a partial refund.

**Refund $5.00 of a payment**

cURL:

```bash
curl https://api.fluveo.com/v1/refunds \
  -H "Authorization: Bearer $FLUVEO_SECRET_KEY" \
  -H "Idempotency-Key: order-1042-refund-1" \
  -d payment_intent=pi_... \
  -d amount=500
```

Node.js:

```javascript
const refund = await fluveo("POST", "/refunds", {
  payment_intent: intent.id,
  amount: "500",
}, "order-1042-refund-1");
console.log(refund.id, refund.status); // re_...
```

Python:

```python
refund = fluveo("POST", "/refunds", {
    "payment_intent": intent["id"],
    "amount": 500,
}, idempotency_key="order-1042-refund-1")
print(refund["id"], refund["status"])  # re_...
```

PHP:

```php
$refund = fluveo('POST', '/refunds', [
    'payment_intent' => $intent['id'],
    'amount' => 500,
], 'order-1042-refund-1');
echo $refund['id'], ' ', $refund['status'], "\n"; // re_...
```

Cards take full and partial refunds, in every currency. A Pix can be refunded, also in part, up to 90 days after payment. **A Boleto cannot be refunded**: the API answers 400. See [Refund a payment](https://docs.fluveo.com/payments/refunds.md).

**A refund does not change the PaymentIntent.** Even after a full refund, the PaymentIntent keeps `status: "succeeded"`: the payment did happen. The refund shows on the Charge (the PaymentIntent's `latest_charge`): its `amount_refunded` grows by each refund, and `refunded` turns `true` when the whole amount is back. Each refund sends `refund.created` and `charge.refunded`. Track refunds on your order from `charge.refunded`, never from the PaymentIntent's status.

A refund is paid from the store's own balance, counting both what is available and what is still pending. When that covers it, the refund leaves at once with `status: "succeeded"`. When it does not:

- **A card refund** is still accepted: 200 with a `re_...` refund in `status: "pending"` and `pending_reason: "insufficient_funds"`, then `refund.created` and `charge.refunded`; its amount already counts in the Charge's `amount_refunded`. Fluveo sends it once the balance covers it and tells you with `refund.updated`. If the balance still falls short 45 days later, it ends `failed` with `failure_reason: "insufficient_funds"` and `refund.failed`.
- **A Pix refund** is refused: 400 `balance_insufficient`, and nothing is refunded.

A `pending` refund is a correct, wired call, not a bug in your code. See [When your balance is too low](https://docs.fluveo.com/payments/refunds.md#balance-too-low).

> **For AI agents:**
>
> A card refund answers 200 with a re_ refund whose status is succeeded, or pending with pending_reason insufficient_funds while the store's balance does not cover it. Treat pending as a correct, wired call: never send the refund again, and never with a new Idempotency-Key; wait for refund.updated (or refund.failed). A Pix refund the balance does not cover answers 400 balance_insufficient; never retry it in a loop. After a full refund the PaymentIntent stays succeeded; read the refund from the Charge (refunded, amount_refunded) or the charge.refunded event.

## 10. Retry safely

Fluveo keeps the answer to each `Idempotency-Key` for 7 days. A POST sent again with the same key and the same body gets the first answer back, with the header `Idempotent-Replayed: true`, and nothing happens twice. `POST /v1/webhook_endpoints` is the exception: its key lasts 24 hours, so a retry after that makes a second endpoint, which gets every event a second time (step 6).

- **Retry** with the same key and the same body after a timeout, a network error, a 409, a 429 or a 5xx. Wait between tries and grow the wait; on a 429, wait at least the seconds in its `Retry-After` header.
- **Stop on a replayed 500.** When Fluveo cannot tell whether a payment went through, it answers 500 with `Fluveo-Should-Retry: false` and keeps that answer for the key. A retry with the same key gets the same 500 back with `Idempotent-Replayed: true`. Its result is unknown, and the same key gives the same answer every time. Reconcile the order instead (below).
- **Do not retry** a 400, 401, 402, 403 or 404 unchanged. Read the error, fix the request, and send the fixed request with a **new** key: the same key with a different body is refused with 400 `idempotency_error`.
- Two requests with the same key at the same moment: the second gets 409 `idempotency_key_in_use`. Retry it later with the same key.

These samples follow the same rule as [idempotent requests](https://docs.fluveo.com/start/idempotency.md#when-to-retry). The Node, Python and PHP ones reuse the `fluveo` helper of step 3.

**Retry with the same key**

cURL:

```bash
# Up to 4 tries with the same key; stops on any answer that will not change.
for attempt in 1 2 3 4; do
  status=$(curl -s --max-time 30 -o response.json -D headers.txt -w "%{http_code}" https://api.fluveo.com/v1/payment_intents \
    -H "Authorization: Bearer $FLUVEO_SECRET_KEY" \
    -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")
  if [ "$status" = 500 ] && grep -qi '^Idempotent-Replayed: *true' headers.txt; then echo "result unknown: reconcile"; break; fi
  case "$status" in
    000|409|429|5??) sleep $((2 ** attempt)) ;;
    *) break ;;
  esac
done
cat response.json
```

Node.js:

```javascript
async function withRetries(method, path, params, idempotencyKey, tries = 4) {
  for (let attempt = 1; ; attempt++) {
    try {
      return await fluveo(method, path, params, idempotencyKey);
    } catch (err) {
      const unknownResult = err.status === 500 && err.headers?.get("idempotent-replayed") === "true";
      const retryable = !unknownResult && (!err.status || err.status >= 500 || err.status === 409 || err.status === 429);
      if (!retryable || attempt === tries) throw err;
      const retryAfterMs = err.status === 429 ? Number(err.headers.get("retry-after")) * 1000 || 0 : 0;
      await new Promise((resolve) => setTimeout(resolve, Math.max(2 ** attempt * 500, retryAfterMs)));
    }
  }
}
```

Python:

```python
import time

import requests


def with_retries(method, path, params, idempotency_key, tries=4):
    for attempt in range(1, tries + 1):
        wait = 2 ** attempt * 0.5
        try:
            return fluveo(method, path, params, idempotency_key)
        except (requests.ConnectionError, requests.Timeout):
            if attempt == tries:
                raise
        except FluveoError as err:
            unknown_result = err.status == 500 and (err.replayed or "").lower() == "true"
            retryable = not unknown_result and (err.status >= 500 or err.status in (409, 429))
            if not retryable or attempt == tries:
                raise
            if err.status == 429 and err.retry_after and err.retry_after.isdigit():
                wait = max(wait, int(err.retry_after))
        time.sleep(wait)
```

PHP:

```php
function withRetries(string $method, string $path, array $params, string $idempotencyKey, int $tries = 4): array
{
    for ($attempt = 1; ; $attempt++) {
        try {
            return fluveo($method, $path, $params, $idempotencyKey);
        } catch (FluveoException $err) {
            $unknownResult = $err->status === 500 && $err->replayed === 'true';
            $retryable = !$unknownResult
                && ($err->status === 0 || $err->status >= 500 || $err->status === 409 || $err->status === 429);
            if (!$retryable || $attempt === $tries) {
                throw $err;
            }
            $wait = (2 ** $attempt) * 0.5;
            if ($err->status === 429 && $err->retryAfter !== null) {
                $wait = max($wait, $err->retryAfter);
            }
            usleep((int) ($wait * 1000000));
        }
    }
}
```

### When the result stays unknown

Two cases leave you without a result: a replayed 500 (`Idempotent-Replayed: true`), or tries that all timed out. Fluveo keeps checking such a payment on its own and sends its events once it knows. Mark the order **unknown**, never charge it again under a new key, and reconcile it:

1. List `GET /v1/payment_intents` with `created[gte]` set to a moment before your first try, and find the one whose `metadata.order_id` is your order. This list filters only by `created` and `customer`: there is no metadata filter and no `/search` endpoint, which is why step 4 puts the order id in `metadata`.
2. Or wait for `payment_intent.succeeded` or `payment_intent.payment_failed` on your webhook, or list them with `GET /v1/events?type=...&created[gte]=...`. Match `data.object.metadata.order_id`. An event made during your own request also carries your key in `request.idempotency_key`, but an event Fluveo makes later, once it learns the result, has `request.idempotency_key: null`.
3. Fulfil only when the PaymentIntent is `succeeded`. The full method, with code in each language: [when the result is unknown](https://docs.fluveo.com/start/idempotency.md#unknown-result).

> **For AI agents:**
>
> Never generate a new Idempotency-Key inside a retry loop. Derive the key from the order and the action (`order-1042-pay`, `order-1042-refund-1`), so a retry after a crash rebuilds the same key. Stop retrying on a 500 that comes back with Idempotent-Replayed: true: mark the order unknown and reconcile it by metadata.order_id, never with a new key.

## 11. Read errors

Every error is JSON: `{"error": {"type": ..., "code": ..., "message": ..., "param": ...}}`. `code` and `param` appear when they apply, and a card decline adds `decline_code`. Every response, error or not, carries a `Request-Id` header (`req_...`); log it.

| Status | `type` and `code` | What it means |
| --- | --- | --- |
| 400 | `invalid_request_error`, often with `param` | A parameter is missing, unknown (`parameter_unknown`) or refused. Fix the request. |
| 400 | `idempotency_error` | The key was used before with a different body. Use a new key for the new request. |
| 401 | `invalid_request_error` | The key is missing or wrong. |
| 402 | `card_error`, `card_declined` with a `decline_code` | The card was declined. Ask the buyer for another payment method. |
| 403 | `invalid_request_error` | The key's account cannot use this API, or it is a live key. |
| 404 | `invalid_request_error`, `resource_missing` | No such object for this account, or no such URL. |
| 409 | `idempotency_key_in_use` | Another request is working on the same key. Retry later with the same key. |
| 429 | `invalid_request_error`, `lock_timeout` | Another request is working on the same object. Retry shortly with the same key. |
| 429 | `rate_limit` | Too many requests. Wait the `Retry-After` seconds. |
| 500 | `api_error` | Fluveo failed. Retry with the same key, unless the 500 comes back with `Idempotent-Replayed: true`: then the result is unknown, so reconcile the order (step 10). |

The full list of codes is on [the errors page](https://docs.fluveo.com/errors.md).

## 12. Verify your integration

Run each check in test mode and keep its evidence (the ids and statuses you saw). The integration is done only when every check passes.

1. **The key works.** `GET /v1/account` answers 200 with an `id` that starts with `mch_`.
2. **A payment succeeds.** A card PaymentIntent with `payment_method_types[]=card`, `pm_card_visa` and `confirm=true` answers `status: "succeeded"`. When the app also takes Pix or Boleto, a Pix answers `requires_action` with `next_action.pix_display_qr_code.data`, and a Boleto with `next_action.boleto_display_details.number`. Create one more with the payer email `succeed_immediately@example.com`: within seconds `GET /v1/payment_intents/{id}` shows `succeeded` and `payment_intent.succeeded` is sent ([test payments](https://docs.fluveo.com/agents/integrate.md#test-pix-and-boleto)).
3. **A decline is handled.** A PaymentIntent with `pm_card_visa_chargeDeclined` answers 402 with code `card_declined`, and your app shows the buyer an error instead of crashing.
4. **The event exists.** `GET /v1/events?type=payment_intent.succeeded&limit=5` lists an event whose `data.object.id` is your PaymentIntent.
5. **The webhook is received and verified.** Your endpoint answered 200 to the delivery of that event, and your handler's check passed. When no public URL exists yet, run the self-signed test in step 7: a good signature gets 200, a changed body or an old `t` gets 400.
6. **The event is handled once.** Delivering the same event twice changes the order only once.
7. **A refund is wired.** `POST /v1/refunds` with the card PaymentIntent answers 200 with a `re_...` refund, `status: "succeeded"`, or `pending` with `pending_reason: "insufficient_funds"` while the balance does not cover it (step 9); both pass. `GET /v1/events?type=charge.refunded&limit=5` lists the matching event, the Charge shows the new `amount_refunded`, and the PaymentIntent still says `succeeded`. Do not send a `pending` refund again: wait for its `refund.updated`.
8. **A repeated POST does not double-charge.** Send the exact PaymentIntent create from check 2 again with the same `Idempotency-Key`. The answer has the same `pi_...` id and the header `Idempotent-Replayed: true`, and `GET /v1/payment_intents?limit=5` shows no second PaymentIntent.
9. **No secret is exposed.** `sk_test_` and `whsec_` values come only from environment variables; searching the code, the logs and the git history for `sk_test_` and `whsec_` finds only placeholders.

> **For AI agents:**
>
> Report each check with its result and the ids you saw. If a check fails, fix the code and run all the checks again. Do not say the integration is done while any check fails.

## Next

- [Testing](https://docs.fluveo.com/start/testing.md): every test value.
- [Idempotent requests](https://docs.fluveo.com/start/idempotency.md): how keys are matched.
- [Webhook signatures](https://docs.fluveo.com/webhooks/signatures.md) and [event types](https://docs.fluveo.com/webhooks/events.md).
- [Accept a payment](https://docs.fluveo.com/payments/accept-a-payment.md): card, [Pix](https://docs.fluveo.com/payments/pix.md) and [Boleto](https://docs.fluveo.com/payments/boleto.md) in one call.
- [API reference](https://docs.fluveo.com/api.md): every endpoint and parameter.
- [The ready prompt](https://docs.fluveo.com/agents/prompt.md): give this playbook to your coding agent in one paste.
