Checkout
Design one checkout page with headless components, then sell any offer on it by id. The API prices every purchase and PayPal takes the payment.
The checkout SDK is its own entry point, @offer/sdk/checkout, so pages that only check access never load PayPal. It needs the API URL, the app id and the publishable key. The secret key stays on your server. See Access and entitlements for how the SDK is imported.
Before you start
- Connect PayPal for the app, in the dashboard or with
PUT /apps/:appId/paypal. Without it,offer.paypalisnull,Offer.Checkoutrenders a “Payments aren't set up” message, and starting a checkout returns409. - Publish the offer. Drafts and archived offers aren't sold. The page falls back to regular prices instead.
- Set the app's
checkout_urlto this page, so share links and 402 upgrade links point at it.
How a purchase works
Load the offer
getOffer()on the server, or the provider in the browser, callsGET /apps/:appId/offers/:offerId/public. It never fails for a bad id: it returns the requested offer if it can be bought, else thefallbackoffer, else the plans at their regular prices. It also returns the app's PayPal client id.The buyer picks
The components keep the selection valid as the buyer changes it: only plans sold for the chosen interval, and only bumps that apply to that plan and interval. Totals on the page are for display.
The API prices it
Clicking the PayPal button calls
POST /apps/:appId/offers/:offerId/checkoutwith only the selection. The API prices it again from the stored offer, saves the quote on the checkout and creates a PayPal subscription for monthly and yearly prices, or a PayPal order for a one-time price.The buyer approves in PayPal
PayPal's popup opens with the API's subscription or order id. On approval the provider calls
POST /apps/:appId/checkouts/:checkoutId/complete, which checks the payment with PayPal, captures one-time orders and applies the purchase to the account.Confirmed
While PayPal is still activating a subscription,
/completereturnsstatus: "created". The provider retries every 1.5 seconds, up to 20 times, then callsonSuccesswith the completed checkout. PayPal's webhook applies the same purchase if the buyer closes the tab first, as long as the API has a public HTTPS URL for PayPal to call.
A full checkout page
Fetch the offer on the server so the page renders with prices, then hand everything to a client component. The query string decides what the page sells: ?offer= picks the offer, ?ref= records who sent the buyer, and ?plan= and ?interval= preselect a plan.
// app/pricing/page.tsx
import { getOffer, type Interval } from "@offer/sdk/checkout";
import { requireUser } from "@/lib/session"; // your own auth helper
import { CheckoutForm } from "./checkout-form";
const INTERVALS: Interval[] = ["month", "year", "once"];
export default async function PricingPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {
const { offer, ref, plan, interval } = await searchParams;
const user = await requireUser();
const config = {
apiUrl: process.env.OFFER_API_URL!,
appId: process.env.OFFER_APP_ID!,
publishableKey: process.env.OFFER_PUBLIC_KEY!,
};
// Fetched on the server so the page renders with prices.
const initialOffer = await getOffer({ ...config, offerId: offer, account: user.id, ref });
return (
<CheckoutForm
{...config}
initialOffer={initialOffer}
offerId={offer ?? null}
account={user.id}
refCode={ref ?? null}
plan={plan ?? null}
interval={INTERVALS.find((i) => i === interval) ?? null}
/>
);
}// app/pricing/checkout-form.tsx
"use client";
import { Offer, OfferProvider, type OfferProviderProps } from "@offer/sdk/checkout";
import { useRouter } from "next/navigation";
export function CheckoutForm(props: Omit<OfferProviderProps, "children" | "onSuccess">) {
const router = useRouter();
return (
<OfferProvider {...props} onSuccess={(checkout) => router.push(`/welcome?checkout=${checkout.id}`)}>
<Offer.Status />
<Offer.Headline />
<Offer.IntervalToggle labels={{ year: "Yearly (2 months free)" }} />
<Offer.Plans className="plans">{(plan) => <Offer.Plan plan={plan} className="plan" />}</Offer.Plans>
<Offer.Bumps className="bumps">{(bump) => <Offer.Bump bump={bump} />}</Offer.Bumps>
<Offer.Summary />
<Offer.Email />
<Offer.Checkout style={{ color: "black", shape: "pill" }} />
</OfferProvider>
);
}The dashboard's /demo/checkout page is a styled version of this pattern.
OfferProvider
| Prop | Type | Description |
|---|---|---|
| apiUrl | string | The Offer API's public URL. |
| appId | string | The app to sell for. |
| publishableKey | string | The app's pub_… key. Safe in the browser. |
| offerId | string | null | The offer to sell, usually from ?offer=. Without it, the plans sell at regular prices. |
| fallback | string | null | The offer to show when offerId can't be bought. |
| initialOffer | PublicOffer | The result of getOffer() on the server. Skips the browser fetch. |
| account | string | null | The signed-in buyer's account id. Without it, the buyer enters an email. |
| string | null | Prefills the email field. | |
| refCode | string | null | Who sent the buyer, usually from ?ref=. Saved on the checkout. |
| plan | string | null | Preselects a plan. |
| interval | "month" | "year" | "once" | null | Preselects a billing interval. |
| onSuccess | (checkout: Checkout) => void | Runs once the API has applied the purchase. |
| fetch | typeof fetch | Optional custom fetch. |
account against them. Without an account, the buyer enters an email and the API creates a new account (acct_…) when the payment completes. The checkout.completed webhook carries that account in data.account, so link it to your user there.Components
Offer.* components read from the nearest provider and render plain, unstyled markup. Each takes className. Most also take a function as children to render your own markup with the same state.
| Component | Props | Renders |
|---|---|---|
| Offer.Status | children?: (reason, ctx) => ReactNode | Why the requested offer isn't shown (expired, sold out…). Nothing otherwise. |
| Offer.Headline | children?: (ctx) => ReactNode | The offer's headline, subhead and bullets, or its name. |
| Offer.IntervalToggle | labels?: Partial<Record<Interval, ReactNode>> | A radio group of intervals. Hidden when the offer has only one. |
| Offer.Plans | children: (plan) => ReactNode | One child per plan sold for the selected interval. |
| Offer.Plan | plan, children?: (props: PlanRenderProps) => ReactNode | A radio button with name, price, renewal terms, description and features. |
| Offer.Bumps | children: (bump) => ReactNode | One child per bump that applies to the selection. Nothing when none apply. |
| Offer.Bump | bump, children?: (props: BumpRenderProps) => ReactNode | A checkbox with label, price and description. |
| Offer.Summary | children?: (ctx) => ReactNode | Line items, the total due today and the renewal terms. |
| Offer.Email | label?, placeholder? | An email field. Renders nothing when the provider has an account. |
| Offer.Checkout | onSuccess?, style?, children?: (ctx) => ReactNode | PayPal's buttons for the selection, plus status and error text. children replaces all of it and PayPal's buttons don't load, so a custom button calls ctx.redirectToPaypal(). See Paying on PayPal's page. |
Render props
Offer.Plan and Offer.Bump do the price formatting for you when you render your own markup.
import { Offer, type PlanRenderProps } from "@offer/sdk/checkout";
function PlanCard({ plan, selected, select, priceText, listPriceText, renewalText, savings }: PlanRenderProps) {
return (
<button type="button" role="radio" aria-checked={selected} className="plan-card" onClick={select}>
<span>{plan.name}</span>
{savings ? <span className="badge">Save {savings}%</span> : null}
<span>
{listPriceText ? <s>{listPriceText}</s> : null} <strong>{priceText}</strong>
</span>
<span>{renewalText ?? plan.description}</span>
</button>
);
}
<Offer.Plans>{(plan) => <Offer.Plan plan={plan}>{(props) => <PlanCard {...props} />}</Offer.Plan>}</Offer.Plans>| PlanRenderProps | Type | Description |
|---|---|---|
| plan | OfferPlan | The plan, with benefits, extras and prices. |
| price | OfferPrice | amount, list_price, cycles and then for the selected interval. |
| interval, currency | Interval, string | The selected interval and the offer's currency. |
| selected, select() | boolean, () => void | Whether it's selected, and how to select it. |
| priceText | string | "$10/mo" |
| listPriceText | string | null | The regular price when discounted, else null. |
| renewalText | string | null | "for 3 months, then $20/mo", or null when the price never changes. |
| savings | number | null | Percent off the regular price. |
| BumpRenderProps | Type | Description |
|---|---|---|
| bump | OfferBump | label, description, amount and the add-ons it grants. |
| selected | boolean | Whether it's in the selection. |
| toggle(on?) | (on?: boolean) => void | Adds or removes it. |
| priceText | string | "+$50" |
Styling
The default markup carries its state in data-* attributes, so you can style it from a stylesheet without render props.
/* The default markup carries its state in data attributes. */
[data-offer-plan][data-selected] { border-color: var(--accent); }
[data-offer-plan][data-featured] { order: -1; }
[data-offer-plan] [data-part="price"] s { opacity: 0.5; }
[data-offer-intervals] button[data-selected] { background: var(--accent); }
[data-offer-bump][data-selected] { outline: 1px solid var(--accent); }
[data-offer-summary] [data-part="total"] { font-weight: 600; }
[data-offer-checkout][data-phase="confirming"] { opacity: 0.6; }
[data-offer-checkout] [data-error] { color: var(--danger); }| Attribute | On |
|---|---|
| data-selected | The selected interval button, plan and bump. |
| data-featured | Plans marked featured in the catalog. |
| data-offer-plan | Each plan, set to its id. |
| data-offer-bump | Each bump, set to its id. |
| data-part | Inner parts: name, price, renewal, description, features, label, total. |
| data-phase | Offer.Checkout's wrapper: the checkout phase. |
| data-offer-status | Offer.Status, set to the reason. |
| data-error | Error text inside Offer.Checkout. |
useOffer()
Everything the provider knows, for a fully custom checkout. Call it inside the checkout OfferProvider.
| Field | Type | Description |
|---|---|---|
| offer | PublicOffer | null | The resolved offer. resolved_from says whether it is the requested offer, the fallback or regular prices. |
| isLoading, error | boolean, Error | null | State of the browser fetch. Always settled with initialOffer. |
| selection | { plan, interval, bumps } | The current selection. |
| summary | Summary | null | plan, price, bumps, totalToday, renewal and ready (a plan with a price above zero). |
| bumps | OfferBump[] | Bumps that apply to the selection. |
| selectPlan(planId) | (planId: string) => void | Selects a plan. The provider keeps the selection valid. |
| setInterval(interval) | (interval: Interval) => void | Switches the interval, and the plan if it isn't sold for it. |
| toggleBump(bumpId, on?) | (bumpId: string, on?: boolean) => void | Adds or removes a bump. |
| account | string | null | The account passed to the provider. |
| email, setEmail | string, (email: string) => void | The email field's value, when there is no account. |
| phase | CheckoutPhase | idle, starting, approving, confirming, completed or failed. |
| checkout, checkoutError | Checkout | null, string | null | The checkout record and the last error message. |
| startCheckout() | () => Promise<Checkout> | Creates the checkout on the API. Offer.Checkout calls it for you. |
| confirm() | () => Promise<Checkout | null> | After approval: completes the checkout, retrying while PayPal activates. |
| redirectToPaypal() | () => Promise<void> | Starts the checkout and sends the buyer to PayPal's page instead of the popup. |
| cancel(), fail(err) | functions | Back to idle, or into the failed phase with a message. |
| client | CheckoutClient | The underlying client. |
Paying on PayPal's page
redirectToPaypal() is for flows where a popup doesn't fit. It creates the checkout with a return URL of the current page plus ?offer_checkout=return, remembers the checkout id in sessionStorage and leaves for PayPal. When the buyer comes back, the provider removes PayPal's query parameters from the URL and confirms the checkout. A cancelled approval comes back with ?offer_checkout=cancel and nothing happens.
"use client";
import { useOffer } from "@offer/sdk/checkout";
// Sends the buyer to PayPal's own page instead of the popup buttons.
export function PayOnPaypal() {
const { summary, phase, checkoutError, redirectToPaypal } = useOffer();
return (
<>
<button type="button" disabled={!summary?.ready || phase === "starting"} onClick={() => void redirectToPaypal().catch(() => {})}>
Continue to PayPal
</button>
{checkoutError ? <p role="alert">{checkoutError}</p> : null}
</>
);
}What the browser sends
The browser sends ids, never prices. The API looks up the offer, checks it can still be bought by this buyer, and prices the plan, interval and bumps itself. A request for an offer that can't be bought returns 409 with a reason.
{
"plan": "pro",
"interval": "month",
"bumps": ["support"],
"account": "user_42",
"ref": "newsletter",
"return_url": "https://blog.example.com/pricing?offer_checkout=return",
"cancel_url": "https://blog.example.com/pricing?offer_checkout=cancel"
}{
"id": "chk_Q2wErTyUiOpAsDfG",
"app_id": "app_AbCdEf",
"offer_id": "launch_50",
"plan_id": "pro",
"interval": "month",
"bumps": ["support"],
"account_id": "user_42",
"email": null,
"ref": "newsletter",
"status": "created",
"currency": "USD",
"total_today": 25,
"quote": {
"offer_id": "launch_50",
"offer_name": "Launch week",
"plan_id": "pro",
"plan_name": "Pro",
"interval": "month",
"currency": "USD",
"price": 10,
"list_price": 20,
"cycles": 3,
"bumps": [{ "id": "support", "label": "Priority support", "amount": 15, "grant": { "addons": ["priority_support"] } }],
"setup_fee": 15,
"total_today": 25,
"renews_at_price": 20,
"extras": { "entitlements": [], "addons": [], "ends": "subscription" }
},
"paypal": { "kind": "subscription", "id": "I-BW452GLLEP1G" },
"approve_url": "https://www.sandbox.paypal.com/webapps/billing/subscriptions?ba_token=…",
"created_at": "2026-10-01T12:00:00.000Z",
"completed_at": null
}Every checkout keeps the quote it was given. Editing the offer later changes future purchases only, and buyers keep the price and extras they bought. approve_url is PayPal's approval page, used by redirectToPaypal() and by links the API creates for 402 responses.
To sell one plan at its regular price without an offer, call POST /apps/:appId/plans/:planId/checkout. It takes the same body without plan and bumps. On the page, leave out offerId and pass plan.
Share links and ?ref=
An offer's link is your checkout page with its id in the query string. Add ref to tell partners and campaigns apart:
https://blog.example.com/pricing?offer=launch_50&ref=newsletterPass ref to the provider as refCode and to getOffer() as ref. The API counts the page view against the offer, saves the ref on the checkout, includes it in the checkout.completed webhook and on the account's subscription, and breaks down GET /apps/:appId/offers/:offerId/stats by ref. Refs are at most 100 characters.
The dashboard builds these links for you on the offer page, from the app's checkout_url. Until you set one, its links point at the dashboard's own /demo/checkout page. See Offers.
Previewing a draft
Drafts can't be bought, and publishing needs PayPal. To see a draft on your real page, send its fields to POST /apps/:appId/offers/draft-preview with the secret key from your server, and pass the result as initialOffer. Nothing is stored.
Helpers
The entry point also exports the pure functions the components use, for building your own UI: formatMoney, priceLabel, renewalLabel, savings, summarize, plansFor, availableBumps, normalize, initialSelection, planPrice and INTERVAL_LABELS. The CheckoutClient class wraps the four checkout routes: getOffer, startCheckout, completeCheckout and getCheckout.