Apps
Building apps
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
- Sign in to SellOrchard (any merchant account works) and open the developer portal.
- Click Become a developer and enter your company name, website and a support email merchants can reach.
- 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:
| Tab | What you set |
|---|---|
| Listing | Name, 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. |
| Setup | App URL, whether it is embedded, redirect URLs (OAuth), webhook URL and topics, requested and optional scopes. |
| Credentials | Your 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:
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:
https://myapp.example.com/sellorchard/callback?code={code}&store={store_id}&state={state}&hmac={hmac}Before doing anything else, verify the hmac:
- Take every query parameter except
hmac(including any query string that is part of your registered redirect URL). - Sort them by key.
- Build a query string from them:
key=valuepairs joined by&, with keys and values percent-encoded per RFC 3986 (PHP’shttp_build_query(…, PHP_QUERY_RFC3986); in JavaScript,encodeURIComponentplus encoding!'()*). - Compute HMAC-SHA256 of that string with your
client_secretas the key, hex-encoded (lowercase), and compare it tohmacin 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:
{
"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);<?php
// callback.php — the redirect URL registered for your app.
session_start();
$clientId = getenv('SELLORCHARD_CLIENT_ID'); // app_…
$clientSecret = getenv('SELLORCHARD_CLIENT_SECRET');
$redirectUri = 'https://myapp.example.com/sellorchard/callback.php'; // registered exactly
/** hmac = HMAC-SHA256(client_secret, the other query parameters sorted by key, as an RFC 3986 query string). */
function sellorchard_verify_hmac(array $query, string $secret): bool
{
$hmac = $query['hmac'] ?? null;
unset($query['hmac']);
if (!is_string($hmac) || $hmac === '') return false;
$query = array_filter($query, 'is_scalar');
ksort($query, SORT_STRING);
$message = http_build_query($query, '', '&', PHP_QUERY_RFC3986);
return hash_equals(hash_hmac('sha256', $message, $secret), $hmac);
}
if (!sellorchard_verify_hmac($_GET, $clientSecret)) {
http_response_code(400);
exit('Invalid hmac');
}
// If you started the install with a state (saved in the session), it must come back unchanged.
if (isset($_SESSION['so_state']) && !hash_equals($_SESSION['so_state'], (string) ($_GET['state'] ?? ''))) {
http_response_code(400);
exit('Invalid state');
}
unset($_SESSION['so_state']);
$ch = curl_init('https://api.sellorchard.com/v1/oauth/token');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Accept: application/json'],
CURLOPT_POSTFIELDS => json_encode([
'client_id' => $clientId,
'client_secret' => $clientSecret,
'code' => (string) $_GET['code'],
'redirect_uri' => $redirectUri,
]),
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status !== 200) {
http_response_code(502);
exit('Token exchange failed');
}
$token = json_decode($body, true);
// Store the token encrypted, keyed by store id. It never expires; it stops working on uninstall.
save_installation($token['store']['id'], $token['access_token'], $token['scope'], $token['store']['domain']);
header('Location: /welcome?store=' . urlencode($token['store']['id']));Check your hmac
With these inputs your hmac code should produce exactly this value:
client_secret: so_secret_test
query: code=c0d3_7f9a2b&store=01JBX4Q2ZK8V7Y5N3M1P0R9S6T&state=b1946ac92492d2347c6235b4d2611184
signed: code=c0d3_7f9a2b&state=b1946ac92492d2347c6235b4d2611184&store=01JBX4Q2ZK8V7Y5N3M1P0R9S6T
hmac: eabc5131995b25e53e3ff5448918c88f0a361ed1fb8a43de63c410381f770e31Calling 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.
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();<?php
$ch = curl_init('https://api.sellorchard.com/v1/products?per_page=50');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $installation['access_token'], 'Accept: application/json'],
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status === 401) {
// The merchant uninstalled the app: stop calling and clean up.
}
$products = json_decode($body, true)['data'];GET /v1/oauth/installationreturns the current token’s installation and granted scopes.DELETE /v1/oauth/installationuninstalls 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
401means the app was uninstalled; a403means 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.
| Resource | Read | Write |
|---|---|---|
| Products, variants, categories & brands | read_products | write_products |
| Locations, inventory & purchase orders | read_inventory | write_inventory |
| Orders, fulfillments & refunds | read_orders | write_orders |
| Customers | read_customers | write_customers |
| Discount codes | read_discounts | write_discounts |
| Gift cards | read_gift_cards | write_gift_cards |
| Bookings | read_bookings | write_bookings |
| Reports | read_reports | — |
| Store details | read_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:
| Parameter | Description |
|---|---|
store | The store ID. |
host | The admin host that is embedding your app. |
session | A 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.
| Claim | Value |
|---|---|
iss | Always sellorchard. |
aud | Your client_id. |
store | The store ID (must match the store query parameter). |
user | The ID of the staff member using the admin. |
exp | Expiry, 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 });
});<?php
function sellorchard_b64url_decode(string $value): string
{
return base64_decode(strtr($value, '-_', '+/') . str_repeat('=', (4 - strlen($value) % 4) % 4));
}
/** Returns the claims of a valid session token, or null. */
function sellorchard_verify_session_token(string $token, string $clientId, string $clientSecret): ?array
{
$parts = explode('.', $token);
if (count($parts) !== 3) return null;
[$header, $payload, $signature] = $parts;
$expected = rtrim(strtr(base64_encode(hash_hmac('sha256', "$header.$payload", $clientSecret, true)), '+/', '-_'), '=');
if (!hash_equals($expected, $signature)) return null;
$head = json_decode(sellorchard_b64url_decode($header), true);
$claims = json_decode(sellorchard_b64url_decode($payload), true);
if (($head['alg'] ?? null) !== 'HS256' || !is_array($claims)) return null;
if (($claims['iss'] ?? null) !== 'sellorchard' || ($claims['aud'] ?? null) !== $clientId) return null;
if (!is_int($claims['exp'] ?? null) || $claims['exp'] < time()) return null;
return $claims; // ['iss', 'aud', 'store', 'user', 'exp']
}
$claims = sellorchard_verify_session_token((string) ($_GET['session'] ?? ''), getenv('SELLORCHARD_CLIENT_ID'), getenv('SELLORCHARD_CLIENT_SECRET'));
if ($claims === null || $claims['store'] !== ($_GET['store'] ?? null)) {
http_response_code(401);
exit('Open this app from your SellOrchard admin.');
}
// $claims['store'] is the store, $claims['user'] the signed-in staff member.postMessage bridge
Your embedded page can ask the admin to do three things:
| Message | Effect |
|---|---|
{ 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. |
// 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:
{
"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);
});<?php
// Same verification as store webhooks (see the Webhooks guide), keyed with your app's client secret.
$payload = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_SELLORCHARD_SIGNATURE'] ?? '';
if (!verify_sellorchard_signature($payload, $header, getenv('SELLORCHARD_CLIENT_SECRET'))) {
http_response_code(400);
exit;
}
$event = json_decode($payload, true);
if ($event['type'] === 'app.uninstalled') {
delete_installation($event['store_id']); // the token is already revoked
}
http_response_code(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://localhostredirect 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.uninstalledas 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.