# The SDK

The `@orbitcommerce/sdk` package wraps the Orbit Commerce APIs — OAuth-scoped REST, GraphQL, billing, and the dashboard `postMessage` bridge — behind one typed `OrbitClient`.

The SDK is versioned independently of the API. A new SDK release does not imply a new API version, and the REST surface it calls remains `/v1/*`. See [Authentication](/guides/authentication) for how tokens are issued and [the API reference](/api-reference) for the full endpoint and schema catalog.

## Install

```bash
npm install @orbitcommerce/sdk
```

```ts
import { OrbitClient } from '@orbitcommerce/sdk';
```

## Initialisation

There are two ways to construct a client, depending on where your code runs.

### Iframe (frontend)

Inside the store dashboard your plugin renders in an `<iframe>`. The dashboard delivers the session token and `storeId` over `postMessage` — never in a URL. Construct the client with no arguments and wait for `ready()`.

```ts
const orbit = new OrbitClient();
await orbit.ready(); // default timeout 10000ms

const storeId = orbit.getStoreId();
```

`ready(timeoutMs = 10000)` resolves once the token and `storeId` have arrived from the dashboard. Until it resolves the client is not configured, so do not issue calls before awaiting it.

### Server / background

For backend jobs, webhook handlers, and cron work there is no dashboard to talk to. Use the access token and refresh token you persisted when the merchant installed the plugin.

```ts
const orbit = OrbitClient.fromRefreshToken({
  token: install.accessToken,
  refreshToken: install.refreshToken,
  storeId: install.storeId,
  onTokenRefreshed: async ({ accessToken, refreshToken }) => {
    await saveInstallTokens(install.storeId, { accessToken, refreshToken });
  },
});
```

`fromRefreshToken` auto-refreshes the access token before it expires and calls `onTokenRefreshed` with the rotated pair so you can persist it. Refresh rotates both tokens — the old refresh token is invalidated, so you must store the new one.

If you already hold a valid access token and do not need auto-refresh, you can construct directly:

```ts
const orbit = new OrbitClient({ token: install.accessToken, storeId: install.storeId });
```

> `storeId` is dynamic per merchant install — never a global constant. A plugin is installed on many stores, and each carries its own context. Load the correct `{ storeId, accessToken, refreshToken }` record per store before constructing a server-side client.

### apiUrl resolution

`apiUrl` accepts either a base URL or a full `/graphql` URL; it is normalised automatically. The default is `https://api.myorbitcommerce.net/graphql`. You rarely need to set it explicitly — resolution differs by environment.

| Environment | Resolution order |
|---|---|
| Server | `ORBIT_API_URL` → `NEXT_PUBLIC_ORBIT_API_URL` → default |
| Client | `?apiUrl=` query param → `NEXT_PUBLIC_ORBIT_API_URL` → `postMessage` → default |

```bash
# server-side override
ORBIT_API_URL=https://api.myorbitcommerce.net/graphql

# exposed to the browser bundle
NEXT_PUBLIC_ORBIT_API_URL=https://api.myorbitcommerce.net/graphql
```

## REST resource clients

The client exposes typed resource helpers. Every request sends `Authorization: Bearer <token>`; calls require the matching scope (see [Scopes](/guides/scopes)). On `/v1` endpoints the store is derived from the token — the `x-store-id` header the SDK also attaches is ignored there (it matters only for [GraphQL](#graphql)).

### Products

```ts
// List a page of products
const { items } = await orbit.products.list({ limit: 50 });

// Create a product
const created = await orbit.products.create({
  // product fields — see the API reference
});

// Paginate the full catalog for an initial sync
let page = 1;
for (;;) {
  const batch = await orbit.products.listForSync({ page, limit: 100 });
  if (batch.items.length === 0) break;
  await indexProducts(batch.items);
  page += 1;
}
```

`listForSync` is the paginated cursor you want for backfilling an external system; `list` is the general-purpose query.

### Orders

```ts
const order = await orbit.orders.create({
  email: 'buyer@example.com',
  currencyId: 'GBP', // ISO 4217 code enabled on the store, or the currency UUID
  shippingAmount: 0, // required — use 0 for free shipping
  externalId: 'mp-1029',
  source: 'my-plugin',
  items: [{ name: 'Widget', sku: 'WIDGET-2', quantity: 2, price: 1999 }],
});
```

`currencyId` accepts the Orbit currency UUID or an ISO 4217 code — codes are resolved against the currencies **enabled on the store**, and an un-enabled code fails with a 400 naming the currency. Line-item `sku` is persisted: on catalog-matched lines a provided sku wins over the catalog snapshot, and on custom lines it is the only sku the order item can carry.

### Settings

`settings` reads and writes the values defined by your manifest's `settingsSchema`.

```ts
// Full settings object plus the declared schema
const { settings, schema } = await orbit.settings.get();

// Read a single value
const apiMode = await orbit.settings.getValue('api_mode');

// Persist changes
await orbit.settings.update({ api_mode: 'live', sync_interval: 15 });
```

The `billing` resource is documented in [its own section](#billing) below.

## GraphQL

For data not covered by a resource client, call GraphQL directly. Both methods POST to `{base}/graphql` with the same `Authorization` and `x-store-id` headers.

```ts
const data = await orbit.query<{ product: { id: string; title: string } }>(
  `query Product($id: ID!) { product(id: $id) { id title } }`,
  { id: productId },
);

await orbit.mutate(
  `mutation Update($id: ID!, $input: ProductInput!) {
     updateProduct(id: $id, input: $input) { id }
   }`,
  { id: productId, input },
);
```

On HTTP `401` the SDK refreshes the token once and retries the request automatically. If the GraphQL response contains errors, the SDK throws `Error(result.errors[0].message)`. Field names and input types live in [the API reference](/api-reference).

## Billing

The billing module lets a plugin offer paid plans and read the merchant's subscription state.

```ts
const plans = await orbit.billing.getPlans();
const status = await orbit.billing.getSubscriptionStatus();

if (!status.hasSubscription) {
  // Opens the dashboard payment modal (iframe context)
  await orbit.billing.requestPurchase({
    planId: plans[0].id,
    priceId: plans[0].prices?.[0].id
  });
}
```

| Method | Purpose |
|---|---|
| `getPlans()` | List the plans you have configured. |
| `getPaymentMethods()` | The merchant's available payment methods. |
| `getSubscriptionStatus()` | Current subscription state for this store. |
| `hasActiveSubscription()` | Shorthand boolean for the above. |
| `requestPurchase({ planId, priceId? })` | Open the dashboard payment modal for the merchant to pay. |
| `subscribeToPlan({ planId, priceId?, paymentMethodId })` | Subscribe using a known payment method. |
| `changePlan({ planId, priceId? })` | Move an existing subscription to another plan. |
| `cancelSubscription({ reason? })` | Cancel the active subscription. |
| `restoreSubscription()` | Restore a previously cancelled subscription. |

In an iframe, prefer `requestPurchase` — it hands the payment flow to the dashboard. Use `subscribeToPlan` only when you already have a `paymentMethodId`.

`getSubscriptionStatus()` returns `{ hasSubscription, subscription? }`, and the detail — `status`, `planName`, `currentPeriodEnd`, `cancelAtPeriodEnd` — is nested under `subscription`. A `status` of `trialing` is still an entitlement: the merchant agreed to the plan and expects the features.

**Where plans come from.** The `pricing` block in `plugin.json` is a display summary for the store listing; it does not create anything sellable. Plans and their prices are configured on your plugin's detail page in the partner dashboard, and that is what `getPlans()` returns. A manifest advertising a subscription with no plans configured shows a price and sells nothing.

**Check entitlement on your server too.** Requires the `billing:read` scope — without it these calls return `401`, and code that treats the error as "not subscribed" will show a free tier to paying customers. Treat an error as unknown rather than as a negative answer: an outage must not silently downgrade someone. The page should check as well, so the UI is right, but the page runs on the merchant's machine and cannot be what decides whether they paid.

## Iframe UI helpers

These post to the parent dashboard, so they only work in the iframe context after `ready()`.

```ts
orbit.toast({ message: 'Sync complete', type: 'success' });
orbit.toast('Saved'); // string shorthand

orbit.navigate('/products');          // navigate the dashboard
orbit.setRoute('/plugin/settings');   // sync your internal route into the URL
const initial = orbit.getInitialRoute(); // string | null — restore deep link on load

orbit.resize({ height: 720 });        // fit the iframe to your content

orbit.openModal({ title: 'Connect account', content: '<div>…</div>', width: 480 });
```

`toast` takes either a string or `{ message, type?: 'success' | 'error' | 'info' | 'warning', duration? }`.

When your plugin is mounted as a focused action, use the action lifecycle helpers:

```ts
const ctx = orbit.getResourceContext(); // { resourceType, resourceId, storeId } | null
if (ctx?.resourceType === 'product') {
  // act on ctx.resourceId
}

orbit.completeAction({ ok: true }); // finish and return a result to the dashboard
orbit.closeAction();                // dismiss without a result
```

## Context getters

Read the current token and store context synchronously after the client is ready.

```ts
orbit.getStoreId();        // string
orbit.getToken();          // current access/session token (a JWT)
orbit.getApiUrl();         // resolved GraphQL URL
orbit.getBaseUrl();        // resolved base URL
orbit.isConfigured();      // token + storeId present
orbit.getTokenExpiresAt(); // expiry timestamp (or null)
orbit.isTokenExpiring();   // true when close to expiry
```

The granted **scopes** and the **plugin id** are not exposed as getters — they live in the token's claims. Read them from a verified token server-side, or by decoding `orbit.getToken()` in the iframe:

```ts
// Server-side (recommended): authoritative + checks revocation
const { scopes, pluginId } = await OrbitClient.verifyToken(token);

// In the iframe: decode the session JWT payload
const [, payload] = orbit.getToken().split('.');
const claims = JSON.parse(atob(payload)) as { scopes: string[]; pluginId: string };
if (claims.scopes.includes('order:read')) {
  await loadOrders();
}
```

See [Scopes](/guides/scopes) for the convention and [the Scope Catalog](/scope-catalog) for the full list.

## Static auth helpers

These run server-side and do not require a constructed client. Use them when validating an incoming token or completing an OAuth exchange. See [Authentication](/guides/authentication) for the full flow.

```ts
// Validate a token (POSTs /oauth/verify-token; result cached ~5 min)
const claims = await OrbitClient.verifyToken(token);
// -> { storeId, pluginId, scopes, type }

// Exchange a short-lived session token for an access + refresh pair
const { accessToken, refreshToken } = await OrbitClient.exchangeToken(sessionToken);

// Refresh an access token (rotates the pair — old refresh token is invalidated)
const rotated = await OrbitClient.refreshToken(refreshToken);
```

Always validate tokens server-side for sensitive operations, and verify the returned `storeId` matches the install you are acting on.

## Error handling

The SDK throws on failure rather than returning error objects:

- REST and GraphQL calls throw `Error` on any non-2xx HTTP response.
- GraphQL responses containing an `errors` array throw `Error(result.errors[0].message)`.
- A `401` is handled transparently first — the SDK refreshes the token once and retries; only a persistent failure surfaces.

Wrap calls accordingly:

```ts
try {
  await orbit.orders.create(payload);
} catch (err) {
  orbit.toast({ message: (err as Error).message, type: 'error' });
}
```

A `401` after retry means either the token could not be refreshed (re-run the OAuth flow) or it is valid but lacks a required scope — the error body reads `Missing required scopes: …`, so request it in your manifest and have the merchant reinstall or re-consent. (A `403` is specific to subscribing to a webhook topic without the topic's scope.)

## Cleanup

In the iframe the client installs a `postMessage` listener. Remove it when your component unmounts to avoid leaks and duplicate handlers.

```tsx
useEffect(() => {
  const orbit = new OrbitClient();
  orbit.ready().then(() => {/* … */});
  return () => orbit.destroy();
}, []);
```

`destroy()` is only relevant for the iframe context; server-side clients hold no listener.

## Related

- [Authentication](/guides/authentication) — token types, OAuth exchange, and refresh.
- [Scopes](/guides/scopes) and the [Scope Catalog](/scope-catalog) — what each call requires.
- [Webhooks](/guides/webhooks) — receive events instead of polling.
- [API reference](/api-reference) — endpoint paths, GraphQL schema, and field-level detail.
