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
/v1API: PaymentIntents, Charges, Refunds, Disputes, Payouts, Events, lists and errors. - You call it over plain HTTP at
https://api.fluveo.com/v1with 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 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.
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.
- The merchant signs in to the Fluveo Dashboard and opens Settings > API keys.
- 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. - They store it as an environment variable, for example
FLUVEO_SECRET_KEY, in the app's local.envfile (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.
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.
| 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) |
# 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_actionMETHODS = {
"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_action2000 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.
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.
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.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.
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:
- Read the body as raw bytes, before any JSON parser touches it.
- Split the header on
,and taketand everyv1. - Reject the event when
tis more than 5 minutes away from your clock. - Compute the HMAC of
t.bodywith your secret and compare it with eachv1in constant time. - 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.
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.
| 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.
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=500const 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 instatus: "pending"andpending_reason: "insufficient_funds", thenrefund.createdandcharge.refunded; its amount already counts in the Charge'samount_refunded. Fluveo sends it once the balance covers it and tells you withrefund.updated. If the balance still falls short 45 days later, it endsfailedwithfailure_reason: "insufficient_funds"andrefund.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-Afterheader. - Stop on a replayed 500. When Fluveo cannot tell whether a payment went through, it answers 500 with
Fluveo-Should-Retry: falseand keeps that answer for the key. A retry with the same key gets the same 500 back withIdempotent-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.
# 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.jsonasync 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:
- List
GET /v1/payment_intentswithcreated[gte]set to a moment before your first try, and find the one whosemetadata.order_idis your order. This list filters only bycreatedandcustomer: there is no metadata filter and no/searchendpoint, which is why step 4 puts the order id inmetadata. - Or wait for
payment_intent.succeededorpayment_intent.payment_failedon your webhook, or list them withGET /v1/events?type=...&created[gte]=.... Matchdata.object.metadata.order_id. An event made during your own request also carries your key inrequest.idempotency_key, but an event Fluveo makes later, once it learns the result, hasrequest.idempotency_key: null. - 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.
| 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.
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.
- The key works.
GET /v1/accountanswers 200 with anidthat starts withmch_. - A payment succeeds. A card PaymentIntent with
payment_method_types[]=card,pm_card_visaandconfirm=trueanswersstatus: "succeeded". When the app also takes Pix or Boleto, a Pix answersrequires_actionwithnext_action.pix_display_qr_code.data, and a Boleto withnext_action.boleto_display_details.number. Create one more with the payer emailsucceed_immediately@example.com: within secondsGET /v1/payment_intents/{id}showssucceededandpayment_intent.succeededis sent (test payments). - A decline is handled. A PaymentIntent with
pm_card_visa_chargeDeclinedanswers 402 with codecard_declined, and your app shows the buyer an error instead of crashing. - The event exists.
GET /v1/events?type=payment_intent.succeeded&limit=5lists an event whosedata.object.idis your PaymentIntent. - 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
tgets 400. - The event is handled once. Delivering the same event twice changes the order only once.
- A refund is wired.
POST /v1/refundswith the card PaymentIntent answers 200 with are_...refund,status: "succeeded", orpendingwithpending_reason: "insufficient_funds"while the balance does not cover it (step 9); both pass.GET /v1/events?type=charge.refunded&limit=5lists the matching event, the Charge shows the newamount_refunded, and the PaymentIntent still sayssucceeded. Do not send apendingrefund again: wait for itsrefund.updated. - 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 samepi_...id and the headerIdempotent-Replayed: true, andGET /v1/payment_intents?limit=5shows no second PaymentIntent. - No secret is exposed.
sk_test_andwhsec_values come only from environment variables; searching the code, the logs and the git history forsk_test_andwhsec_finds only placeholders.
Next#
- Testing: every test value.
- Idempotent requests: how keys are matched.
- Webhook signatures and event types.
- Accept a payment: card, Pix and Boleto in one call.
- API reference: every endpoint and parameter.
- The ready prompt: give this playbook to your coding agent in one paste.