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.
On this page
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
referenceandmetadata(Ask at Continue). - Match your site with
--railbed-*CSS variables (Match your site). - React to what happens with
railbed:*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.
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:
<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) |
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:
- The checkout opens the card provider's tab, as it always does.
- The page POSTs JSON to your endpoint, with your site's cookies:
{ "checkout": "…", "plan": "…", "option": 1, "email": "…" }.planandoptionare the pricing table's plan id and switch side, ornull;emailis what the buyer typed. - Your server makes a session from the checkout for its signed-in user, with your
reference,metadataand the user's email, and answers{ "session": "pay_…" }. - The checkout checks the session is what the buyer chose (same checkout, plan, price and currency) and the buyer pays it.
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
emailin 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 firesrailbed:errorwith codesession_failed. A401there is your cue to ask the buyer to sign in. - Fulfil from the webhook, never from the page. The
payment.paidevent carries yourmetadataand the checkout. Know which of your users paid saves each attempt first, for safe retries and checks, and Fulfil orders safely 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.
npm install @railbed/embedRailbedCheckout 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':
'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. 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
npm install @railbed/embed<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.
WordPress
- Edit the page in the block editor and add a Custom HTML block where the checkout should appear.
- Paste the code from the checkout's Share tab.
- 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 under Appearance, Customize, Additional CSS. If a security plugin sets a Content Security Policy, allow https://pay.railbed.io (Troubleshooting). Selling from a WooCommerce store? Use the WooCommerce plugin instead.
Webflow
- In the Designer, add a Code Embed element where the checkout should appear.
- Paste the code from the checkout's Share tab and save.
- 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.
<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:
<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.
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:
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 paidIt resolves when the pop-up closes, with status: paid, held, failed, expired or closed. Hosted checkout 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:
- In the dashboard, in Test mode, open the checkout's Share tab.
- Under Test on your site, enter the address of your page and choose Get test link.
- Open the link. For one hour, the tab it opens in can try Test payments on your page and simulate outcomes.
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.ioinscript-src,frame-srcandconnect-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 forrailbed:errorto see the code. - "This is a test checkout": you embedded a Test checkout. Use 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.