A custom checkout is a page of your own: your cart, your layout, your Pay button. Only the card form is Fluveo's. The script fluveo.js puts that form on your page in a frame, so the card number, the expiration date and the security code never reach your page or your server.
You write three things: a server route that creates a PaymentIntent, a page that mounts the form, and a webhook route that marks the order paid. This guide builds all three in four steps. Every block is a whole file, so copy each one as it is. fluveo.js describes every call, and Hosted checkout is the choice when you would rather not build a page. Payment Element explains the form itself, with its layouts, its look and its options.
Build the checkout#
Get your test keys#
In the Dashboard, open Settings, then API keys, in test mode. Copy the publishable key (
pk_test_...) and a secret key (sk_test_...). Save them in a file named.envbeside your server. The publishable key goes to the browser. The secret key and the webhook secret stay on the server..envFLUVEO_PUBLISHABLE_KEY=pk_test_... FLUVEO_SECRET_KEY=sk_test_... FLUVEO_WEBHOOK_SECRET=whsec_...Create the PaymentIntent on your server#
Your server creates one PaymentIntent per order with the secret key, and gives the page only its
client_secret. The amount is an integer in the smallest unit of the currency, so2000is $20.00. Build theIdempotency-Keyfrom the order's own id: a retry then returns the same PaymentIntent and never makes a second one. Keep the order id inmetadata, where the webhook reads it back.The server below also serves the two pages of step 3, with your publishable key written into them, and sends
POST /webhookto the handler of step 4.The servercurl https://api.fluveo.com/v1/payment_intents \ -H "Authorization: Bearer sk_test_..." \ -H "Idempotency-Key: order-1042-pay" \ -d amount=2000 \ -d currency=usd \ -d "metadata[order_id]=1042"// server.mjs (Node 20.6 or newer, no packages) import { createServer } from "node:http"; import { readFile } from "node:fs/promises"; import { randomUUID } from "node:crypto"; import { handleWebhook } from "./webhook.mjs"; const API = "https://api.fluveo.com/v1"; const PAGES = { "/": "checkout.html", "/complete": "complete.html" }; async function createPaymentIntent() { const orderId = randomUUID(); // in your shop: the order's own id, so a retry never makes a second payment const response = await fetch(API + "/payment_intents", { method: "POST", headers: { Authorization: "Bearer " + process.env.FLUVEO_SECRET_KEY, "Idempotency-Key": "order-" + orderId + "-pay", }, body: new URLSearchParams({ amount: "2000", currency: "usd", "metadata[order_id]": orderId }), }); const paymentIntent = await response.json(); if (!response.ok) console.error("Fluveo answered", response.status, paymentIntent.error?.message); return { status: response.status, body: { clientSecret: paymentIntent.client_secret } }; } async function answer(req, res) { const path = new URL(req.url, "http://localhost").pathname; if (req.method === "POST" && path === "/create-payment-intent") { const { status, body } = await createPaymentIntent(); return res.writeHead(status, { "Content-Type": "application/json" }).end(JSON.stringify(body)); } if (req.method === "POST" && path === "/webhook") return handleWebhook(req, res); if (req.method !== "GET" || !PAGES[path]) return res.writeHead(404).end(); const page = await readFile(new URL(PAGES[path], import.meta.url), "utf8"); const html = page.replace("FLUVEO_PUBLISHABLE_KEY", process.env.FLUVEO_PUBLISHABLE_KEY); res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }).end(html); } createServer((req, res) => answer(req, res).catch(() => res.writeHead(502).end())).listen(4242);# server.py (Python 3.9 or newer, no packages) import json import os import uuid from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from urllib import error, parse, request from webhook import handle_webhook API = "https://api.fluveo.com/v1" PAGES = {"/": "checkout.html", "/complete": "complete.html"} def create_payment_intent(): order_id = str(uuid.uuid4()) # in your shop: the order's own id, so a retry never makes a second payment call = request.Request( API + "/payment_intents", data=parse.urlencode({"amount": 2000, "currency": "usd", "metadata[order_id]": order_id}).encode(), headers={ "Authorization": "Bearer " + os.environ["FLUVEO_SECRET_KEY"], "Idempotency-Key": "order-" + order_id + "-pay", }, ) try: with request.urlopen(call) as response: return response.status, json.load(response) except error.HTTPError as refused: payment_intent = json.load(refused) print("Fluveo answered", refused.code, payment_intent.get("error", {}).get("message")) return refused.code, payment_intent class Handler(BaseHTTPRequestHandler): def reply(self, status, content_type="text/plain", body=b""): self.send_response(status) self.send_header("Content-Type", content_type) self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def do_POST(self): raw_body = self.rfile.read(int(self.headers.get("Content-Length", 0))) if self.path == "/create-payment-intent": status, payment_intent = create_payment_intent() body = json.dumps({"clientSecret": payment_intent.get("client_secret")}).encode() return self.reply(status, "application/json", body) if self.path == "/webhook": return self.reply(handle_webhook(raw_body, self.headers.get("Fluveo-Signature"))) self.reply(404) def do_GET(self): name = PAGES.get(parse.urlsplit(self.path).path) if not name: return self.reply(404) with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), name), encoding="utf-8") as page: html = page.read().replace("FLUVEO_PUBLISHABLE_KEY", os.environ["FLUVEO_PUBLISHABLE_KEY"]) self.reply(200, "text/html; charset=utf-8", html.encode()) ThreadingHTTPServer(("localhost", 4242), Handler).serve_forever()<?php // server.php (PHP 8.0 or newer with the curl extension, no packages) require __DIR__ . '/webhook.php'; const API = 'https://api.fluveo.com/v1'; const PAGES = ['/' => 'checkout.html', '/complete' => 'complete.html']; function create_payment_intent(): array { $orderId = bin2hex(random_bytes(16)); // in your shop: the order's own id, so a retry never makes a second payment $ch = curl_init(API . '/payment_intents'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . getenv('FLUVEO_SECRET_KEY'), 'Idempotency-Key: order-' . $orderId . '-pay', ], CURLOPT_POSTFIELDS => http_build_query([ 'amount' => 2000, 'currency' => 'usd', 'metadata' => ['order_id' => $orderId], ]), ]); $paymentIntent = json_decode((string) curl_exec($ch), true) ?? []; $status = curl_getinfo($ch, CURLINFO_HTTP_CODE) ?: 502; if ($status !== 200) { error_log('Fluveo answered ' . $status . ' ' . ($paymentIntent['error']['message'] ?? '')); } return [$status, $paymentIntent]; } $path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); $method = $_SERVER['REQUEST_METHOD']; if ($method === 'POST' && $path === '/create-payment-intent') { [$status, $paymentIntent] = create_payment_intent(); http_response_code($status); header('Content-Type: application/json'); echo json_encode(['clientSecret' => $paymentIntent['client_secret'] ?? null]); } elseif ($method === 'POST' && $path === '/webhook') { http_response_code(handle_webhook(file_get_contents('php://input'), $_SERVER['HTTP_FLUVEO_SIGNATURE'] ?? '')); } elseif ($method === 'GET' && array_key_exists($path, PAGES)) { $page = file_get_contents(__DIR__ . '/' . PAGES[$path]); echo str_replace('FLUVEO_PUBLISHABLE_KEY', (string) getenv('FLUVEO_PUBLISHABLE_KEY'), $page); } else { http_response_code(404); }Add the payment form to your page#
Load
fluveo.jsfrom its own address with a script tag. GiveFluveo()your publishable key andelements()the client secret from your server, then mount the payment element in an empty element of your page. When the buyer presses your Pay button,confirmPayment()sends the card straight from the form to Fluveo. An approved payment takes the buyer toreturn_url. A declined one keeps the page, resolves witherror, and the buyer can try again on the same PaymentIntent.The page keeps Pay disabled until the form is ready, takes the amount and currency on its button from the PaymentIntent, and shows a spinner while the payment confirms. The form shows card and validation errors under its own fields, so the line under the button shows only the rest, such as a lost connection. A mistake in your code, such as a missing key, throws an error that the page shows with its link.
In React, install
@fluveo/fluveo-jsand@fluveo/react-fluveo-js, which fluveo.js describes. The component loads the same script throughloadFluveo()and makes the same calls throughElementsandPaymentElement.loadFluveo()uses the script the page already loaded with a script tag, when there is one. Serve/create-payment-intentfrom the same origin as your app, or proxy it there.The checkout page<!doctype html> <!-- checkout.html --> <html lang="en"> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>Checkout</title> <script src="https://elements.fluveo.com/v1/"></script> <style> body { margin: 0; padding: 16px; background: #ffffff; color: #1c1917; font: 16px/1.3 system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; } main { box-sizing: border-box; max-width: 500px; margin: 0 auto; padding: 16px; border: 1px solid #e7e5e4; border-radius: 5px; background: #ffffff; } h1 { margin: 0 0 12px; font-size: 16px; font-weight: 600; } .total { display: flex; justify-content: space-between; margin: 0 0 16px; padding: 12px 0; border-block: 1px solid #e7e5e4; } strong { font-weight: 600; } #payment-element { margin-bottom: 16px; } #submit { display: flex; align-items: center; justify-content: center; width: 100%; height: 44px; border: 0; border-radius: 5px; background: #2961b2; color: #ffffff; font: inherit; font-weight: 600; cursor: pointer; } #submit:disabled { opacity: 0.5; cursor: default; } #submit.busy { opacity: 1; } .spinner { display: none; box-sizing: border-box; width: 20px; height: 20px; border: 2px solid #ffffff; border-top-color: #2961b2; border-radius: 50%; animation: spin 0.8s linear infinite; } .busy .spinner { display: block; } .busy #button-text { display: none; } @keyframes spin { to { transform: rotate(360deg); } } #error-message { margin-top: 12px; color: #c03f34; text-align: center; } #error-message:empty { display: none; } #error-message a { color: inherit; overflow-wrap: anywhere; } </style> <main> <h1>Your order</h1> <p class="total"><span>Total</span> <strong id="total"></strong></p> <form id="payment-form"> <div id="payment-element"></div> <button id="submit" disabled><span class="spinner" aria-hidden="true"></span><span id="button-text">Pay</span></button> <div id="error-message" role="alert"></div> </form> </main> <script type="module"> const button = document.getElementById("submit"); const line = document.getElementById("error-message"); const busy = (on) => { button.disabled = on; button.classList.toggle("busy", on); }; // A mistake in your code, such as a missing key, throws an error that says how to fix it and links to its docs. function show(error) { line.textContent = error.doc_url ? error.message.replace(error.doc_url, "") : error.message; if (!error.doc_url) return; const link = line.appendChild(document.createElement("a")); link.href = link.textContent = error.doc_url; } try { // Your server writes your publishable key here and keeps the secret key to itself. const fluveo = Fluveo("FLUVEO_PUBLISHABLE_KEY"); const { clientSecret } = await fetch("/create-payment-intent", { method: "POST" }).then((r) => r.json()); const elements = fluveo.elements({ clientSecret }); const payment = elements.create("payment"); payment.on("ready", () => busy(false)); payment.mount("#payment-element"); document.getElementById("payment-form").addEventListener("submit", async (event) => { event.preventDefault(); busy(true); line.textContent = ""; let error; try { // An approved payment goes to return_url. A declined one stays here, and the buyer can try again. ({ error } = await fluveo.confirmPayment({ elements, confirmParams: { return_url: location.origin + "/complete" }, })); } catch (thrown) { error = thrown; } busy(false); // The form shows card and validation errors under its own fields. This line shows the rest, such as a lost connection. if (error && !["card_error", "validation_error"].includes(error.type)) show(error); }); // The total comes from the PaymentIntent, so the page always shows what the buyer pays. const { paymentIntent } = await fluveo.retrievePaymentIntent(clientSecret); if (paymentIntent) { const money = new Intl.NumberFormat(document.documentElement.lang, { style: "currency", currency: paymentIntent.currency }); const total = money.format(paymentIntent.amount / 10 ** money.resolvedOptions().maximumFractionDigits); document.getElementById("total").textContent = total; document.getElementById("button-text").textContent = "Pay " + total; } } catch (error) { show(error); } </script> </html>// Checkout.jsx: render <Checkout publishableKey={yourPublishableKey} /> on your checkout route // npm install @fluveo/fluveo-js @fluveo/react-fluveo-js import { useEffect, useState } from "react"; import { loadFluveo } from "@fluveo/fluveo-js"; import { Elements, PaymentElement, useElements, useFluveo } from "@fluveo/react-fluveo-js"; const STYLE = ` body { margin: 0; padding: 16px; background: #ffffff; color: #1c1917; font: 16px/1.3 system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; } main { box-sizing: border-box; max-width: 500px; margin: 0 auto; padding: 16px; border: 1px solid #e7e5e4; border-radius: 5px; background: #ffffff; } h1 { margin: 0 0 12px; font-size: 16px; font-weight: 600; } .total { display: flex; justify-content: space-between; margin: 0 0 16px; padding: 12px 0; border-block: 1px solid #e7e5e4; } strong { font-weight: 600; } #payment-element { margin-bottom: 16px; } #submit { display: flex; align-items: center; justify-content: center; width: 100%; height: 44px; border: 0; border-radius: 5px; background: #2961b2; color: #ffffff; font: inherit; font-weight: 600; cursor: pointer; } #submit:disabled { opacity: 0.5; cursor: default; } #submit.busy { opacity: 1; } .spinner { display: none; box-sizing: border-box; width: 20px; height: 20px; border: 2px solid #ffffff; border-top-color: #2961b2; border-radius: 50%; animation: spin 0.8s linear infinite; } .busy .spinner { display: block; } .busy #button-text { display: none; } @keyframes spin { to { transform: rotate(360deg); } } #error-message { margin-top: 12px; color: #c03f34; text-align: center; } #error-message:empty { display: none; } #error-message a { color: inherit; overflow-wrap: anywhere; } `; // The amount comes from the PaymentIntent, so the page always shows what the buyer pays. export function formatAmount({ amount, currency }) { const money = new Intl.NumberFormat("en", { style: "currency", currency }); return money.format(amount / 10 ** money.resolvedOptions().maximumFractionDigits); } // A mistake in your code, such as a missing key, throws an error that says how to fix it and links to its docs. export function ErrorText({ error }) { if (!error.doc_url) return error.message; return <>{error.message.replace(error.doc_url, "")}<a href={error.doc_url}>{error.doc_url}</a></>; } export default function Checkout({ publishableKey }) { const [error, setError] = useState(null); // fluveo.js loads from Fluveo's own address, once per page. A key it refuses rejects here. const [fluveo] = useState(() => loadFluveo(publishableKey).catch((thrown) => { setError(thrown); return null; })); const [clientSecret, setClientSecret] = useState(""); useEffect(() => { fetch("/create-payment-intent", { method: "POST" }) .then((r) => r.json()) .then((body) => setClientSecret(body.clientSecret)) .catch(setError); }, []); return ( <main> <style>{STYLE}</style> <h1>Your order</h1> <Elements fluveo={fluveo} options={{ clientSecret }}> <PaymentForm clientSecret={clientSecret} error={error} setError={setError} /> </Elements> </main> ); } function PaymentForm({ clientSecret, error, setError }) { const fluveo = useFluveo(); const elements = useElements(); const [total, setTotal] = useState(""); const [ready, setReady] = useState(false); const [busy, setBusy] = useState(false); useEffect(() => { if (!fluveo || !clientSecret) return; fluveo.retrievePaymentIntent(clientSecret) .then(({ paymentIntent }) => paymentIntent && setTotal(formatAmount(paymentIntent))) .catch(setError); }, [fluveo, clientSecret]); async function pay(event) { event.preventDefault(); setBusy(true); setError(null); let error; try { // An approved payment goes to return_url. A declined one stays here, and the buyer can try again. ({ error } = await fluveo.confirmPayment({ elements, confirmParams: { return_url: location.origin + "/complete" }, })); } catch (thrown) { error = thrown; } setBusy(false); // The form shows card and validation errors under its own fields. This line shows the rest, such as a lost connection. if (error && !["card_error", "validation_error"].includes(error.type)) setError(error); } return ( <> <p className="total"><span>Total</span> <strong id="total">{total}</strong></p> <form id="payment-form" onSubmit={pay}> <PaymentElement id="payment-element" onReady={() => setReady(true)} /> <button id="submit" className={busy ? "busy" : undefined} disabled={!ready || busy}> <span className="spinner" aria-hidden="true" /> <span id="button-text">{total ? "Pay " + total : "Pay"}</span> </button> <div id="error-message" role="alert">{error && <ErrorText error={error} />}</div> </form> </> ); }The buyer lands on
return_urlwithpayment_intent,payment_intent_client_secretandredirect_statusin the address. The return page reads the PaymentIntent with that client secret and tells the buyer how it ended. It is a thank-you page only: a buyer can close the tab before it loads, so it never marks the order paid.The return page<!doctype html> <!-- complete.html --> <html lang="en"> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>Order</title> <script src="https://elements.fluveo.com/v1/"></script> <style> body { margin: 0; padding: 16px; background: #ffffff; color: #1c1917; font: 16px/1.3 system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; } main { box-sizing: border-box; max-width: 500px; margin: 0 auto; padding: 16px; border: 1px solid #e7e5e4; border-radius: 5px; background: #ffffff; text-align: center; } #icon { width: 48px; height: 48px; color: #79716b; } h1 { margin: 16px 0 8px; font-size: 16px; font-weight: 600; } p { margin: 0; color: #57534d; overflow-wrap: anywhere; } p a { color: inherit; font-weight: 400; } dl { display: grid; grid-template-columns: auto 1fr; gap: 8px 16px; margin: 16px 0 0; padding: 12px 0; border-block: 1px solid #e7e5e4; font-size: 14px; text-align: left; } dt { font-weight: 600; } dd { margin: 0; color: #57534d; text-align: right; overflow-wrap: anywhere; } a { color: #2961b2; font-weight: 600; } main > a { display: inline-block; margin-top: 16px; } </style> <main> <svg id="icon" viewBox="0 0 48 48" aria-hidden="true"> <circle cx="24" cy="24" r="24" fill="currentColor" /> <path id="mark" fill="none" stroke="#ffffff" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" d="M16 24h16" /> </svg> <h1 id="title">Checking your payment…</h1> <p id="detail"></p> <dl id="details" hidden> <dt>Payment</dt><dd id="payment-id"></dd> <dt>Status</dt><dd id="status"></dd> </dl> <a href="/">Back to the checkout</a> </main> <script type="module"> // A thank-you page only. Mark the order paid on your server, on the payment_intent.succeeded webhook. const PAGES = { succeeded: { title: "Thank you for your order", color: "#2961b2", mark: "M15 25l6 6 12-13", detail: (amount) => amount + " paid." }, processing: { title: "Your payment is processing", color: "#57534d", mark: "M24 14v11l7 4", detail: (amount) => amount + " will show as paid once it clears." }, requires_payment_method: { title: "Your payment did not go through", color: "#c03f34", mark: "M17 17l14 14M31 17L17 31", detail: (amount, why) => (why ? why + " " : "") + amount + " was not charged. Please try again." }, }; const OTHER = { title: "Something went wrong", color: "#c03f34", mark: "M24 13v14M24 34v1", detail: (amount) => "Order total " + amount + "." }; function show(page, detail) { document.getElementById("icon").style.color = page.color; document.getElementById("mark").setAttribute("d", page.mark); document.getElementById("title").textContent = page.title; document.getElementById("detail").textContent = detail; } try { const fluveo = Fluveo("FLUVEO_PUBLISHABLE_KEY"); const clientSecret = new URLSearchParams(location.search).get("payment_intent_client_secret"); const { paymentIntent, error } = await fluveo.retrievePaymentIntent(clientSecret); if (error) throw error; const money = new Intl.NumberFormat(document.documentElement.lang, { style: "currency", currency: paymentIntent.currency }); const amount = money.format(paymentIntent.amount / 10 ** money.resolvedOptions().maximumFractionDigits); const page = PAGES[paymentIntent.status] || OTHER; show(page, page.detail(amount, paymentIntent.last_payment_error?.message)); document.getElementById("payment-id").textContent = paymentIntent.id; document.getElementById("status").textContent = paymentIntent.status; document.getElementById("details").hidden = false; } catch (error) { // A mistake in your code, such as a missing key, throws an error that says how to fix it and links to its docs. show(OTHER, error.doc_url ? error.message.replace(error.doc_url, "") : error.message); if (error.doc_url) { const link = document.getElementById("detail").appendChild(document.createElement("a")); link.href = link.textContent = error.doc_url; } } </script> </html>// Complete.jsx: render <Complete publishableKey={yourPublishableKey} /> on the route of your return_url import { useEffect, useState } from "react"; import { loadFluveo } from "@fluveo/fluveo-js"; import { ErrorText, formatAmount } from "./Checkout.jsx"; const STYLE = ` body { margin: 0; padding: 16px; background: #ffffff; color: #1c1917; font: 16px/1.3 system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; } main { box-sizing: border-box; max-width: 500px; margin: 0 auto; padding: 16px; border: 1px solid #e7e5e4; border-radius: 5px; background: #ffffff; text-align: center; } #icon { width: 48px; height: 48px; color: #79716b; } h1 { margin: 16px 0 8px; font-size: 16px; font-weight: 600; } p { margin: 0; color: #57534d; overflow-wrap: anywhere; } p a { color: inherit; font-weight: 400; } dl { display: grid; grid-template-columns: auto 1fr; gap: 8px 16px; margin: 16px 0 0; padding: 12px 0; border-block: 1px solid #e7e5e4; font-size: 14px; text-align: left; } dt { font-weight: 600; } dd { margin: 0; color: #57534d; text-align: right; overflow-wrap: anywhere; } a { color: #2961b2; font-weight: 600; } main > a { display: inline-block; margin-top: 16px; } `; // A thank-you page only. Mark the order paid on your server, on the payment_intent.succeeded webhook. const PAGES = { succeeded: { title: "Thank you for your order", color: "#2961b2", mark: "M15 25l6 6 12-13", detail: (amount) => amount + " paid." }, processing: { title: "Your payment is processing", color: "#57534d", mark: "M24 14v11l7 4", detail: (amount) => amount + " will show as paid once it clears." }, requires_payment_method: { title: "Your payment did not go through", color: "#c03f34", mark: "M17 17l14 14M31 17L17 31", detail: (amount, why) => (why ? why + " " : "") + amount + " was not charged. Please try again." }, }; const OTHER = { title: "Something went wrong", color: "#c03f34", mark: "M24 13v14M24 34v1", detail: (amount) => "Order total " + amount + "." }; const CHECKING = { title: "Checking your payment…", mark: "M16 24h16" }; export default function Complete({ publishableKey }) { const [shown, setShown] = useState({ page: CHECKING }); useEffect(() => { const clientSecret = new URLSearchParams(location.search).get("payment_intent_client_secret"); loadFluveo(publishableKey) .then((fluveo) => fluveo.retrievePaymentIntent(clientSecret)) .then(({ paymentIntent, error }) => { if (error) throw error; const page = PAGES[paymentIntent.status] || OTHER; const detail = page.detail(formatAmount(paymentIntent), paymentIntent.last_payment_error?.message); setShown({ page, detail, paymentIntent }); }) .catch((error) => setShown({ page: OTHER, detail: <ErrorText error={error} /> })); }, [publishableKey]); const { page, detail, paymentIntent } = shown; return ( <main> <style>{STYLE}</style> <svg id="icon" viewBox="0 0 48 48" aria-hidden="true" style={{ color: page.color }}> <circle cx="24" cy="24" r="24" fill="currentColor" /> <path fill="none" stroke="#ffffff" strokeWidth="4" strokeLinecap="round" strokeLinejoin="round" d={page.mark} /> </svg> <h1 id="title">{page.title}</h1> <p id="detail">{detail}</p> {paymentIntent && ( <dl id="details"> <dt>Payment</dt><dd id="payment-id">{paymentIntent.id}</dd> <dt>Status</dt><dd id="status">{paymentIntent.status}</dd> </dl> )} <a href="/">Back to the checkout</a> </main> ); }Mark the order paid on the webhook#
Fluveo sends your server a
payment_intent.succeededevent when the money is in. The handler checks theFluveo-Signatureheader against the raw body, skips an event id it has seen, and marks the order named inmetadata.order_idpaid. Check webhook signatures explains each line of the check.The webhook handler// webhook.mjs: checks Fluveo-Signature on the raw body, then marks the order paid, once per event import { createHmac, timingSafeEqual } from "node:crypto"; export const paidOrders = new Map(); // order id -> event id; in your shop: your orders table const seenEvents = new Set(); // in your shop: a table with a unique key on the event id function verified(rawBody, header, secret, now = Math.floor(Date.now() / 1000)) { const parts = String(header || "").split(",").map((part) => part.trim().split("=")); const timestamp = parts.find(([key]) => key === "t")?.[1] ?? ""; if (!secret || !/^\d{1,12}$/.test(timestamp) || Math.abs(now - Number(timestamp)) > 300) return false; const expected = createHmac("sha256", secret).update(timestamp + ".").update(rawBody).digest(); return parts.some( ([key, value]) => key === "v1" && /^[0-9a-f]{64}$/.test(value ?? "") && timingSafeEqual(Buffer.from(value, "hex"), expected), ); } export function handleWebhook(req, res) { const chunks = []; req.on("data", (chunk) => chunks.push(chunk)); req.on("end", () => { const rawBody = Buffer.concat(chunks); if (!verified(rawBody, req.headers["fluveo-signature"], process.env.FLUVEO_WEBHOOK_SECRET)) { return res.writeHead(400).end(); } const event = JSON.parse(rawBody.toString("utf8")); if (!seenEvents.has(event.id)) { seenEvents.add(event.id); if (event.type === "payment_intent.succeeded") { const paymentIntent = event.data.object; paidOrders.set(paymentIntent.metadata.order_id, event.id); console.log("order paid", paymentIntent.metadata.order_id, paymentIntent.id, event.id); } } res.writeHead(200).end(); }); }# webhook.py: checks Fluveo-Signature on the raw body, then marks the order paid, once per event import hashlib import hmac import json import os import time paid_orders = {} # order id -> event id; in your shop: your orders table seen_events = set() # in your shop: a table with a unique key on the event id def verified(raw_body, header, secret, now=None): parts = [part.strip().partition("=") for part in (header or "").split(",")] timestamp = next((value for key, _, value in parts if key == "t"), "") if not secret or not (timestamp.isascii() and timestamp.isdigit()) or len(timestamp) > 12: return False if abs((int(time.time()) if now is None else now) - int(timestamp)) > 300: return False expected = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest() return any(key == "v1" and hmac.compare_digest(expected.encode(), value.encode()) for key, _, value in parts) def handle_webhook(raw_body, header): if not verified(raw_body, header, os.environ.get("FLUVEO_WEBHOOK_SECRET")): return 400 event = json.loads(raw_body) if event["id"] not in seen_events: seen_events.add(event["id"]) if event["type"] == "payment_intent.succeeded": payment_intent = event["data"]["object"] paid_orders[payment_intent["metadata"]["order_id"]] = event["id"] print("order paid", payment_intent["metadata"]["order_id"], payment_intent["id"], event["id"]) return 200<?php // webhook.php: checks Fluveo-Signature on the raw body, then marks the order paid, once per event // event id => order id. PHP keeps nothing between requests, so this file stands in for your orders table. const ORDERS_FILE = __DIR__ . '/orders.json'; function verified(string $rawBody, string $header, string $secret, ?int $now = null): bool { $timestamp = ''; $signatures = []; foreach (explode(',', $header) as $part) { [$key, $value] = array_pad(explode('=', trim($part), 2), 2, ''); if ($key === 't') { $timestamp = $value; } elseif ($key === 'v1') { $signatures[] = $value; } } if ($secret === '' || !ctype_digit($timestamp) || strlen($timestamp) > 12) { return false; } if (abs(($now ?? time()) - (int) $timestamp) > 300) { return false; } $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret); foreach ($signatures as $signature) { if (hash_equals($expected, $signature)) { return true; } } return false; } function handle_webhook(string $rawBody, string $header): int { if (!verified($rawBody, $header, (string) getenv('FLUVEO_WEBHOOK_SECRET'))) { return 400; } $event = json_decode($rawBody, true); $seen = is_file(ORDERS_FILE) ? json_decode(file_get_contents(ORDERS_FILE), true) : []; if (!array_key_exists($event['id'], $seen)) { $paid = $event['type'] === 'payment_intent.succeeded'; $paymentIntent = $event['data']['object']; $seen[$event['id']] = $paid ? $paymentIntent['metadata']['order_id'] : null; file_put_contents(ORDERS_FILE, json_encode($seen), LOCK_EX); if ($paid) { error_log('order paid ' . $paymentIntent['metadata']['order_id'] . ' ' . $paymentIntent['id'] . ' ' . $event['id']); } } return 200; }Fluveo delivers only to a public address (
https, orhttpfor a test endpoint). When your server has one, create an endpoint for its/webhookroute with the eventspayment_intent.succeededandpayment_intent.payment_failed, put the endpoint'swhsec_...secret in.envand start the server again. On your own computer, a tunnel gives the route such an address. Until then, the Verify list checks the handler with an event you sign yourself.
Run it#
Put the files in one folder, load .env and start the server. Then open http://localhost:4242. Test keys work on http://localhost, with no address to register.
node --env-file=.env server.mjsset -a; . ./.env; set +a
python3 server.pyset -a; . ./.env; set +a
php -S localhost:4242 server.phpThe form shows a Test mode line with a card number you can use, and the browser console prints one line that starts with [fluveo.js] test mode and names the PaymentIntent. A mistake in your code throws an error that says what is wrong and how to fix it, and ends with a link: fluveo.js errors has one entry per error code.
Verify#
Run every check. Each one names the result to expect. In the form, any future expiration date and any three-digit security code work. Testing lists more card numbers.
- A payment succeeds. Pay with
4242 4242 4242 4242. The browser goes to/completewithredirect_status=succeededin the address, and the page shows the amount paid, the PaymentIntent id and the statussucceeded. Read the PaymentIntent with your secret key: itsstatusissucceededand itsamountis2000. - A decline shows a message, and the next card pays. Open the checkout again and pay with
4000 0000 0000 0002. The page stays, and the form shows the decline message under the card number, once. On the same page, pay with4242 4242 4242 4242: it succeeds on the same PaymentIntent, and your server got onePOST /create-payment-intent. - 3D Secure completes. Pay with
4000 0027 6000 3184. The browser opens a test authentication page with two buttons. Complete returns to/complete, which showssucceeded. Do it again with Fail: the return page shows the error message, and the PaymentIntent is inrequires_payment_method. - The webhook is checked. After a payment succeeded, run
verify-webhook.mjsbelow. It reads the newestpayment_intent.succeededevent of your store, signs it withFLUVEO_WEBHOOK_SECRETand posts it to your handler. It prints200for the signed event,200for the same event again, and400for a changed body. Your server printsorder paidonce. - No secret reaches the browser. The last command below prints
no secret in the pages. Check your built bundle the same way when you have one.
curl https://api.fluveo.com/v1/payment_intents/pi_... \
-H "Authorization: Bearer sk_test_..."// verify-webhook.mjs: node --env-file=.env verify-webhook.mjs (after a test payment succeeded)
import { createHmac } from "node:crypto";
const events = await fetch("https://api.fluveo.com/v1/events?type=payment_intent.succeeded&limit=1", {
headers: { Authorization: "Bearer " + process.env.FLUVEO_SECRET_KEY },
}).then((response) => response.json());
const body = JSON.stringify(events.data[0]);
async function deliver(sentBody) {
const t = Math.floor(Date.now() / 1000);
const v1 = createHmac("sha256", process.env.FLUVEO_WEBHOOK_SECRET).update(t + "." + body).digest("hex");
const response = await fetch("http://localhost:4242/webhook", {
method: "POST",
headers: { "Content-Type": "application/json", "Fluveo-Signature": "t=" + t + ",v1=" + v1 },
body: sentBody,
});
return response.status;
}
console.log("signed event", events.data[0].id, await deliver(body)); // 200
console.log("same event again", await deliver(body)); // 200, and the server prints "order paid" only once
console.log("changed body", await deliver(body.replace("succeeded", "changed"))); // 400grep -nE "(sk|rk)_(test|live)_|whsec_" checkout.html complete.html || echo "no secret in the pages"Test your page with Playwright#
The form is a frame with the title Fluveo payment form. Inside it, the card number, the expiration date and the security code are three more frames, in the slots #card-number, #card-expiry and #card-cvc. Your page cannot read them, and a browser test can type into them. For the US, Puerto Rico, Canada and the UK the form also asks for the postal code, in its input #postal. This function needs Playwright 1.38 or newer.
// Types a card into Fluveo's payment form, as a buyer does.
export async function fillFluveoCard(page, { number = "4242 4242 4242 4242", expiry = "12 / 34", cvc = "123" } = {}) {
const form = page.frameLocator('iframe[title="Fluveo payment form"]');
const fields = { "#card-number": number, "#card-expiry": expiry, "#card-cvc": cvc };
for (const [slot, value] of Object.entries(fields)) {
const input = form.frameLocator(slot + " iframe").locator("input").first();
await input.click();
await input.pressSequentially(value, { delay: 20 });
}
if (await form.locator("#postal").isVisible()) await form.locator("#postal").fill("12345");
}
// In a test:
// await page.goto("http://localhost:4242/");
// await fillFluveoCard(page);
// await page.getByRole("button", { name: "Pay" }).click();
// await page.getByText("succeeded").waitFor();