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.paypal is null, Offer.Checkout renders a “Payments aren't set up” message, and starting a checkout returns 409.
  • Publish the offer. Drafts and archived offers aren't sold. The page falls back to regular prices instead.
  • Set the app's checkout_url to this page, so share links and 402 upgrade links point at it.

How a purchase works

  1. Load the offer

    getOffer() on the server, or the provider in the browser, calls GET /apps/:appId/offers/:offerId/public. It never fails for a bad id: it returns the requested offer if it can be bought, else the fallback offer, else the plans at their regular prices. It also returns the app's PayPal client id.

  2. 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.

  3. The API prices it

    Clicking the PayPal button calls POST /apps/:appId/offers/:offerId/checkout with 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.

  4. 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.

  5. Confirmed

    While PayPal is still activating a subscription, /complete returns status: "created". The provider retries every 1.5 seconds, up to 20 times, then calls onSuccess with 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
// 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
// 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

PropTypeDescription
apiUrlstringThe Offer API's public URL.
appIdstringThe app to sell for.
publishableKeystringThe app's pub_… key. Safe in the browser.
offerIdstring | nullThe offer to sell, usually from ?offer=. Without it, the plans sell at regular prices.
fallbackstring | nullThe offer to show when offerId can't be bought.
initialOfferPublicOfferThe result of getOffer() on the server. Skips the browser fetch.
accountstring | nullThe signed-in buyer's account id. Without it, the buyer enters an email.
emailstring | nullPrefills the email field.
refCodestring | nullWho sent the buyer, usually from ?ref=. Saved on the checkout.
planstring | nullPreselects a plan.
interval"month" | "year" | "once" | nullPreselects a billing interval.
onSuccess(checkout: Checkout) => voidRuns once the API has applied the purchase.
fetchtypeof fetchOptional custom fetch.
Pass the account when you know it. Targeted offers are sold to one account only, and the API checks 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.

ComponentPropsRenders
Offer.Statuschildren?: (reason, ctx) => ReactNodeWhy the requested offer isn't shown (expired, sold out…). Nothing otherwise.
Offer.Headlinechildren?: (ctx) => ReactNodeThe offer's headline, subhead and bullets, or its name.
Offer.IntervalTogglelabels?: Partial<Record<Interval, ReactNode>>A radio group of intervals. Hidden when the offer has only one.
Offer.Planschildren: (plan) => ReactNodeOne child per plan sold for the selected interval.
Offer.Planplan, children?: (props: PlanRenderProps) => ReactNodeA radio button with name, price, renewal terms, description and features.
Offer.Bumpschildren: (bump) => ReactNodeOne child per bump that applies to the selection. Nothing when none apply.
Offer.Bumpbump, children?: (props: BumpRenderProps) => ReactNodeA checkbox with label, price and description.
Offer.Summarychildren?: (ctx) => ReactNodeLine items, the total due today and the renewal terms.
Offer.Emaillabel?, placeholder?An email field. Renders nothing when the provider has an account.
Offer.CheckoutonSuccess?, style?, children?: (ctx) => ReactNodePayPal'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.

A custom plan card
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>
PlanRenderPropsTypeDescription
planOfferPlanThe plan, with benefits, extras and prices.
priceOfferPriceamount, list_price, cycles and then for the selected interval.
interval, currencyInterval, stringThe selected interval and the offer's currency.
selected, select()boolean, () => voidWhether it's selected, and how to select it.
priceTextstring"$10/mo"
listPriceTextstring | nullThe regular price when discounted, else null.
renewalTextstring | null"for 3 months, then $20/mo", or null when the price never changes.
savingsnumber | nullPercent off the regular price.
BumpRenderPropsTypeDescription
bumpOfferBumplabel, description, amount and the add-ons it grants.
selectedbooleanWhether it's in the selection.
toggle(on?)(on?: boolean) => voidAdds or removes it.
priceTextstring"+$50"

Styling

The default markup carries its state in data-* attributes, so you can style it from a stylesheet without render props.

checkout.css
/* 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); }
AttributeOn
data-selectedThe selected interval button, plan and bump.
data-featuredPlans marked featured in the catalog.
data-offer-planEach plan, set to its id.
data-offer-bumpEach bump, set to its id.
data-partInner parts: name, price, renewal, description, features, label, total.
data-phaseOffer.Checkout's wrapper: the checkout phase.
data-offer-statusOffer.Status, set to the reason.
data-errorError text inside Offer.Checkout.

useOffer()

Everything the provider knows, for a fully custom checkout. Call it inside the checkout OfferProvider.

FieldTypeDescription
offerPublicOffer | nullThe resolved offer. resolved_from says whether it is the requested offer, the fallback or regular prices.
isLoading, errorboolean, Error | nullState of the browser fetch. Always settled with initialOffer.
selection{ plan, interval, bumps }The current selection.
summarySummary | nullplan, price, bumps, totalToday, renewal and ready (a plan with a price above zero).
bumpsOfferBump[]Bumps that apply to the selection.
selectPlan(planId)(planId: string) => voidSelects a plan. The provider keeps the selection valid.
setInterval(interval)(interval: Interval) => voidSwitches the interval, and the plan if it isn't sold for it.
toggleBump(bumpId, on?)(bumpId: string, on?: boolean) => voidAdds or removes a bump.
accountstring | nullThe account passed to the provider.
email, setEmailstring, (email: string) => voidThe email field's value, when there is no account.
phaseCheckoutPhaseidle, starting, approving, confirming, completed or failed.
checkout, checkoutErrorCheckout | null, string | nullThe 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)functionsBack to idle, or into the failed phase with a message.
clientCheckoutClientThe 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.

components/pay-on-paypal.tsx
"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.

POST /apps/:appId/offers/launch_50/checkout
{
  "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"
}
201 Created
{
  "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.

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=newsletter

Pass 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.