SellOrchard

Apps

Building apps

Apps extend SellOrchard for every merchant who installs them. This guide covers registering as a developer, the OAuth install flow, embedding your app in the admin, webhooks and what we check before listing an app.
On this page

How apps work

  • An app is a web service you host. A merchant installs it from the App Store in their admin (or from your install link) and approves the scopes it asks for.
  • SellOrchard redirects back to your app with a one-time code, which your backend exchanges for an access token (so_app_…) for that store.
  • The token works with the same REST API as API keys, limited to the scopes the merchant granted.
  • Webhook topics you choose are subscribed automatically on install and delivered to your webhook URL.
  • Your app can open in a new tab or be embedded in the admin in an iframe.

Apps can’t charge merchants through SellOrchard yet. The pricing on your listing is informational — bill merchants on your own site.

Become a developer

  1. Sign in to SellOrchard (any merchant account works) and open the developer portal.
  2. Click Become a developer and enter your company name, website and a support email merchants can reach.
  3. You can build and test apps straight away. Our team approves your developer account before your first app can be published.

Create the app

In the developer portal, click New app. The app editor has three tabs:

TabWhat you set
ListingName, tagline (up to 80 characters), description (Markdown), icon, screenshots, category, pricing (free, paid or freemium plus a short text), website, support and privacy policy URLs.
SetupApp URL, whether it is embedded, redirect URLs (OAuth), webhook URL and topics, requested and optional scopes.
CredentialsYour client_id (app_…) and client_secret. The secret is shown once — store it safely. You can roll it at any time; the old one stops working immediately.

When you’re ready, click Submit for review. You’ll see the status (draft, in review, published, rejected) and any review notes in the portal. Apps can also be unlisted: they never appear in the App Store, but anyone with your install link can install them — useful for private or custom apps.

OAuth install flow

SellOrchard uses the OAuth 2.0 authorization-code flow. Your backend needs one HTTPS route — the redirect URL — and your client secret.

1. Install URL & consent

Installs start from the App Store or from your app’s install link:

Install link
https://app.sellorchard.com/install/{slug}
https://app.sellorchard.com/install/{slug}?state={state}&redirect_uri={redirect_uri}

The merchant picks a store (if they have several) and sees a consent screen with your app’s name, developer, the requested scopes in plain language, toggles for your optional scopes, your webhook topics and your privacy policy. Nothing is shared until they click Approve & install.

  • state (optional) — a random value you generate and remember (e.g. in a cookie). It comes back unchanged so you can check the redirect belongs to the install you started. Use letters, digits, - and _ only.
  • redirect_uri (optional) — one of your registered redirect URLs, matched exactly. If omitted, your first registered URL is used.

2. Redirect & hmac

After approval the merchant’s browser is sent to your redirect URL:

Redirect
https://myapp.example.com/sellorchard/callback?code={code}&store={store_id}&state={state}&hmac={hmac}

Before doing anything else, verify the hmac:

  1. Take every query parameter except hmac (including any query string that is part of your registered redirect URL).
  2. Sort them by key.
  3. Build a query string from them: key=value pairs joined by &, with keys and values percent-encoded per RFC 3986 (PHP’s http_build_query(…, PHP_QUERY_RFC3986); in JavaScript, encodeURIComponent plus encoding !'()*).
  4. Compute HMAC-SHA256 of that string with your client_secret as the key, hex-encoded (lowercase), and compare it to hmac in constant time.

Then check state if you sent one. The code is valid for 10 minutes, can be used once, and is bound to your app, the store, the granted scopes and the redirect URL.

3. Token exchange

From your backend (never the browser), exchange the code at POST https://api.sellorchard.com/v1/oauth/token with client_id, client_secret, code and the same redirect_uri:

200 OK
{
  "access_token": "so_app_6f1d0c7a9b2e4f8a1c3d5e7f9a0b2c4d",
  "scope": "read_products write_orders",
  "store": {
    "id": "01JBX4Q2ZK8V7Y5N3M1P0R9S6T",
    "name": "Orchard Coffee Co.",
    "domain": "orchard-coffee.sellorchard.store"
  }
}

The exchange fails with 403 if the store’s plan doesn’t include the Developer API, and with 400 if the code is expired, already used or the redirect_uri doesn’t match. scope lists what the merchant granted — required scopes plus any optional ones they switched on. Check it before using an optional feature.

import crypto from "node:crypto";
import express from "express";

const CLIENT_ID = process.env.SELLORCHARD_CLIENT_ID; // app_…
const CLIENT_SECRET = process.env.SELLORCHARD_CLIENT_SECRET;
const REDIRECT_URI = "https://myapp.example.com/sellorchard/callback"; // registered exactly
const app = express();

/** RFC 3986 percent-encoding (like PHP's rawurlencode). */
const encode = (value) => encodeURIComponent(value).replace(/[!'()*]/g, (c) => "%" + c.charCodeAt(0).toString(16).toUpperCase());

/** hmac = HMAC-SHA256(client_secret, the other query parameters sorted by key, as an RFC 3986 query string). */
function verifyHmac(query, secret) {
  const { hmac, ...params } = query;
  if (typeof hmac !== "string") return false;
  const message = Object.keys(params)
    .filter((key) => typeof params[key] === "string")
    .sort()
    .map((key) => `${encode(key)}=${encode(params[key])}`)
    .join("&");
  const expected = crypto.createHmac("sha256", secret).update(message).digest("hex");
  return hmac.length === expected.length && crypto.timingSafeEqual(Buffer.from(hmac), Buffer.from(expected));
}

// Optional: start the install from your own site with a state tied to this browser.
app.get("/sellorchard/install", (req, res) => {
  const state = crypto.randomBytes(16).toString("hex");
  res.cookie("so_state", state, { httpOnly: true, secure: true, sameSite: "lax", maxAge: 10 * 60 * 1000 });
  const params = new URLSearchParams({ state, redirect_uri: REDIRECT_URI });
  res.redirect(`https://app.sellorchard.com/install/my-app?${params}`);
});

// The merchant approved: exchange the one-time code for an access token.
app.get("/sellorchard/callback", async (req, res) => {
  if (!verifyHmac(req.query, CLIENT_SECRET)) return res.status(400).send("Invalid hmac");

  const savedState = /(?:^|;\s*)so_state=([^;]+)/.exec(req.headers.cookie ?? "")?.[1];
  if (savedState && req.query.state !== savedState) return res.status(400).send("Invalid state");

  const response = await fetch("https://api.sellorchard.com/v1/oauth/token", {
    method: "POST",
    headers: { "Content-Type": "application/json", Accept: "application/json" },
    body: JSON.stringify({
      client_id: CLIENT_ID,
      client_secret: CLIENT_SECRET,
      code: String(req.query.code),
      redirect_uri: REDIRECT_URI,
    }),
  });
  if (!response.ok) return res.status(502).send("Token exchange failed");

  const { access_token, scope, store } = await response.json();
  // Store the token encrypted, keyed by store.id. It never expires; it stops working on uninstall.
  await saveInstallation(store.id, { accessToken: access_token, scope, name: store.name, domain: store.domain });
  res.redirect(`/welcome?store=${encodeURIComponent(store.id)}`);
});

app.listen(3000);
Keep the client secret and access tokens on your server only. Store tokens encrypted. A token doesn’t expire, but it stops working the moment the merchant uninstalls your app.

Check your hmac

With these inputs your hmac code should produce exactly this value:

Test vector
client_secret: so_secret_test
query:         code=c0d3_7f9a2b&store=01JBX4Q2ZK8V7Y5N3M1P0R9S6T&state=b1946ac92492d2347c6235b4d2611184
signed:        code=c0d3_7f9a2b&state=b1946ac92492d2347c6235b4d2611184&store=01JBX4Q2ZK8V7Y5N3M1P0R9S6T
hmac:          eabc5131995b25e53e3ff5448918c88f0a361ed1fb8a43de63c410381f770e31

Calling the API

Send the token as a Bearer token, exactly like an API key. The store comes from the token, so there’s nothing else to pass.

Shell
curl https://api.sellorchard.com/v1/store \
  -H "Authorization: Bearer so_app_6f1d0c7a9b2e4f8a1c3d5e7f9a0b2c4d"
const res = await fetch("https://api.sellorchard.com/v1/products?per_page=50", {
  headers: { Authorization: `Bearer ${installation.accessToken}`, Accept: "application/json" },
});
if (res.status === 401) {
  // The merchant uninstalled the app: stop calling and clean up.
}
const { data } = await res.json();
  • GET /v1/oauth/installation returns the current token’s installation and granted scopes.
  • DELETE /v1/oauth/installation uninstalls your app from the store (for example when the merchant closes their account with you).
  • Rate limit: 120 requests per minute per installation (30 for stores on a free trial). See Rate limits for the headers and retrying.
  • A 401 means the app was uninstalled; a 403 means the merchant didn’t grant the scope that endpoint needs.

Scopes

Request only what your app needs; merchants see every scope on the consent screen. A write scope includes read. Put scopes that power optional features in optional scopes so merchants can decline them.

ResourceReadWrite
Products, variants, categories & brandsread_productswrite_products
Locations, inventory & purchase ordersread_inventorywrite_inventory
Orders, fulfillments & refundsread_orderswrite_orders
Customersread_customerswrite_customers
Discount codesread_discountswrite_discounts
Gift cardsread_gift_cardswrite_gift_cards
Bookingsread_bookingswrite_bookings
Reportsread_reports—
Store detailsread_store—

If a new version of your app requests more scopes, installed stores see an Update permissions prompt. Until a merchant accepts, your token keeps its old scopes, so handle 403 responses gracefully.

Embedded apps

Turn on Embedded and SellOrchard loads your App URL inside the admin at /s/{store}/apps/{slug}, in a sandboxed iframe (allow-scripts allow-forms allow-same-origin allow-popups), with these query parameters:

ParameterDescription
storeThe store ID.
hostThe admin host that is embedding your app.
sessionA short-lived session token proving the request comes from a signed-in staff member of that store.

Allow the admin to frame your page — e.g. Content-Security-Policy: frame-ancestors https://app.sellorchard.com — and use SameSite=None; Secure on any cookies you set inside the iframe.

Session tokens

session is a JWT signed with HS256 using your client_secret. It expires after 5 minutes, so verify it when the page loads and then use your own session.

ClaimValue
issAlways sellorchard.
audYour client_id.
storeThe store ID (must match the store query parameter).
userThe ID of the staff member using the admin.
expExpiry, in Unix seconds.
import crypto from "node:crypto";

/** Returns the claims of a valid session token, or null. */
export function verifySessionToken(token, clientId, clientSecret) {
  const parts = String(token ?? "").split(".");
  if (parts.length !== 3) return null;
  const [header, payload, signature] = parts;

  const expected = crypto.createHmac("sha256", clientSecret).update(`${header}.${payload}`).digest("base64url");
  if (signature.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) return null;

  const head = JSON.parse(Buffer.from(header, "base64url").toString("utf8"));
  const claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
  if (head.alg !== "HS256") return null;
  if (claims.iss !== "sellorchard" || claims.aud !== clientId) return null;
  if (typeof claims.exp !== "number" || claims.exp < Math.floor(Date.now() / 1000)) return null;
  return claims; // { iss, aud, store, user, exp }
}

// The page loaded in the admin iframe: /app?store=…&host=…&session=…
app.get("/app", (req, res) => {
  const claims = verifySessionToken(req.query.session, process.env.SELLORCHARD_CLIENT_ID, process.env.SELLORCHARD_CLIENT_SECRET);
  if (!claims || claims.store !== req.query.store) return res.status(401).send("Open this app from your SellOrchard admin.");
  // claims.store is the store, claims.user the signed-in staff member. Look up the installation by store.
  res.render("dashboard", { storeId: claims.store, userId: claims.user });
});

postMessage bridge

Your embedded page can ask the admin to do three things:

MessageEffect
{ type: "sellorchard:toast", message }Shows a toast notification in the admin.
{ type: "sellorchard:navigate", path }Navigates the admin to another page of the same store.
{ type: "sellorchard:resize", height }Sets the iframe height in pixels.
Embedded page
// Inside your embedded page. Always target the admin's origin, never "*".
const ADMIN_ORIGIN = "https://app.sellorchard.com";
const send = (message) => window.parent.postMessage(message, ADMIN_ORIGIN);

// Show a toast in the admin.
send({ type: "sellorchard:toast", message: "Settings saved" });

// Move the admin to another page of the same store (path inside the store admin).
send({ type: "sellorchard:navigate", path: "/orders" });

// Grow the iframe to fit your content instead of scrolling inside it.
new ResizeObserver(() => {
  send({ type: "sellorchard:resize", height: document.documentElement.scrollHeight });
}).observe(document.body);

Webhooks & app.uninstalled

On install, SellOrchard subscribes the store to your app’s webhook topics, delivered to your webhook URL. The endpoints are removed on uninstall. Deliveries use the same payload, headers, retries and X-SellOrchard-Signature scheme as store webhooks — but they are signed with your app’s client secret.

When a merchant uninstalls your app (or you call DELETE /v1/oauth/installation), the token is revoked and app.uninstalled is sent to your webhook URL:

app.uninstalled
{
  "id": "evt_01JC9V2M4K7QX3T8B5W6Y1Z0AR",
  "type": "app.uninstalled",
  "created_at": "2026-11-02T14:03:12Z",
  "api_version": "2026-11",
  "store_id": "01JBX4Q2ZK8V7Y5N3M1P0R9S6T",
  "data": {
    "object": {
      "object": "app",
      "id": "app_8f2k1m9q4x7v3b6n0c5z2w1e",
      "installation_id": 4182,
      "name": "My App",
      "slug": "my-app",
      "uninstalled_by": "merchant",
      "uninstalled_at": "2026-11-02T14:03:12Z"
    }
  }
}
// Same verification as store webhooks (see the Webhooks guide), keyed with your app's client secret.
app.post("/sellorchard/webhooks", express.raw({ type: "application/json" }), async (req, res) => {
  if (!verifySignature(req.body, req.get("X-SellOrchard-Signature"), process.env.SELLORCHARD_CLIENT_SECRET)) {
    return res.sendStatus(400);
  }
  const event = JSON.parse(req.body.toString("utf8"));
  if (event.type === "app.uninstalled") {
    await deleteInstallation(event.store_id); // the token is already revoked
  }
  res.sendStatus(200);
});

On app.uninstalled, stop calling the API for that store and delete or anonymise its data as your privacy policy says. A reinstall goes through the OAuth flow again and issues a new token.

Testing your app

  • While your app is a draft, you can install it on stores you own with Test install in the developer portal — no review needed.
  • Draft apps may use http://localhost redirect URLs for local development. Published apps must use HTTPS everywhere.
  • Use the trial of a test store to try your app against real API responses.

Review guidelines

Every app is reviewed before it’s published. We check that:

  • It works. Install, the main features and uninstall work on a fresh store without errors.
  • Scopes are minimal. Every requested scope is needed for a feature described in the listing; nice-to-haves are optional scopes.
  • The listing is accurate. Name, tagline, description, screenshots and pricing match what the app does and costs. No keyword stuffing, no SellOrchard branding that suggests we made it.
  • Security basics. HTTPS on every URL, hmac and state checked, session tokens verified, webhook signatures verified, secrets and tokens kept server-side.
  • Privacy. A privacy policy explains what store and customer data you keep and why, and data is removed after app.uninstalled as described.
  • Support. A working support URL or email that merchants get answers from.
  • Acceptable use. The app follows our Acceptable Use Policy — including the rules for age-restricted products.
  • Embedded apps load quickly, fit the iframe (use sellorchard:resize) and don’t open pop-ups without a user action.

If we reject an app, the review notes explain what to change; fix it and submit again. We may delist apps that stop working or break these guidelines. Browse published apps in the app marketplace.