OrbitDocs

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:5173

Copy src/OrbitCheckout.jsx into your app, and install its peer dependencies:

npm install @stellar/freighter-api @stellar/stellar-sdk framer-motion

Usage

import OrbitCheckout from "./OrbitCheckout";

export function Subscribe() {
  return (
    <OrbitCheckout
      planId="7c1e2d4a-0f6b-4a8e-9d51-3b2c9a1f6e20"
      apiUrl="https://your-orbit-api.example.com"
    />
  );
}

Props

planIdstringrequired

The plan's UUID from the dashboard or POST /plans. The widget fetches GET {apiUrl}/plans/{planId}.

planDataobject

Pass a plan object you already have to skip the fetch. Shape: { id, name, usdc_amount, interval_seconds, merchants: { name } }.

apiUrlstringdefault: http://localhost:3001

Base URL of your Merchant API.

States

The widget is always in exactly one of these states.

StateWhenWhat the user sees
LoadingThe widget is fetching GET {apiUrl}/plans/{planId}"Loading Orbit checkout..."
ErrorThe plan request failed, or no planId was passed"Unable to load plan", a short message and a Try again button
ReadyThe plan loaded and no wallet is connectedPlan name, merchant, price, interval and a Connect Freighter Wallet button
ConnectedFreighter returned an addressThe shortened address and a Subscribe & Approve button
SuccessPOST /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 is http://localhost:3001, which only works on your own machine. Pass your deployed Merchant API URL with no trailing slash, and use https if your page is served over https.
  • 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 by POST /plans. Test it directly with curl {apiUrl}/plans/{planId}. A 400 means it is not a valid UUID, and a 404 or 500 means no plan has that id.
  • API not running or misconfigured. A 500 with Supabase client not initialized means the backend is missing SUPABASE_URL or SUPABASE_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.

On this page