---
title: "Custom checkout"
description: "Take a card on your own page with Fluveo's payment form."
url: https://docs.fluveo.com/payments/custom-checkout/
---

> Index of every page: https://docs.fluveo.com/llms.txt. Integrating Fluveo? Read https://docs.fluveo.com/agents/integrate.md first.

> Too long? This page with code in one language only: https://docs.fluveo.com/payments/custom-checkout.curl.md (cURL), https://docs.fluveo.com/payments/custom-checkout.node.md (Node.js), https://docs.fluveo.com/payments/custom-checkout.python.md (Python), https://docs.fluveo.com/payments/custom-checkout.php.md (PHP).

# Custom checkout

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](https://docs.fluveo.com/payments/fluveo-js.md) describes every call, and [Hosted checkout](https://docs.fluveo.com/payments/checkout.md) is the choice when you would rather not build a page. [Payment Element](https://docs.fluveo.com/payments/payment-element.md) explains the form itself, with its layouts, its look and its options.

> **For AI agents:**
>
> - The page, the cart and the Pay button are yours. The payment form is Fluveo's: never build a card input, and never send a card number from your code.
> - In the browser, the one script is https://elements.fluveo.com/v1/. A page with no build step loads it with a script tag. With a bundler or React, install @fluveo/fluveo-js and @fluveo/react-fluveo-js, which only load that same script and hold no card code. Never copy the script into a bundle and never install another payments package.
> - The calls are Fluveo(publishableKey), fluveo.elements({ clientSecret }), elements.create("payment"), element.mount(target), fluveo.confirmPayment({ elements, confirmParams: { return_url } }) and fluveo.retrievePaymentIntent(clientSecret). A call or an option fluveo.js does not have throws an error that names what to use: read its doc_url and follow it over what you remember from other payment libraries.
> - On the server, call the API over plain HTTP with FLUVEO_SECRET_KEY and an Idempotency-Key on every POST.
> - Mark an order paid only on a payment_intent.succeeded webhook whose Fluveo-Signature you checked, never on the return page.
> - Never put sk_, rk_ or whsec_ in anything the browser gets.
> - When you finish, run the Verify list of this page and report each result with the ids you saw.

## Build the checkout

### Step 1: Get your test keys

In the [Dashboard](https://dash.fluveo.com), 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*

```text
FLUVEO_PUBLISHABLE_KEY=pk_test_...
FLUVEO_SECRET_KEY=sk_test_...
FLUVEO_WEBHOOK_SECRET=whsec_...
```

### Step 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:

```bash
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"
```

Node.js:

```javascript
// 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);
```

Python:

```python
# 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:

```php
<?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);
}
```

### Step 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](https://docs.fluveo.com/payments/fluveo-js.md#packages) 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**

HTML:

```html
<!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>
```

React:

```jsx
// 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**

HTML:

```html
<!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>
```

React:

```jsx
// 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>
  );
}
```

### Step 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](https://docs.fluveo.com/webhooks/signatures.md) explains each line of the check.

**The webhook handler**

Node.js:

```javascript
// 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();
  });
}
```

Python:

```python
# 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:

```php
<?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](https://docs.fluveo.com/webhooks.md#create) 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](https://docs.fluveo.com/webhooks/testing.md#local) gives the route such an address. Until then, the [Verify](https://docs.fluveo.com/payments/custom-checkout.md#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.js:

```javascript
node --env-file=.env server.mjs
```

Python:

```python
set -a; . ./.env; set +a
python3 server.py
```

PHP:

```php
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](https://docs.fluveo.com/payments/fluveo-js-errors.md) 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](https://docs.fluveo.com/start/testing.md#typed-cards) 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*

```bash
curl https://api.fluveo.com/v1/payment_intents/pi_... \
  -H "Authorization: Bearer sk_test_..."
```

*verify-webhook.mjs*

```javascript
// 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*

```bash
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*

```javascript
// 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

- [fluveo.js](https://docs.fluveo.com/payments/fluveo-js.md): Every call, what it takes and what it returns.
- [fluveo.js errors](https://docs.fluveo.com/payments/fluveo-js-errors.md): Each error code, with its cause and its fix.
- [Testing](https://docs.fluveo.com/start/testing.md): Card numbers for declines and 3D Secure.
- [Receive webhooks](https://docs.fluveo.com/webhooks.md): Endpoints, retries and duplicates.
