Docs
API 2026-05-27.dahlia Dashboard

Build with AI agents

Integrate Fluveo with an AI agent

Use with AI

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.

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.
  • Checkout platforms 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 sendFluveo answers
A header that acts for another account400 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 body400 invalid_request_error with that name in param and the same message
Raw card data: card[number], payment_method_data[card][number], any cvc402 (raw card data is not accepted), or 400 naming the field
A Fluveo-Version header that names any version but 2026-05-27.dahlia400 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 take400 invalid_request_error, code parameter_unknown
An endpoint Fluveo does not servean 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.

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 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.

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 https://api.fluveo.com/v1/account \
  -H "Authorization: Bearer $FLUVEO_SECRET_KEY"
// 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);
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
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); any other answers 400 with param set to currency.

CardPix
currencyusd in this examplebrl in this example
payment_method_types[]cardpix
The methodpayment_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 answerstatus: "succeeded"status: "requires_action" and a Pix code to show the buyer (step 5)
Create and confirm a payment: card or Pix
# 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"
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
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
$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):

PaymentMethodResult
pm_card_visa, pm_card_visa_debit, pm_card_mastercard, pm_card_amexThe payment succeeds.
pm_card_visa_chargeDeclined402 card_error, code card_declined, decline_code generic_decline
pm_card_visa_chargeDeclinedInsufficientFunds402 card_error, code card_declined, decline_code insufficient_funds
pm_card_chargeDeclinedExpiredCard402 card_error, code expired_card
pm_card_chargeDeclinedIncorrectCvc402 card_error, code incorrect_cvc
pm_card_chargeDeclinedProcessingError402 card_error, code processing_error
pm_card_refundFailThe 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.

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 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"
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);
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"])
$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. See Cards.

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.

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.typeFieldsShow the buyer
pix_display_qr_codedata, expires_at, image_url_png, image_url_svg, hosted_instructions_urlThe QR image and the copy-and-paste code in data. Stop showing it after expires_at (Unix seconds).
boleto_display_detailsnumber, expires_at, hosted_voucher_url, pdfThe 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 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"
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.
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.
// 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.

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, so the endpoint URL of step 6 ends in /webhooks/fluveo.

Verify and handle a webhook (no SDK)
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);
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 -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.

EventWhat to do
payment_intent.succeededMark the order paid and fulfil it. data.object is the PaymentIntent; find your order through its metadata or its id.
payment_intent.payment_failedTell the buyer and let them try again.
charge.refundedRecord 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.failedThe 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 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
const refund = await fluveo("POST", "/refunds", {
  payment_intent: intent.id,
  amount: "500",
}, "order-1042-refund-1");
console.log(refund.id, refund.status); // re_...
refund = fluveo("POST", "/refunds", {
    "payment_intent": intent["id"],
    "amount": 500,
}, idempotency_key="order-1042-refund-1")
print(refund["id"], refund["status"])  # re_...
$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.

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.

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. The Node, Python and PHP ones reuse the fluveo helper of step 3.

Retry with the same key
# 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
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)));
    }
  }
}
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)
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.

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.

Statustype and codeWhat it means
400invalid_request_error, often with paramA parameter is missing, unknown (parameter_unknown) or refused. Fix the request.
400idempotency_errorThe key was used before with a different body. Use a new key for the new request.
401invalid_request_errorThe key is missing or wrong.
402card_error, card_declined with a decline_codeThe card was declined. Ask the buyer for another payment method.
403invalid_request_errorThe key's account cannot use this API, or it is a live key.
404invalid_request_error, resource_missingNo such object for this account, or no such URL.
409idempotency_key_in_useAnother request is working on the same key. Retry later with the same key.
429invalid_request_error, lock_timeoutAnother request is working on the same object. Retry shortly with the same key.
429rate_limitToo many requests. Wait the Retry-After seconds.
500api_errorFluveo 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.

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).
  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.

Next#