# Embedded checkout

> Put a checkout in your own site, in its look, and know which of your users paid. Plain HTML, Next.js, Vue, WordPress and Webflow, with the CSS variables and events.

Source: https://railbed.com/docs/guides/embedded-checkout/ · Updated: 2026-10-30 · Railbed by MediaGlobe developer docs

For AI assistants: the index of every docs page, with the rules for an integration, is https://railbed.com/docs/llms.txt

## What you get

A checkout you made in the dashboard (a payment widget, a pricing table or a checkout page) runs inside your own page: the buyer picks a plan, enters their details, pays on the card provider's page in a new tab and sees the receipt, all without leaving your site. Two elements do it:

| Element | What buyers see |
|---|---|
| `<railbed-checkout>` | The checkout itself, in your page |
| `<railbed-button>` | A button that opens the checkout over your page (a full-screen sheet on phones) |

On top of the code the dashboard gives you, developers can:

- **Know which of your users paid.** The checkout asks your server for a session when the buyer presses Continue, so the payment carries your own `reference` and `metadata` ([Ask at Continue](#ask-at-continue)).
- **Match your site** with `--railbed-*` CSS variables ([Match your site](#css-variables)).
- **React to what happens** with `railbed:*` events ([Events](#events)).

No API key goes in the page. Card details are never entered in your site: the buyer always pays on the provider's own page. Merchants without a developer can stop at [Widgets and buy buttons](https://railbed.com/docs/user-guide/widgets-and-buttons.md).

## Plain HTML

Copy the code from the checkout's **Share** tab, under **Add it to your site**, or write it yourself with the checkout's slug:

Example: A checkout in your page

```html
<script src="https://pay.railbed.io/embed.js" async></script>

<railbed-checkout checkout="your-checkout-slug">
  <a href="https://pay.railbed.io/c/your-checkout-slug">Buy now</a>
</railbed-checkout>
```

Load the script once per page. The link inside the element is what shows if your site strips or blocks the script, so buyers can still pay; the script draws the checkout over it. For a button, use `<railbed-button checkout="…" label="Book a call" shape="rounded">` instead.

| Attribute | What it does |
|---|---|
| `checkout` | The checkout's slug |
| `session` | A session your server already made (`pay_…`), for a payment you set up before the page loads |
| `session-endpoint` | A path on your site that makes the session when the buyer presses Continue ([Ask at Continue](#ask-at-continue)) |
| `email` | Fills in the buyer's email. It reaches the checkout by message, never in a URL |
| `label`, `shape` | `<railbed-button>` only: its text, and `pill` (the default) or `rounded` |

## Ask at Continue: know which of your users paid

Give the element a `session-endpoint`. The checkout shows at once, and when the buyer presses Continue:

1. The checkout opens the card provider's tab, as it always does.
2. The page POSTs JSON to your endpoint, with your site's cookies: `{ "checkout": "…", "plan": "…", "option": 1, "email": "…" }`. `plan` and `option` are the pricing table's plan id and switch side, or `null`; `email` is what the buyer typed.
3. Your server makes a [session from the checkout](https://railbed.com/docs/api/checkout-sessions.md#from-a-saved-checkout) for its signed-in user, with your `reference`, `metadata` and the user's email, and answers `{ "session": "pay_…" }`.
4. The checkout checks the session is what the buyer chose (same checkout, plan, price and currency) and the buyer pays it.

Example: Node\.js (Express): POST /api/railbed/session

```js
import { randomUUID } from 'node:crypto';

app.post('/api/railbed/session', async (req, res) => {
  const user = req.user; // whoever is signed in to your site
  if (!user) return res.status(401).json({ error: 'sign_in' });
  const { plan = null, option = null } = req.body;

  const response = await fetch('https://pay.railbed.io/v1/checkout_sessions', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.RAILBED_SECRET_KEY}`,
      'Content-Type': 'application/json',
      'Idempotency-Key': randomUUID(),
    },
    body: JSON.stringify({
      checkout: 'your-checkout-slug', // yours, not whatever the page sends
      plan,
      option,
      customer_email: user.email, // the payment uses it, whatever was typed
      metadata: { user_id: String(user.id) },
    }),
  });
  if (!response.ok) return res.status(502).json({ error: 'session_failed' });
  const session = await response.json();
  res.json({ session: session.id });
});
```

- **Decide the checkout on your server.** Use your own slug, or check the one the page sends against the checkouts you sell.
- **Trust your own user record**, not the `email` in the request: a buyer can type anything.
- **Answer within 15 seconds.** Anything other than `{ "session": … }` (the session object itself also works), an error status or no answer in time shows the buyer that their payment couldn't start, with a retry, and fires `railbed:error` with code `session_failed`. A `401` there is your cue to ask the buyer to sign in.
- **Fulfil from the webhook**, never from the page. The `payment.paid` event carries your `metadata` and the checkout. [Know which of your users paid](https://railbed.com/docs/guides/existing-app.md#your-users) saves each attempt first, for safe retries and checks, and [Fulfil orders safely](https://railbed.com/docs/guides/fulfilment.md#sessions-from-a-checkout) shows the checks.

The endpoint is called from the buyer's browser on your own site (`credentials: 'same-origin'`), so use a path on the same site as the page.

## Next.js

The React components come in the `@railbed/embed` package. It loads the same script from Railbed, so fixes reach your site without an update.

```bash
npm install @railbed/embed
```

`RailbedCheckout` and `RailbedButton` are client components, so a server component can render them as they are. Event handlers are functions, which can't be passed from a server component, so put them in a component of your own marked `'use client'`:

Example: app/pricing/pro\-checkout\.tsx

```ts
'use client';
import { useRouter } from 'next/navigation';
import { RailbedCheckout } from '@railbed/embed/react';

export function ProCheckout({ email }: { email?: string }) {
  const router = useRouter();
  return (
    <RailbedCheckout
      checkout="your-checkout-slug"
      sessionEndpoint="/api/railbed/session"
      email={email}
      style={{ '--railbed-accent': '#1F4D3A', '--railbed-font': 'Inter' }}
      onPaid={(detail, event) => {
        event.preventDefault(); // stay in your app instead of going to the success URL
        router.push(`/account?payment=${detail.session}`);
      }}
      onError={(detail) => {
        if (detail.code === 'session_failed') router.push('/sign-in');
      }}
    />
  );
}
```

The route handler for `/api/railbed/session` is in [Know which of your users paid](https://railbed.com/docs/guides/existing-app.md#your-users). Instead of an endpoint you can pass `createSession`, a function that gets `{ checkout, plan, option, email }` and returns the session id, for example through your own API client.

Props: `checkout`, `session`, `sessionEndpoint`, `createSession`, `email`, `label` and `shape` (button only), `className`, `style`, and `onReady`, `onStep`, `onHandoff`, `onPaid`, `onHeld`, `onFailed`, `onExpired`, `onError`, `onClose`, each called with the event's detail and the event. `onLoadError` is called if the script can't load; the plain link to the checkout stays.

## Vue

```bash
npm install @railbed/embed
```

Example: ProCheckout\.vue

```html
<script setup>
import { RailbedCheckout } from '@railbed/embed/vue';

const props = defineProps({ email: String });

function onPaid(detail, event) {
  event.preventDefault(); // stay on this page
  showThanks(detail.session);
}
</script>

<template>
  <RailbedCheckout
    checkout="your-checkout-slug"
    session-endpoint="/api/railbed/session"
    :email="props.email"
    class="checkout"
    @paid="onPaid"
    @error="(detail) => console.warn(detail.code)"
  />
</template>
```

The Vue components take the same props and emit `ready`, `step`, `handoff`, `paid`, `held`, `failed`, `expired`, `error` and `close` with the detail and the event, and `load-error` if the script can't load. `class` and `style` go on the element, so the CSS variables work from either. Your session endpoint is any server route, as in [Ask at Continue](#ask-at-continue).

## WordPress

1. Edit the page in the block editor and add a **Custom HTML** block where the checkout should appear.
2. Paste the code from the checkout's **Share** tab.
3. Publish or preview the page. The editor doesn't run scripts, so the checkout shows only on the page itself.

To match your theme, add the [CSS variables](#css-variables) under **Appearance**, **Customize**, **Additional CSS**. If a security plugin sets a Content Security Policy, allow `https://pay.railbed.io` ([Troubleshooting](#troubleshooting)). Selling from a WooCommerce store? Use the [WooCommerce plugin](https://railbed.com/docs/guides/woocommerce.md) instead.

## Webflow

1. In the Designer, add a **Code Embed** element where the checkout should appear.
2. Paste the code from the checkout's **Share** tab and save.
3. Publish the site and check the published page. Webflow's canvas doesn't run custom scripts.

If you use the checkout on several pages, you can put the `<script>` line once in **Site settings**, **Custom code**, before the closing body tag, and paste only the element on each page. The CSS variables go in a `<style>` tag in the same Code Embed or in the page's custom code.

## Match your site

Set any of these on the element, a parent or `:root`. They win over the checkout's own appearance settings, so set only what you want to change. The checkout keeps Railbed's own design: you choose its accent color, fonts and light or dark theme, and the layout, the other colors, the order summary and the corners stay as they are.

Example: Your styles

```html
<style>
  railbed-checkout,
  railbed-button {
    --railbed-accent: #1F4D3A;
    --railbed-font: Inter;
    --railbed-heading-font: Inter;
    --railbed-mode: auto;
  }
</style>
```

| Variable | What it sets |
|---|---|
| `--railbed-mode` | `light`, `dark`, or `auto` to follow the visitor's setting |
| `--railbed-accent` | Buttons, links and highlights; the buy button's fill. Any CSS color without transparency; Railbed keeps the text on it readable |
| `--railbed-font` | Text: a Google Fonts family name |
| `--railbed-heading-font` | Headings: a Google Fonts family name |

Fonts are served from Railbed's own host, so buyers' browsers never contact Google. When your page changes theme (a class or `data-theme` on `<html>` or `<body>`, or the visitor's light or dark setting), the checkout picks up the new values. A pop-up opened with `Railbed.open` reads the variables from `:root`.

## Events

Each element fires DOM events that bubble, so you can listen on the element or on `document`:

Example: Listen for a payment

```html
<script>
  document.addEventListener('railbed:paid', (event) => {
    // event.detail: { session, checkout, mode, amount, currency, plan, status }
    event.preventDefault(); // stay on this page instead of going to the success URL
    showThanks(event.detail.session);
  });
</script>
```

| Event | When | Detail beyond the common fields |
|---|---|---|
| `railbed:ready` | The checkout is showing | |
| `railbed:step` | The buyer moved on | `step`: `plan`, `details`, `method`, `opening`, `waiting`, `crypto` or `receipt` |
| `railbed:handoff` | The card provider's tab took over | `provider` |
| `railbed:paid` | The payment is confirmed. `preventDefault()` keeps the buyer on your page instead of the success URL | `amount`, `currency`, `status` |
| `railbed:held` | The payment arrived and is being reviewed | `status` |
| `railbed:failed` | The provider declined it | `status` |
| `railbed:expired` | The session expired before payment | `status` |
| `railbed:error` | Something stopped the payment, such as `session_failed` | `code` |
| `railbed:close` | A pop-up closed | `status`: the outcome, or `closed` |

Every detail has `session` (the `pay_…` id, once there is one), `checkout` (the slug) and `mode` (`test` or `live`), and `amount`, `currency` and `plan` (the plan's label) where known. Events never carry the buyer's email or details, your `reference` or `metadata`.

> [!IMPORTANT]
> An event is a signal for your page, not proof of payment. Fulfil orders from the [`payment.paid` webhook](https://railbed.com/docs/webhooks.md) or `GET /v1/payments/:id`.

Without `preventDefault()`, a confirmed payment shows the receipt and then sends your page to the checkout's success URL after a short countdown, if it has one.

## Open a session in a pop-up

A session your server made for an order can open over your page instead of in it. `Railbed.open` takes the session's `url` or its id:

Example: Open a session over your page

```js
import { openCheckout } from '@railbed/embed';

const { status, session } = await openCheckout(sessionId); // or Railbed.open(sessionId) with the script tag
if (status === 'closed') showOrderStatus(); // ask your server whether it's paid
```

It resolves when the pop-up closes, with `status`: `paid`, `held`, `failed`, `expired` or `closed`. [Hosted checkout](https://railbed.com/docs/guides/hosted-checkout.md#pop-up) compares this with sending buyers to the session's page.

## Test it on your site

A Test checkout takes payments only from your team, on your site too: anyone else sees that it's a test and can't pay. To try it where you embedded it:

1. In the dashboard, in Test mode, open the checkout's **Share** tab.
2. Under **Test on your site**, enter the address of your page and choose **Get test link**.
3. Open the link. For one hour, the tab it opens in can try Test payments on your page and [simulate outcomes](https://railbed.com/docs/testing.md).

The link works in any browser that opens it, so share it only with your team. Test payments never move money. When you're ready, embed the Live checkout's code instead.

## Troubleshooting

- **Nothing appears:** check the published page (site builders' editors don't run scripts), that the `<script>` line is there once, and that the slug matches a checkout in the mode you expect.
- **Content Security Policy:** allow `https://pay.railbed.io` in `script-src`, `frame-src` and `connect-src`. The checkout's Share tab names a site where its embed was blocked.
- **"We couldn't start your payment":** your session endpoint answered an error, something other than `{ "session": … }`, or took longer than 15 seconds. Listen for `railbed:error` to see the code.
- **"This is a test checkout":** you embedded a Test checkout. Use [Test on your site](#test-on-your-site), or embed the Live one.
- **The provider opens in a new tab:** that's expected. Card entry always happens on the provider's own page.
