---
name: fluveo-custom-checkout
description: "Build or fix a checkout page that takes payments with Fluveo's payment form (fluveo.js, loaded from https://elements.fluveo.com/v1/) and the Fluveo /v1 API. Use it when the task names Fluveo or fluveo.js, shows a pk_test_ or sk_test_ key, or asks for a checkout page, a payment form or a Pay button in an app that takes payments with Fluveo."
---

# Fluveo custom checkout

The page, the cart and the Pay button belong to the app. The payment form is Fluveo's and sits in a frame, so the app's code never sees a card number.

## Read first

Fetch `https://docs.fluveo.com/llms/custom-checkout.txt` and follow it over anything you remember from other payments APIs. It is one file, under 40 KB, with the quickstart, the fluveo.js calls a checkout makes, the errors that mounting the form or confirming a payment throws, the test cards and the webhook signature check. Before you change how the form looks or behaves, fetch `https://docs.fluveo.com/llms/payment-element.txt` too: the form's options and events, with the errors they throw. Every other page of the docs is listed at `https://docs.fluveo.com/llms.txt`.

## Rules

- Never build a card input, and never read, keep or send a card number in your code. Mount Fluveo's form: it takes the card.
- Load the script only from `https://elements.fluveo.com/v1/`. A page with no build step uses 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 the bundle, never install another payments package, and never load another payments script.
- Install no server SDK. Call `https://api.fluveo.com/v1` over plain HTTP with form-encoded bodies and an `Idempotency-Key` on every POST.
- Read the secret key from `FLUVEO_SECRET_KEY` and the webhook secret from `FLUVEO_WEBHOOK_SECRET`, on the server only. The browser gets the publishable key (`pk_test_...`) and the client secret, and nothing else.
- Never ask the person to paste a key into the chat. Ask them to set the environment variables.
- Use test keys only.
- Mark an order paid only on a verified `payment_intent.succeeded` event, once per event id. The return page only thanks the buyer.
- When fluveo.js throws an integration error, do what its message says and read the page its `doc_url` names. Never work around it.

## Steps

1. Server: a route that makes a PaymentIntent with `POST https://api.fluveo.com/v1/payment_intents` (`amount` in the smallest currency unit, `currency`, your order id in `metadata`) and answers its `client_secret`.
2. Page: a script tag for `https://elements.fluveo.com/v1/`, an empty `<div id="payment-element">` inside your form, then `Fluveo(publishableKey)`, `fluveo.elements({ clientSecret })`, `elements.create("payment")` and `mount("#payment-element")`.
3. Pay button: on submit, `fluveo.confirmPayment({ elements, confirmParams: { return_url } })`. An approved payment sends the buyer to `return_url`; a declined one answers `{ error }`, and you show `error.message`.
4. Return page: `fluveo.retrievePaymentIntent(clientSecret)` with the `payment_intent_client_secret` parameter of the address, to say what happened.
5. Webhook: a route that reads the raw body, checks the `Fluveo-Signature` header and marks the order paid on `payment_intent.succeeded`.

## Verify

Run each check yourself, in test mode, and report its result with the ids you saw. Do not say you are done before all pass.

1. The page shows Fluveo's form in a frame from `https://elements.fluveo.com`, and has no card input of its own.
2. The card 4242 4242 4242 4242, with any future date and any security code, pays: the server reads the PaymentIntent as `succeeded`.
3. A card that [Testing](https://docs.fluveo.com/start/testing.md#typed-cards) lists as declined shows a message on the page, and 4242 4242 4242 4242 then pays the same PaymentIntent.
4. A card that asks for 3D Secure opens the test authentication page. After Complete the buyer is back on `return_url`, and the PaymentIntent is `succeeded`.
5. The webhook route answers 200 to a correctly signed body and marks the order paid. It answers 400 to a changed body and to a timestamp older than 5 minutes.
6. The same event delivered twice marks the order paid once.
7. No `sk_` or `whsec_` value is in any file sent to the browser, in the logs or in the git history.
8. Every POST to the API carries an `Idempotency-Key`.
