Checkout & payments

One-click checkout with @hyperyai/sdk — BuyButton, SubscribeButton and useCheckout run login → charge → add-card → retry as a popup or redirect, for marketplace purchases, subscriptions and AI-credit top-ups.

@hyperyai/sdk ships a seamless auth + charge flow: a single action drives the whole chain — press buy → log in (if needed) → charge → add a card (if needed) → retry → done. An unauthenticated visitor can click Buy and be walked through sign-in, card entry, and payment without you writing any of that glue.

It comes in these forms:

  • <BuyButton> — a drop-in "buy with Hypery" button for one-click marketplace purchases.
  • <SubscribeButton> — a drop-in button that subscribes the user to one of your app's subscription plans, monthly or annual.
  • useAppSubscription() — plans, the user's subscription, remaining credit, and subscribe / cancel / resume / switch actions.
  • useCheckout() — the hook underneath, for programmatic checkout (purchases, subscriptions and AI-credit top-ups) on your own UI.
  • useMarketplace() — a single marketplace charge with no login or card-entry steps, for when you handle those yourself.
  • planPriceCents() — read a plan's price for an interval.
press buy
user action
log in
if signed out
charge
card on file
add card
if no card (402)
retry → done
success

Each interactive step (login, card entry) runs as a popup or a redirect, chosen by interactionMode — so the flow completes in-page on desktop and stays robust on mobile.

Setup

Checkout reads everything it needs from HyperyProvider — no extra provider. The only new option is interactionMode:

// app/layout.tsx
import { HyperyProvider } from "@hyperyai/sdk";
 
export default function RootLayout({ children }) {
  return (
    <HyperyProvider
      config={{
        clientId: process.env.NEXT_PUBLIC_HYPERY_CLIENT_ID!,
        redirectUri: "https://yourapp.com/callback",
        gatewayUrl: "https://hypery.ai",
        interactionMode: "auto", // 'auto' | 'popup' | 'redirect'
      }}
    >
      {children}
    </HyperyProvider>
  );
}

interactionMode defaults to auto: popup on desktop, redirect on mobile or when a popup is blocked. For popup mode, redirectUri must be same-origin as your app (the popup posts the result back to the opener).

BuyButton

Drop-in checkout for a marketplace purchase. On click it runs the full flow via useCheckout and credits the sale to the seller's app (a Stripe Connect direct charge; your platform fee is applied automatically).

import { BuyButton } from "@hyperyai/sdk";
 
<BuyButton
  appId="app_123"
  amountCents={499}
  description="Pro upgrade"
  onSuccess={(r) => console.log("paid", r.paymentIntentId)}
  onError={(e) => toast.error(e.message)}
/>;

By default the button is two-click — the first click arms it (Confirm · $4.99 + Cancel), the second charges — because the card is charged off-session and the buyer should give a clear "yes". Set requireConfirmation={false} to charge on a single click.

PropTypeDescription
appIdstringSeller app the purchase is credited to. Required.
amountCentsnumberAmount to charge, in cents. Required.
descriptionstringHuman-readable purchase description.
labelReactNodeButton label. Defaults to Buy · $X.XX.
requireConfirmationbooleanTwo-click confirm (default true) vs one-click.
onSuccess(r: BuySuccess) => voidFires on a completed charge.
onError(e: ParsedError) => voidFires on failures other than needs-a-card. ALREADY_CHARGED (409) means this is a repeat of a purchase whose original charge succeeded. The response carries that charge's paymentIntentId: if you haven't fulfilled that payment yet (e.g. the first response was lost), fulfil it once now, keyed on paymentIntentId; if you already have, do nothing. Never fulfil the same paymentIntentId twice.
classNamestringExtra classes on the button.
brandingBrandingConfigprimaryColor is used as the button color.

onSuccess receives { ok, paymentIntentId, status, amountCents, applicationFeeCents }. The button never throws — a redirecting (navigating away) or cancelled (user closed a popup) result stays quiet, so no callback fires.

SubscribeButton

Subscribes the user to one of your app's plans. Like BuyButton, it logs the user in if needed, uses the card they already have on Hypery, asks for a card only if there is none, and asks for a confirming second click first.

import { SubscribeButton } from "@hyperyai/sdk";
 
<SubscribeButton
  planId="66f…"
  interval="year"          // "month" (default) or "year" — the plan must offer it
  priceCents={10000}       // display only; Hypery charges the plan's price
  onSuccess={(r) => toast.success(r.alreadySubscribed ? "already subscribed" : "subscribed")}
  onError={(e) => toast.error(e.message)}
/>
PropTypeDescription
planIdstringRequired. From useAppSubscription().plans or GET /api/marketplace/plans.
interval'month' | 'year'Billing interval. Defaults to monthly. Annual subscribers still receive grants every month.
priceCentsnumberPrice shown on the button (display only).
labelReactNodeButton label. Defaults to "Subscribe" (+ price).
requireConfirmationbooleanRequire a second click. Defaults to true.
onSuccess(data) => voidReceives { subscription, alreadySubscribed }.
onError(error, result) => voiderror.code === 'PAYMENT_INCOMPLETE' means 3-D Secure: confirm result.data.error.clientSecret with Stripe.js using stripeAccount.
className, brandingStyling, as on BuyButton.

The button never throws; redirecting and cancelled results fire no callback.

useAppSubscription

Everything a pricing or account page needs for your app's plans.

import { useAppSubscription, planPriceCents } from "@hyperyai/sdk";
 
function Plans({ appId }: { appId: string }) {
  const {
    plans, isSubscribed, activeSubscription, interval, pendingInterval,
    nextGrantAt, remainingCreditUsd, subscribe, cancel, resume, switchInterval,
  } = useAppSubscription(appId);
 
  if (!isSubscribed) {
    return plans.map((p) => (
      <button key={p.id} onClick={() => subscribe(p.id, { interval: "year" })}>
        {p.name} · ${(planPriceCents(p, "year") ?? 0) / 100}/yr
      </button>
    ));
  }
 
  return (
    <div>
      <p>${remainingCreditUsd.toFixed(2)} credit left · next credit {nextGrantAt}</p>
      {interval === "month" && <button onClick={() => switchInterval(undefined, "year")}>switch to yearly</button>}
      {interval === "year" && !pendingInterval && (
        <button onClick={() => switchInterval(undefined, "month")}>switch to monthly at renewal</button>
      )}
      {activeSubscription?.cancelAtPeriodEnd
        ? <button onClick={() => resume()}>resume</button>
        : <button onClick={() => cancel()}>cancel</button>}
    </div>
  );
}
FieldDescription
plansActive plans for the app. Each has prices: [{ interval, priceCents }]; planPriceCents(plan, interval) reads it (falls back to the legacy monthly priceCents).
subscriptions, activeSubscriptionAll of the user's subscriptions to the app, and the live one (active / trialing / past_due).
isSubscribedTrue while a live subscription exists.
remainingCreditUsdUnexpired grant value left on the live subscription.
interval, pendingIntervalHow the live subscription is billed, and a scheduled switch (annual → monthly at period end).
nextGrantAtWhen the next monthly grant lands.
subscribe(planId, { interval?, idempotencyKey? })Runs the checkout flow; refreshes on success.
cancel(id?), resume(id?)Stop renewal at period end / undo it.
switchInterval(id | undefined, interval)Month → year is charged now (may fail with PAYMENT_DECLINED); year → month applies at period end. Passing the current interval while a switch is pending cancels it. Returns { success, changed, effective, at, subscription, error }.
isLoading, error, refresh()Load state.

useCheckout

The hook behind BuyButton and SubscribeButton. Use it directly when you want checkout on your own button, or for AI-credit top-ups (which BuyButton doesn't cover).

import { useCheckout } from "@hyperyai/sdk";
 
function AddCredits() {
  const { checkout, status, isRunning } = useCheckout();
 
  const buy = async () => {
    const result = await checkout({ kind: "topup", usdAmount: 5 });
    if (result.status === "success") {
      // balance is funded
    }
  };
 
  return (
    <button onClick={buy} disabled={isRunning}>
      {status === "charging" ? "charging…" : "add $5 of credits"}
    </button>
  );
}

useCheckout() returns:

FieldTypeDescription
checkout(input) => Promise<CheckoutResult>Runs the flow; resolves when it finishes (or is redirecting away).
statusCheckoutStatusidle · authenticating · charging · adding-card · redirecting · success · error · cancelled.
isRunningbooleanTrue while a checkout is in flight — bind to your button's disabled.
errorParsedError | nullThe last classified error, if any.

checkout(input) takes one of three shapes:

// marketplace purchase (Stripe Connect)
checkout({ kind: "purchase", appId: "app_123", amountCents: 499, description: "Pro" });
 
// buy AI credits for the signed-in user's balance
checkout({ kind: "topup", usdAmount: 5 });
 
// subscribe to one of your app's plans (monthly by default)
checkout({ kind: "subscription", planId: "66f…", interval: "year" });
KindFields
purchaseappId (required), amountCents (required), description?, idempotencyKey? — POST /api/marketplace/checkout
topupusdAmount (required) — POST /api/wallet/topup
subscriptionplanId (required), interval?: 'month' | 'year', idempotencyKey? — POST /api/marketplace/subscribe

Purchases and subscriptions are idempotent: a stable idempotencyKey is generated (or pass your own) so a resumed or retried charge is never double-billed.

interactionMode controls how the login and add-card steps appear.

Each step opens a centered popup and the chain completes in-page — no navigation (like PayPal / Firebase signInWithPopup). Best on desktop. Requires a same-origin redirectUri.

In redirect mode the flow can navigate away mid-checkout and resume on return — so treat checkout() returning redirecting as "in progress", not "done". Resume runs automatically once per page load.

Handling the result

checkout() resolves to a CheckoutResult:

const result = await checkout({ kind: "topup", usdAmount: 5 });
 
switch (result.status) {
  case "success":     /* result.data has the charge */ break;
  case "error":       /* result.error is a ParsedError */ break;
  case "cancelled":   /* user closed a popup — no-op */ break;
  case "redirecting": /* navigating away; resumes on return */ break;
}

Errors are already classified (ParsedError), so a payment decline or a spending-limit hit can drive error UI without extra parsing. On an error result, data carries the raw response body (e.g. a subscription's 3-D Secure clientSecret).

A few details of the flow:

  • A card is requested only for a 402 that a card can fix — not for PAYMENT_DECLINED or PAYMENT_INCOMPLETE, which come back as errors.
  • The charge is retried once after a card is added; still failing returns a PAYMENT_METHOD_REQUIRED error.
  • Calling checkout() while one is already running returns { status: 'error' } with no error.
  • A redirect-mode resume gives up after 3 attempts.

useMarketplace

A single marketplace charge (POST /api/marketplace/checkout) through authenticatedFetch. Unlike useCheckout it doesn't log the user in or ask for a card — it tells you when either is needed. It never throws for billing errors.

import { useMarketplace, useBuyerWallet } from "@hyperyai/sdk";
 
function Buy({ orderId }: { orderId: string }) {
  const { buy, isBuying } = useMarketplace();
  const { addCard } = useBuyerWallet();
 
  const onClick = async () => {
    const r = await buy({ appId: "app_123", amountCents: 499, description: "Pro", idempotencyKey: orderId });
    if (r.ok) return toast.success(`paid ${r.paymentIntentId}`);
    if (r.needsPaymentMethod) return addCard();
    toast.error(r.error.message);
  };
 
  return <button onClick={onClick} disabled={isBuying}>Buy</button>;
}

buy(input: BuyInput):

FieldTypeDescription
appIdstringRequired. Seller app the purchase is credited to.
amountCentsnumberRequired. Amount in cents.
descriptionstringPurchase description.
paymentMethodIdstringA specific saved card (pm_…); defaults to the buyer's default card.
idempotencyKeystringPass a stable value per order so a user retry can't charge twice. A fresh key is generated per call when omitted.

Returns:

FieldTypeDescription
buy(input: BuyInput) => Promise<BuyResult>Resolves { ok: true, paymentIntentId, status, amountCents, applicationFeeCents } or { ok: false, error, needsPaymentMethod, needsAuth }.
isBuyingbooleanA charge is in flight.
lastErrorParsedError | nullError from the last buy.

Marketplace charges require the seller app to have finished Stripe Connect onboarding. See Marketplace.

planPriceCents

planPriceCents(plan, interval = 'month'): number | null returns a plan's price in cents for an interval. It reads plan.prices when present and otherwise falls back to the legacy priceCents / interval fields. Returns null when the plan doesn't offer that interval.

import { planPriceCents } from "@hyperyai/sdk";
 
const yearly = planPriceCents(plan, "year");
if (yearly !== null) label = `$${(yearly / 100).toFixed(2)}/yr`;

Next steps