> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.fluveo.com/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.fluveo.com/_mcp/server.

# Embed Fluveo Checkout in an iframe

> **Status: test-only.** Embedding is available for test-mode Checkout Sessions. Card
> payments, including 3-D Secure, are exercised in test mode.

You can show the [hosted payment page](/checkout/hosted-payment-page) of a
[Checkout Session](/checkout/checkout-sessions) inside an `<iframe>` on your own
checkout page instead of redirecting to it. The card fields still live on Fluveo's
page, so card data never reaches your page or your servers.

> **PCI DSS.** An embedded payment page is not the same as a redirect for SAQ A
> eligibility: with an iframe, the page that hosts it must also be protected against
> script attacks (PCI SSC FAQ 1588). Confirm your scope with your assessor before you
> switch from a redirect.

How it works:

1. Your server creates a Checkout Session and gets its `url`.
2. Your page puts that `url` in an iframe. The customer pays inside it.
3. When the payment reaches a final status, the page in the iframe sends your page
   a `fluveo.checkout.completed` message. It does **not** navigate your page by itself.
4. Your page asks your server, and your server confirms the outcome with
   `GET /v1/checkout/sessions/{id}` before it fulfils the order.

When the card needs 3-D Secure, the customer leaves your page for the challenge and
comes back on your `success_url` instead (see [3-D Secure](#3-d-secure)).

## 1. Create the Checkout Session (server)

Create the session exactly as for a redirect. `success_url` and `cancel_url` must be
pages on your site that can finish the order on their own, because a customer who
goes through 3-D Secure returns there as a full page.

**`curl`**

```bash title="curl"
curl https://api.devfluveo.com/v1/checkout/sessions \
  -H "Authorization: Bearer $FLUVEO_API_KEY" \
  -d mode=payment \
  -d "success_url=https://shop.example.com/order/complete?session_id={CHECKOUT_SESSION_ID}" \
  -d "cancel_url=https://shop.example.com/cart" \
  -d "line_items[0][price_data][currency]=brl" \
  -d "line_items[0][price_data][unit_amount]=4990" \
  -d "line_items[0][price_data][product_data][name]=Order 1042" \
  -d "line_items[0][quantity]=1"
```

The answer has the session `id` (`cs_test_...`) and its `url`
(`https://pay.devfluveo.com/c/cs_test_...` in test mode). Send both to your page.

Embed the session `url` only. A [Payment Link](/checkout/payment-links) URL
(`/p/...`) refuses to be framed.

## 2. Embed the page (browser)

**`checkout.html`**

```html title="checkout.html"
<iframe
  id="fluveo-checkout"
  src="https://pay.devfluveo.com/c/cs_test_..."
  title="Payment"
  allow="payment"
  style="width: 100%; min-height: 900px; border: 0"
></iframe>
```

* **Do not sandbox the iframe.** That is the setup Fluveo has tested (Chromium).
* If your policy requires `sandbox`, we recommend
  `sandbox="allow-scripts allow-same-origin allow-forms allow-top-navigation-by-user-activation"`.
  The 3-D Secure step navigates the whole tab, so it needs
  `allow-top-navigation-by-user-activation`. This recipe follows from that mechanism; it
  has not been tested, so try a 3-D Secure test card in each browser you support.
* Keep `allow="payment"` so browser wallets can use the Payment Request API inside the frame.

## 3. Listen for the completion message (browser)

When the page in the iframe reaches a final status, it posts one message to its parent:

```json
{ "type": "fluveo.checkout.completed", "checkout_session": "cs_test_...", "status": "succeeded" }
```

| Field              | Meaning                                                                                                                                              |
| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| `type`             | Always `fluveo.checkout.completed`.                                                                                                                  |
| `checkout_session` | The Checkout Session id.                                                                                                                             |
| `status`           | The payment's final status: `succeeded`, `requires_capture` (manual capture), `partially_captured`, `failed`, `canceled` / `cancelled` or `expired`. |

The message carries no secret, and it is sent to any parent (target origin `*`).
Anyone who can frame the page can receive it, and any page can post a look-alike
message to yours. So:

* check `event.origin`: it must be the origin of the session `url`
  (`https://pay.devfluveo.com` in test mode, `https://pay.fluveo.com` in live mode);
* check `event.source` is your iframe and `checkout_session` is the session you created;
* **never fulfil an order from the message alone.** Treat it as "go and check now".

**`checkout.html`**

```html title="checkout.html"
<script>
  const sessionId = "cs_test_...";           // from your server, step 1
  const sessionUrl = "https://pay.devfluveo.com/c/cs_test_..."; // the session url
  const frame = document.getElementById("fluveo-checkout");

  window.addEventListener("message", async (event) => {
    if (event.origin !== new URL(sessionUrl).origin) return;
    if (event.source !== frame.contentWindow) return;
    const data = event.data;
    if (!data || data.type !== "fluveo.checkout.completed") return;
    if (data.checkout_session !== sessionId) return;

    // Ask your server; it reads the session from Fluveo (step 4).
    const response = await fetch(`/orders/confirm?session_id=${encodeURIComponent(sessionId)}`);
    const order = await response.json();
    if (order.paid) {
      window.location.assign(`/order/complete?session_id=${encodeURIComponent(sessionId)}`);
    }
  });
</script>
```

## 4. Confirm on your server

Read the session with your secret key and fulfil only when it is paid:

**`curl`**

```bash title="curl"
curl https://api.devfluveo.com/v1/checkout/sessions/cs_test_... \
  -H "Authorization: Bearer $FLUVEO_API_KEY"
```

**`server.js (Node 18+)`**

```js title="server.js (Node 18+)"
// GET /orders/confirm?session_id=cs_test_...
async function confirmOrder(sessionId) {
  const response = await fetch(
    `https://api.devfluveo.com/v1/checkout/sessions/${encodeURIComponent(sessionId)}`,
    { headers: { Authorization: `Bearer ${process.env.FLUVEO_API_KEY}` } },
  );
  if (!response.ok) throw new Error(`Fluveo answered ${response.status}`);
  const session = await response.json();
  const paid = session.status === "complete" && session.payment_status === "paid";
  if (paid) {
    // Fulfil once: key the fulfilment on session.id so a repeat call does nothing.
  }
  return { paid };
}
```

Use the same check on your `success_url` page. If the customer closes the tab before
either runs, a signed [webhook](/webhooks) `payment_intent.succeeded` for the session's
PaymentIntent (`payment_intent` on the session) still reaches your server.

## 3-D Secure

When the card needs 3-D Secure, the challenge does **not** stay inside the iframe:

1. The customer presses Pay inside the iframe.
2. The whole tab moves to the bank's challenge page. Your checkout page is no longer shown.
3. After the challenge, the tab returns to the Fluveo page and then goes to your
   `success_url` as a full page.

No `fluveo.checkout.completed` message is sent in this case, because the Fluveo page
is no longer in your iframe. Your `success_url` page confirms the order with step 4.

## "Return to merchant" link

Inside an iframe, the paid page does not send the customer anywhere by itself: browsers
block a framed page from navigating your page without a click. It shows a
**Return to merchant** link to your `success_url` instead, which opens in the whole tab.
Most integrations navigate on their own after step 4, so the customer rarely needs it.

## Checklist

* The session is created on your server; the browser only gets its `id` and `url`.
* The iframe has no `sandbox`; or, with `sandbox`, the four tokens above, and a 3-D Secure
  test payment works in your browsers.
* Your listener checks `event.origin`, `event.source`, `type` and `checkout_session`.
* Fulfilment happens only after `GET /v1/checkout/sessions/{id}` shows
  `payment_status: "paid"`, and only once per session.
* `success_url` and `cancel_url` work as full pages (3-D Secure returns there).