Docs
API 2026-05-27.dahlia Dashboard

Payments

Take a card on your own page with Fluveo's payment form.

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#

  1. 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 .env beside your server. The publishable key goes to the browser. The secret key and the webhook secret stay on the server.

    .env
    FLUVEO_PUBLISHABLE_KEY=pk_test_...
    FLUVEO_SECRET_KEY=sk_test_...
    FLUVEO_WEBHOOK_SECRET=whsec_...
  2. 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, so 2000 is $20.00. Build the Idempotency-Key from the order's own id: a retry then returns the same PaymentIntent and never makes a second one. Keep the order id in metadata, 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 /webhook to the handler of step 4.

    The server
    curl https://api.fluveo.com/v1/payment_intents \
      -H "Authorization: Bearer sk_test_..." \
      -H "Idempotency-Key: order-1042-pay" \
      -d amount=2000 \
      -d currency=usd \
      -d "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);
    }
  3. Add the payment form to your page#

    Load fluveo.js from its own address with a script tag. Give Fluveo() your publishable key and elements() 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 to return_url. A declined one keeps the page, resolves with error, 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-js and @fluveo/react-fluveo-js, which fluveo.js describes. The component loads the same script through loadFluveo() and makes the same calls through Elements and PaymentElement. loadFluveo() uses the script the page already loaded with a script tag, when there is one. Serve /create-payment-intent from 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_url with payment_intent, payment_intent_client_secret and redirect_status in 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>
      );
    }
  4. Mark the order paid on the webhook#

    Fluveo sends your server a payment_intent.succeeded event when the money is in. The handler checks the Fluveo-Signature header against the raw body, skips an event id it has seen, and marks the order named in metadata.order_id paid. 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, or http for a test endpoint). When your server has one, create an endpoint for its /webhook route with the events payment_intent.succeeded and payment_intent.payment_failed, put the endpoint's whsec_... secret in .env and 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.

Start the server
node --env-file=.env server.mjs
set -a; . ./.env; set +a
python3 server.py
set -a; . ./.env; set +a
php -S localhost:4242 server.php

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

  1. A payment succeeds. Pay with 4242 4242 4242 4242. The browser goes to /complete with redirect_status=succeeded in the address, and the page shows the amount paid, the PaymentIntent id and the status succeeded. Read the PaymentIntent with your secret key: its status is succeeded and its amount is 2000.
  2. 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 with 4242 4242 4242 4242: it succeeds on the same PaymentIntent, and your server got one POST /create-payment-intent.
  3. 3D Secure completes. Pay with 4000 0027 6000 3184. The browser opens a test authentication page with two buttons. Complete returns to /complete, which shows succeeded. Do it again with Fail: the return page shows the error message, and the PaymentIntent is in requires_payment_method.
  4. The webhook is checked. After a payment succeeded, run verify-webhook.mjs below. It reads the newest payment_intent.succeeded event of your store, signs it with FLUVEO_WEBHOOK_SECRET and posts it to your handler. It prints 200 for the signed event, 200 for the same event again, and 400 for a changed body. Your server prints order paid once.
  5. 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.
Read the PaymentIntent
curl https://api.fluveo.com/v1/payment_intents/pi_... \
  -H "Authorization: Bearer sk_test_..."
verify-webhook.mjs
// 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"))); // 400
No secret in the pages
grep -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.

fill-fluveo-card.mjs
// 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();

Next#