Skip to main content

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:

  1. Display the event's name, dates, location, and organizer.
  2. Show available ticket types with quantity selection (wave-tier pricing collapsed to one row per type — "From $X").
  3. Hold inventory with a server-issued reservation (captcha-gated).
  4. Show the reservation's line items and an MM:SS countdown to expiry.
  5. Confirm checkout — free events succeed immediately; paid events mount Stripe Elements and confirm a PaymentIntent.
  6. Poll the order's status until it reaches a terminal state (completed, failed, or refunded).

Total integration: one file, ~50 lines.

Prerequisites

  • Installation complete — @feelrift/react installed, peers in place, framework placement chosen.
  • An eventId for an event with at least one configured wave and one ticket type. The dashboard creates these.
  • For paid events: @stripe/stripe-js and @stripe/react-stripe-js installed (see Installation → Paid events).
  • A returnUrl your 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 disclaimer prop or globally via <RiftProvider strings={...}>; pass disclaimer={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:

  1. useCheckout().prepare({ email }) calls the checkout endpoint to mint an order + PaymentIntent client secret.
  2. useCheckoutPayment().capture() collects a PaymentMethod through Stripe Elements.
  3. useCheckout().confirm({ returnUrl }) charges the card. 3DS / wallet redirects hand off to returnUrl; on-page confirmations call on.paymentSucceeded directly.

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 returnUrl also accepts a function (response) => string — use it to encode orderId and orderStatusToken into 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 orderStatusToken is 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, the Referer header on subsequent clicks, and downstream server access logs. See Securing the order-status token for the recommended pattern.

Note clientSecret is 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:

  1. Page loads. <EventHeader> paints the event metadata. The browser network panel shows GET /api/v1/events/<id>/config, GET /api/v1/events/<id>/ticketing/config, and GET /api/v1/events/<id>/ticketing/availability, all 200.
  2. Pick at least one ticket quantity in <AvailabilityList>. The <ReserveButton> becomes enabled when the captcha clears.
  3. Click Verify on the captcha widget. The widget flips to "Verified".
  4. Click the reserve button. The network panel shows POST /api/v1/events/<id>/ticketing/reservations returning 200. <ReservationSummary> and <Countdown> paint.
  5. Enter an email in <CheckoutForm> and (for paid events) the <PaymentElement> card details. Use Stripe's test card 4242 4242 4242 4242 with any future expiry and any CVC.
  6. Submit. The network panel shows POST /api/v1/events/<id>/ticketing/checkout returning 200. Stripe's confirmPayment resolves OK.
  7. The paymentSucceeded callback fires with the orderId.
  8. <OrderStatusBanner> appears, polling GET /api/v1/events/<id>/ticketing/orders/<orderId>/status every 2s. It transitions to completed within 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:

Common deviations

You want to…Reach for…
Custom item rendering inside <AvailabilityList>Function-children render prop; see <AvailabilityList>.
Replace the default reserve-button stylingasChild 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 paiduseTicketingConfig().data discriminates: enabled === true && stripe === null is free; stripe !== null is paid. Branch before mounting <CheckoutForm>.
Drive the reservation from a custom buttonuseReservation — 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>.