Checkout widget
Embed the <OrbitCheckout /> React component and run the subscription handshake from your own app.
<OrbitCheckout /> is a drop-in React component. It loads a plan, connects Freighter and records the subscription with POST /subscriptions.
The widget does not submit the onchain handshake yet. Pair it with the signing code below so the subscription is backed by a real vault.
Install
The widget lives in the monorepo under packages/checkout-widget. It is not on npm yet.
git clone https://github.com/Orbit-xyz/Orbit
cd Orbit/packages/checkout-widget
npm install
npm run dev # preview at http://localhost:5173Copy src/OrbitCheckout.jsx into your app, and install its peer dependencies:
npm install @stellar/freighter-api @stellar/stellar-sdk framer-motionUsage
import OrbitCheckout from "./OrbitCheckout";
export function Subscribe() {
return (
<OrbitCheckout
planId="7c1e2d4a-0f6b-4a8e-9d51-3b2c9a1f6e20"
apiUrl="https://your-orbit-api.example.com"
/>
);
}Props
The plan's UUID from the dashboard or POST /plans. The widget fetches GET {apiUrl}/plans/{planId}.
Pass a plan object you already have to skip the fetch. Shape: { id, name, usdc_amount, interval_seconds, merchants: { name } }.
Base URL of your Merchant API.
States
The widget is always in exactly one of these states.
| State | When | What the user sees |
|---|---|---|
| Loading | The widget is fetching GET {apiUrl}/plans/{planId} | "Loading Orbit checkout..." |
| Error | The plan request failed, or no planId was passed | "Unable to load plan", a short message and a Try again button |
| Ready | The plan loaded and no wallet is connected | Plan name, merchant, price, interval and a Connect Freighter Wallet button |
| Connected | Freighter returned an address | The shortened address and a Subscribe & Approve button |
| Success | POST /subscriptions returned 201 | "Payment Approved" and "You are now subscribed to plan name." |
Loading
Shown on mount and again after every retry. It is skipped when you pass planData, because there is nothing to fetch.
Error and retry
The widget enters the error state when the plan request cannot be completed:
- the network request fails (API down, wrong host, blocked by CORS), or
- the API answers with any non-2xx status (unknown or malformed
planId, server error).
The user sees the heading "Unable to load plan" with the message "Unable to load plan. Please check your connection or try again." and a Try again button. The button sends the same request again and puts the widget back in the loading state. There is no automatic retry and no limit on manual retries.
The widget never shows placeholder plan data. If the plan cannot be loaded, no price is displayed and the user cannot subscribe.
If neither planId nor planData is passed, the message is "No planId provided." instead. Retrying does not help in that case, so fix the props.
Ready
Wallet problems are shown inline under the connect button and do not leave this state:
- Freighter not installed: "Install Freighter to continue." with a Get Freighter link.
- Request rejected or extension locked: the message Freighter returned, or "Could not reach Freighter. Is the extension unlocked?"
Connected
While the request is in flight the button reads "Approving..." and is disabled. If POST /subscriptions does not return 201, the API's error message is shown above the button and the user can press it again.
Success
Terminal state. The widget does not reset on its own. Remount it to start a new checkout.
Troubleshooting the error state
Open the browser console first. The widget logs Could not load plan from API: followed by the underlying error.
- Wrong
apiUrl. The default ishttp://localhost:3001, which only works on your own machine. Pass your deployed Merchant API URL with no trailing slash, and usehttpsif your page is served overhttps. - CORS. The browser blocks the request if the API does not allow your page's origin. Check the network tab for a failed request with no status code. The reference backend allows every origin, so this usually means a proxy or a custom deployment is stripping the headers.
- Unknown
planId. The id must be the plan's UUID exactly as returned byPOST /plans. Test it directly withcurl {apiUrl}/plans/{planId}. A400means it is not a valid UUID, and a404or500means no plan has that id. - API not running or misconfigured. A
500withSupabase client not initializedmeans the backend is missingSUPABASE_URLorSUPABASE_SERVICE_ROLE_KEY.
Signing the handshake yourself
For full control, build the two subscriber transactions with @stellar/stellar-sdk and sign them with Freighter:
import { rpc, Contract, TransactionBuilder, nativeToScVal, Address } from "@stellar/stellar-sdk";
import { signTransaction } from "@stellar/freighter-api";
const server = new rpc.Server("https://soroban-testnet.stellar.org");
const passphrase = "Test SDF Network ; September 2015";
const ORBIT = "CAZBZBUWBSQYK2RZ6WHMXVDLIQHSU5WD7ANZYL6HLNSCRUOTNYCDYQNG";
async function invoke(source: string, contractId: string, fn: string, args: any[]) {
const account = await server.getAccount(source);
const tx = new TransactionBuilder(account, { fee: "100000", networkPassphrase: passphrase })
.addOperation(new Contract(contractId).call(fn, ...args))
.setTimeout(60)
.build();
const prepared = await server.prepareTransaction(tx); // simulate + footprint
const { signedTxXdr } = await signTransaction(prepared.toXDR(), { networkPassphrase: passphrase });
const sent = await server.sendTransaction(TransactionBuilder.fromXDR(signedTxXdr, passphrase));
return server.pollTransaction(sent.hash); // wait for the ledger
}
export async function subscribe(user: string, merchant: string, token: string,
amount: bigint, interval: bigint, cycles: bigint) {
const { sequence } = await server.getLatestLedger();
// 1. allowance on the token
await invoke(user, token, "approve", [
new Address(user).toScVal(),
new Address(ORBIT).toScVal(),
nativeToScVal(amount * cycles, { type: "i128" }),
nativeToScVal(sequence + 500_000, { type: "u32" }),
]);
// 2. vault terms on Orbit
await invoke(user, ORBIT, "create_vault", [
new Address(user).toScVal(),
new Address(merchant).toScVal(),
new Address(token).toScVal(),
nativeToScVal(amount, { type: "i128" }),
nativeToScVal(interval, { type: "u64" }),
]);
}prepareTransaction simulates first. If the simulation fails (not enough balance, for example), show that error before you ask the user to sign.