Your first checkout
End-to-end walkthrough of the SDK's happy path. By the end of this guide, an attendee can render the event, select tickets, hold inventory with a reservation, complete checkout (free or paid via Stripe), and see the order's terminal status.
What you'll build
A single embedded React component that walks an attendee through:
- Display the event's name, dates, location, and organizer.
- Show available ticket types with quantity selection (wave-tier pricing collapsed to one row per type — "From $X").
- Hold inventory with a server-issued reservation (captcha-gated).
- Show the reservation's line items and an MM:SS countdown to expiry.
- Confirm checkout — free events succeed immediately; paid events
mount Stripe Elements and confirm a
PaymentIntent. - Poll the order's status until it reaches a terminal state
(
completed,failed, orrefunded).
Total integration: one file, ~50 lines.
Prerequisites
- Installation complete —
@feelrift/reactinstalled, peers in place, framework placement chosen. - An
eventIdfor an event with at least one configured wave and one ticket type. The dashboard creates these. - For paid events:
@stripe/stripe-jsand@stripe/react-stripe-jsinstalled (see Installation → Paid events). - A
returnUrlyour site exposes — the URL Stripe redirects to after a 3DS or wallet redirect (e.g.https://your.site/checkout/return).
The full example
"use client";
import {
AvailabilityList,
CaptchaWidget,
CheckoutForm,
Countdown,
EstimateBreakdown,
EventHeader,
ReservationSummary,
ReserveButton,
RiftEvent,
RiftProvider,
useOrderStatus,
} from "@feelrift/react";
export function TicketingEmbed() {
return (
<RiftProvider
locale="es-MX"
on={{
paymentSucceeded: (orderId, orderStatusToken) => {
// Fires after a paid `confirmPayment` resolves OK, or
// immediately for free orders. Carry the `orderId` and
// short-lived `orderStatusToken` into your post-purchase
// routing — `useOrderStatus` uses the token to poll.
// See: Guides → Securing the order-status token.
router.push(
`/order/${orderId}?t=${encodeURIComponent(orderStatusToken)}`,
);
},
paymentFailed: (err) => {
console.error("Payment failed:", err);
},
}}
>
<RiftEvent eventId="evt_summerfest_2026">
<EventHeader />
<AvailabilityList />
<EstimateBreakdown />
<CaptchaWidget />
<ReserveButton requireCaptcha />
<ReservationSummary />
<Countdown />
<CheckoutForm returnUrl="https://your.site/checkout/return" />
<OrderStatusBanner />
</RiftEvent>
</RiftProvider>
);
}
function OrderStatusBanner() {
const { data, isTerminal } = useOrderStatus();
if (!data) return null;
if (isTerminal) {
return (
<p>
Order {data.orderId}: {data.status}
</p>
);
}
return <p>Finalizing order {data.orderId}…</p>;
}
That's the entire happy path. The rest of this page explains why each piece is there, then shows how to verify the flow works.
What each piece does
<RiftProvider>
Root of the SDK runtime. Holds API base URLs, locale, theming tokens, and behavioral callbacks. Place it once near the top of the embedded surface — see Installation for framework-specific placement.
The on prop accepts a set of callbacks fired on state transitions
inside the SDK. The two most commonly used are paymentSucceeded
(your post-purchase landing) and paymentFailed (Sentry hook). See
the <RiftProvider> reference
for the full list.
<RiftEvent>
Scopes everything inside it to one event. Owns the eventId and
lazily imports @stripe/stripe-js when the event's Ticketing config
carries a non-null stripe (paid events). Free events skip the import
entirely.
Note Multiple
<RiftEvent>instances on the same page are supported, e.g. for a multi-event picker. Each scopes its own subtree; reservations and checkout state are per-event.
<EventHeader>
Optional display: event name, dates, location, organizer. Reads from
useConfig() — renders nothing until config loads, then paints
without flicker because the config is persisted to localStorage and
hydrates from the cache on subsequent visits.
Drop it if you have your own header design — call useConfig()
directly to get the same data.
<AvailabilityList>
Renders a flat list of ticket-type rows with quantity steppers.
Waves (the pricing/scheduling tiers) are collapsed internally —
each row shows the cheapest active price as "From $X" and the
stepper clamps at the total available count and the most permissive
max-per-order cap across active waves with inventory.
The wave-tier breakdown surfaces in <EstimateBreakdown> once a
quantity is picked.
Reads selection from the event-scoped context that <RiftEvent>
provides — <ReserveButton> reads from the same scope, which is
why both can be siblings here rather than nested.
For custom layouts:
<AvailabilityList>
{(ticketTypes) =>
ticketTypes.map((tt) => /* your custom rendering per ticket type */)
}
</AvailabilityList>
Tip Selection lives on
<RiftEvent>, so the picker and the reserve action can be laid out independently — sticky reserve at the bottom, two-column with a persistent summary on the side, separate panels with intermediate content. See<RiftEvent>— Two-panel layout example.
<EstimateBreakdown>
Pre-reservation pricing breakdown. Appears as soon as a quantity is selected. Walks each ticket type's currently-active waves cheapest-first (the same algorithm the reservation service uses) and renders a hierarchical tree: per ticket type → wave-split lines → subtotal → grand total + always-visible disclaimer.
The wave concept doesn't surface in the picker — <AvailabilityList>
collapses it. The breakdown is where wave-tier transparency lives:
when an attendee picks 2 of a ticket type that has 1 left at $80
in the early-bird wave and unlimited at $100 in the regular wave,
the breakdown shows them why the total is $180, not $160.
Note The disclaimer is visible by default and covers a real regulatory expectation about modifier transparency (fees, taxes added at checkout). Override the copy via the
disclaimerprop or globally via<RiftProvider strings={...}>; passdisclaimer={null}only if you display equivalent copy elsewhere on the page.
Layout-neutral: the component ships no position, no sticky
behavior. Put it in an inline panel, a sticky drawer, a sidebar,
or a modal — that's your CSS, not the SDK's.
See the full algorithm and the disclaimer requirements in Pricing and estimates.
<CaptchaWidget>
Solves the captcha challenge against the provider's configured
altchaBaseUrl. The SDK attaches the solution as the
altcha-payload header on the next mutating request (reservations,
checkout). Solutions are single-use server-side; the widget flips
back to idle after each consumption so a new solve is required for
the next call.
<ReserveButton requireCaptcha />
Submits POST /events/{eventId}/ticketing/reservations with the items
selected in <AvailabilityList>. The requireCaptcha prop disables
the button until the captcha solution is ready — set it whenever a
<CaptchaWidget> is mounted nearby.
On success, the SDK stores the resulting reservation token + line
items in the persisted store. On failure, the button surfaces the
server's error in a styled <div> underneath — rate-limit messages
include the retryAfterSeconds, sold-out messages route via the
error.insufficientInventory string, everything else falls back to a
generic message.
<ReservationSummary> + <Countdown>
After a reservation succeeds, these two paint. <ReservationSummary>
shows the line items the server confirmed (which may differ from the
estimate the UI displayed if prices changed between page load and
reservation — see <PriceChangedWarning> to surface the delta).
<Countdown> renders an MM:SS countdown to the reservation's
expiresAt. Both render nothing when there's no active reservation,
so they're safe to leave mounted in the same tree.
Note The countdown stops at 00:00. A checkout already in flight may still complete briefly past that, but the UI should not depend on it. After expiry, have the user start over.
<CheckoutForm returnUrl=… />
Branches on the event's Ticketing config: disabled Ticketing renders
an unavailable state, free Ticketing renders email + submit, and paid
Ticketing mounts Stripe Elements. The SDK treats the event as free
when ticketingConfig.enabled === true and
ticketingConfig.stripe === null; paid when stripe is present.
Free checkout returns success immediately and fires
on.paymentSucceeded. Paid checkout runs a three-step flow:
useCheckout().prepare({ email })calls the checkout endpoint to mint an order + PaymentIntent client secret.useCheckoutPayment().capture()collects a PaymentMethod through Stripe Elements.useCheckout().confirm({ returnUrl })charges the card. 3DS / wallet redirects hand off toreturnUrl; on-page confirmations callon.paymentSucceededdirectly.
The returnUrl is required for paid events — the SDK throws at
render time if you forget it on a paid event. Free events ignore it.
Tip
returnUrlalso accepts a function(response) => string— use it to encodeorderIdandorderStatusTokeninto the URL so the post-redirect page recovers state without reading the persisted store. Useful for cross-domain return pages, new-tab confirmations, or SSR-rendered return routes. See<CheckoutForm>—returnUrl.
Warning The
orderStatusTokenis a bearer credential. When you carry it in the return URL, the return page must strip it from the URL on first render to keep it out of browser history, theRefererheader on subsequent clicks, and downstream server access logs. See Securing the order-status token for the recommended pattern.
Note
clientSecretis never persisted. If the user reloads mid-checkout, the SDK re-mints the PaymentIntent from the reservation token rather than rehydrating a stale secret (which Stripe could have invalidated server-side in the meantime).
useOrderStatus()
Polls GET /events/{eventId}/ticketing/orders/{orderId}/status until
the order reaches a terminal state (completed, failed, or
refunded). Picks up eventId, orderId, and orderStatusToken
from SDK state by default — these are populated automatically when
<CheckoutForm> confirms. The hook auto-stops on terminal status; the
returned isTerminal flag tells you when it's safe to navigate away.
For paid events that involve 3DS, polling covers the window between
stripe.confirmPayment returning and the order reaching its
terminal status — confirmation and order completion are
asynchronous, so always wait for the status, never assume.
Verify
Drop the example into your app and walk through the flow end-to-end with a paid event:
- Page loads.
<EventHeader>paints the event metadata. The browser network panel showsGET /api/v1/events/<id>/config,GET /api/v1/events/<id>/ticketing/config, andGET /api/v1/events/<id>/ticketing/availability, all200. - Pick at least one ticket quantity in
<AvailabilityList>. The<ReserveButton>becomes enabled when the captcha clears. - Click Verify on the captcha widget. The widget flips to "Verified".
- Click the reserve button. The network panel shows
POST /api/v1/events/<id>/ticketing/reservationsreturning200.<ReservationSummary>and<Countdown>paint. - Enter an email in
<CheckoutForm>and (for paid events) the<PaymentElement>card details. Use Stripe's test card4242 4242 4242 4242with any future expiry and any CVC. - Submit. The network panel shows
POST /api/v1/events/<id>/ticketing/checkoutreturning200. Stripe'sconfirmPaymentresolves OK. - The
paymentSucceededcallback fires with theorderId. <OrderStatusBanner>appears, pollingGET /api/v1/events/<id>/ticketing/orders/<orderId>/statusevery 2s. It transitions tocompletedwithin a few seconds and stops polling.
If any step fails, see Error handling for the codes the SDK surfaces and how to handle each one.
Next steps
You have a working embed. From here:
- Error handling — every server error code and how to handle it.
- Server-side rendering — the guarantees the SDK ships with and what consumers need to do to hold them.
- Component reference — every prop, every callback, every example.
Common deviations
| You want to… | Reach for… |
|---|---|
Custom item rendering inside <AvailabilityList> | Function-children render prop; see <AvailabilityList>. |
| Replace the default reserve-button styling | asChild slot on <ReserveButton> — keeps the click handler, swaps the element. See reference. |
Build the checkout flow without <CheckoutForm> | useCheckout — same data flow, no UI. |
| Show free events differently from paid | useTicketingConfig().data discriminates: enabled === true && stripe === null is free; stripe !== null is paid. Branch before mounting <CheckoutForm>. |
| Drive the reservation from a custom button | useReservation — same data flow as <ReserveButton>, no UI. Returns { reservation, estimate, breakdown, token, createReservation, … }. |
| Display the seconds remaining as a custom widget | <Countdown> accepts function children: <Countdown>{({ secondsRemaining }) => …}</Countdown>. |