Publishing your plugin
This guide takes you from a built plugin that works on your development store to one merchants can install: listed on the plugin store after Orbit's review, or made available through install links without a listing.
Overview
Publishing happens in the partner dashboard at https://partner.myorbitcommerce.net. You create the plugin (by name, or by importing your plugin.json), fill in its listing, keep its configuration in releases, optionally add pricing plans, and choose how it is distributed. There are two ways to go live:
Submit for Review approved
draft ──▶ release ──▶ public ─────────────────▶ in review ─────────▶ listed in the plugin store
│
└──▶ unlisted or private ──▶ Make available to merchants ──▶ live via install links
│
Submit for public listing ──▶ listed
Unpublish ──▶ inactive
Before you start, make sure your plugin works end to end on a development store: see Testing your plugin. Getting started covers the architecture, Scopes the permission model, and the SDK guide how your app talks to Orbit at runtime.
Register or sign in
- Go to https://partner.myorbitcommerce.net.
- Register a partner account, or sign in if you already have one, and verify your email address.
- From the partner dashboard you can create plugins, manage their releases, configure pricing, test them on development stores and submit them for review.
A single partner account can own multiple plugins.
Create the plugin
Under Plugins, choose New Plugin. Give it a name and choose Create plugin; listing details, technical configuration and media come after, on the plugin's own page.
If you already have a plugin.json, choose Import from plugin.json instead. The file is checked against the public schema and the plugin is created from it: configuration from the top level, listing copy from the listing block. Older field names (displayName, embedUrl, a top-level webhooks list, oauth.redirectUrl, author, permissions, ui.pages) are still accepted, and the import ends with a report headed What Orbit did with your plugin.json that lists every field that was changed to fit Orbit or left out, so nothing changes silently. A file that uses relative URLs needs a baseUrl to join them to.
The plugin page has six tabs:
| Tab | What lives there |
|---|---|
| Overview | The submission readiness checklist, and the Test on a development store panel |
| Listing | Name, descriptions, category, tags, features, support and documentation links, screenshots and icon |
| Technical | Extension points, scopes, menu and settings schema, the Releases card and the Webhook signing secret card |
| Plans | Pricing plans, if you charge |
| Distribution | Visibility, install links, and Make available to merchants |
| Reviews | Merchant reviews and your replies |
Listing
Marketplace-facing detail. The form enforces the limits.
| Field | Rules |
|---|---|
name | Display name, max 30 characters |
pluginId | Stable identifier, kebab-case, max 50 characters |
shortDescription | One-line summary shown on listing cards |
description | Full description for the plugin store page |
version | Semantic version, for example 1.0.0 |
category | One of: marketing, sales, analytics, shipping, payments, inventory, customer-service, design, productivity, other |
tags | Free-form tags |
features[] | Highlighted capabilities shown on the listing |
supportUrl | Where merchants get help |
documentationUrl | Your plugin's docs |
The pluginId is your plugin's permanent handle. Choose it carefully, since it identifies your plugin across installs and releases. supportUrl and documentationUrl are optional, but real, working links improve both review and merchant trust. In plugin.json all of this is the listing block, except pluginId and version, which are configuration.
Technical configuration
How your plugin mounts inside the store dashboard, what it can access, and how it is configured. In plugin.json these are the top-level fields; the Technical tab edits the same things.
baseUrl: where your hosted app is served. Every path below is joined onto it.extensionPoints[]: where your plugin UI appears. Each entry is{ target, url, label? }, whereurlis a path underbaseUrl(or an absolute HTTPS URL) that the store dashboard loads in an iframe.dashboard.main.embedis the main plugin page; the Technical tab lists the other targets, such as the home widget and the product page block and action.oauth.scopes[]: the permissions your plugin requests, in the singular<resource>:<action>form (product:read,order:list).oauth.webhooks[]:{ topic, path }subscriptions Orbit creates on every store that installs the plugin. Each topic needs its scope inoauth.scopes. See Webhooks.oauth.redirectUrls[]: OAuth callback paths or URLs, if you use them.ui.menu:{ label, icon, position?, parent? }for your entry in the store dashboard navigation.labelandiconare required.staffPermissions[]: the permissions a store hands its own staff for your plugin's screens. Each entry is{ key, label, default? }; see Staff permissions below.settingsSchema: the merchant-facing settings form, an object keyed by field key (lower_snake). Each field is{ type, title, description?, required?, options? }, wheretypeis one ofstring,number,booleanorselect, andoptions({ label, value }) applies toselect.
{
"baseUrl": "https://app.example.com",
"extensionPoints": [
{
"target": "dashboard.main.embed",
"url": "/orbit",
"label": "My Plugin"
}
],
"oauth": {
"scopes": ["product:list", "product:read", "order:read"],
"webhooks": [{ "topic": "order.created", "path": "/webhooks/orbit" }],
"redirectUrls": ["/oauth/callback"]
},
"ui": {
"menu": { "label": "My Plugin", "icon": "puzzle", "position": 50 }
},
"staffPermissions": [
{ "key": "orders:sync", "label": "Run a sync by hand", "default": "staff" },
{ "key": "settings:edit", "label": "Change the connection settings" }
],
"settingsSchema": {
"api_mode": {
"type": "select",
"title": "API mode",
"description": "Which environment to sync with",
"required": true,
"options": [
{ "label": "Live", "value": "live" },
{ "label": "Sandbox", "value": "sandbox" }
]
}
}
}Every URL in a release must be HTTPS, and localhost is refused by the release validator. Your hosted app must also send the frame-ancestors header from Getting started so the dashboard can embed it.
Request only the scopes you need
The scopes you list in oauth.scopes are what the merchant is asked to consent to at install. The granted set becomes your token's scopes claim and is enforced on every request: a token missing a required scope is rejected with HTTP 401 (the error body names the missing scope). Webhook-topic scope failures are the exception; those return HTTP 403.
Request the minimum that makes your plugin work. Over-requesting raises friction at the consent step and during review. If you later need more, ship a new release. See the Scopes guide and the full Scope Catalog.
Staff permissions
Scopes say what your plugin may do in a store. Staff permissions say what each member of the store's team may do inside your plugin, and the store decides, not you. Declare them in staffPermissions[]:
| Field | Purpose |
|---|---|
key | resource:action in lower case, two or three segments (deals:manage, automations:rules:edit), each starting with a letter and then letters, digits or hyphens; at most 64 characters and unique within your plugin |
label | What the store's role editor shows beside the switch, at most 80 characters |
default | The lowest store role that holds it without being granted it: owner, admin or staff. Defaults to admin |
A team member who holds any of your permissions can open your plugin's page, even without the settings:read:store permission every plugin page otherwise takes: a store hands a custom role one of your permissions so that role can use your plugin. A store owner always holds every permission you declare. Admins hold the admin and staff ones, staff and the premade specialist roles hold the staff ones, and a team member with only a custom role holds whatever that role or a one-off grant gives them. When your release goes live, each entry becomes a row in the store's role editor under your plugin's name, so a store can widen or narrow access per role exactly as it does for Orbit's own permissions. A key you later remove from the manifest disappears from every role.
Your plugin learns what the current user holds from the session token: POST /oauth/verify-token answers permissions, the keys this user holds, next to userId and userName (see Verifying tokens). Check it before showing a control or acting on a request made from your embedded page, and treat a missing list as holding nothing. The list is worked out when the session token is minted, so a role change reaches your plugin with the user's next session token. Background work runs on the access token, which names no user and carries no permissions.
At most 50 entries. Staff permissions are configuration: changing them is a new release, like changing a scope.
Releases
A release is an immutable snapshot of your configuration. Merchants' stores run your plugin's current release; Orbit reviews a specific release; a development release runs only on your development stores. Listing copy is never part of a release, so you can edit it at any time without releasing.
Your first save of the Technical tab creates release 1 and makes it current. From then on, configuration changes are made by creating a release:
POST /api/plugins/mine/{pluginId}/releases
Authorization: Bearer <partner JWT>
Content-Type: application/json
{ "manifest": { ...your plugin.json configuration... }, "env": "prod", "notes": "Adds the order.created webhook" }The same configuration under the same version returns the existing release with deduplicated: true (200); anything else is a new release (201). A manifest that fails validation is a 400 with errors: [{ path, code, message }]. Add "activate": true to make the release current straight away, or activate it later:
POST /api/plugins/mine/{pluginId}/releases/{releaseNumber}/activateActivating a release rolls it out to every store running the plugin, so treat it as a deploy. Development releases cannot be activated; the API refuses with the reason. The Technical tab's Releases card lists every release, newest first, with a Production or Development badge and Current on the live one.
GET /api/plugins/mine/{pluginId}/releases # newest first, as { items, meta }
GET /api/plugins/mine/{pluginId}/releases?channel=production
GET /api/plugins/mine/{pluginId}/releases?page=2&limit=50 # 50 a page by default, 100 at mostVersioning
Use semantic versioning (MAJOR.MINOR.PATCH, for example 1.0.0) for the version field. It is a label on the release, not part of what makes two releases different.
- PATCH (
1.0.1): backwards-compatible fixes. - MINOR (
1.1.0): backwards-compatible additions. - MAJOR (
2.0.0): breaking changes, including changes to the scopes you require.
If a new release requests additional scopes, merchants consent to the expanded set, so treat scope expansion as a significant change and document it in your release notes.
Configure pricing (if monetising)
If you charge for your plugin, configure pricing plans on the Plans tab. Plans can be:
- free: no charge
- one_time: a single purchase
- subscription: recurring billing
Your plugin reads and acts on the merchant's billing state at runtime through the SDK's billing client, for example orbit.billing.getSubscriptionStatus() to check entitlement, and orbit.billing.requestPurchase({ planId, priceId }) to open the dashboard payment modal. Checking entitlement requires the billing:read scope, and it should also happen on your server: a page running on the merchant's machine cannot be what decides whether they paid. See the SDK guide for the full billing surface. On a development store a paid plugin runs on a trial that never bills, so you can test the paid path without a card.
Choose how it is distributed
On the Distribution tab, pick a visibility:
| Visibility | Who can install | How they install | Review |
|---|---|---|---|
| Public | Any merchant | The plugin store listing | Orbit reviews it before it goes live |
| Unlisted | Anyone with a link | An install link | None |
| Private | One named store, or one use | An install link bound to that store or single use | None |
Public is the plugin store. Switching a plugin to Public directly is refused; going public happens through Submit for Review, and approval switches it. Unlisted and private plugins never appear in the plugin store, so they need no review to go live.
Make it available to merchants
For an unlisted or private plugin, the Distribution tab has Make available to merchants. It makes the plugin live at once, with no review, and it stays out of the plugin store; merchants install it through your install links. You can still submit it for a public listing later. The button is enabled once the readiness checklist on the Overview tab is complete.
Install links start working once the plugin is live. Until then a merchant who opens one is told the link is not valid, so wait before you share it. Install links are for merchants: to test your own plugin, use a development store.
Generate a link on the Distribution tab, or with the API:
POST /api/plugins/mine/{pluginId}/install-links
Authorization: Bearer <partner JWT>
Content-Type: application/jsonEvery field is optional. An empty body gives you a reusable link that never expires.
| Field | Type | Effect |
|---|---|---|
expiresAt | ISO 8601 | Link stops working after this instant. Must be in the future |
maxUses | integer, at least 1 | Cap on installs through this link. 1 makes it single-use |
targetStoreId | uuid | Binds the link to one store; only that store can resolve or install through it |
The response contains the raw token and the shareable URL. This is the only time either is returned: only a SHA-256 hash of the token is stored, so a database leak never exposes a usable link. If you lose it, revoke the link and generate another. Creating a link for a public plugin returns 400; public plugins are installed from the listing.
A merchant who opens the link while signed in to their store sees the consent screen: your plugin's identity and the exact scopes its current release asks for. Accepting installs and activates it, and the granted scopes become your plugin's token scopes for that store.
GET /api/plugins/mine/{pluginId}/install-links # list, with a usable flag
DELETE /api/plugins/mine/{pluginId}/install-links/{linkId} # revokeusable is true only when the link would install right now: the plugin is live and the link is not revoked, expired or used up. Revoking is idempotent. Revoke a link the moment it has served its purpose; anyone holding it can install your plugin on a store they control until it expires or hits its use cap. Unknown, revoked, expired and exhausted links, and links whose plugin is not live, all return the same 404, so a link that stops working gives no signal about which of those it was. A link bound to a different store returns 403.
Unpublish
A live unlisted or private plugin has an Unpublish action. Merchants who already installed it keep using it; new installs through your links stop until you make it available again, and a pending listing review is withdrawn. Public plugins are listed in the store and cannot be unpublished this way.
Submit for review
Review is what puts a plugin in the plugin store. Two things gate it:
- The readiness checklist on the Overview tab: a name, plugin ID, short description, description, version and category, enough screenshots, and a chosen distribution. The same checklist gates Make available to merchants.
- A release. Submitting records your current release as the submitted release, so Orbit reviews exactly that. A plugin with no release is refused with "Create a release before submitting your plugin for review."
Then:
- A draft, inactive or rejected plugin with Public visibility: choose Submit for Review. It moves to In review.
- A live unlisted or private plugin: choose Submit for public listing. It stays live through your install links while Orbit reviews it, and approval switches it to Public.
Orbit staff install the submitted release on a review store of their own, open it as a merchant would, place a test order and check the manifest, the scopes you requested and that your embedded app loads and behaves. You can keep releasing while the review is open; the reviewer sees that the plugin has changed since submission but tests the release you submitted. Approval lists the plugin in the plugin store. Rejection shows Changes requested with the reviewer's notes; address them and submit again (a live plugin stays live through its install links in the meantime).
To keep review smooth:
- Make sure your extension point URLs load over HTTPS and embed cleanly in an iframe.
- Request only the scopes your plugin actually uses.
- Provide a working support URL and documentation URL.
- Test the whole flow on a development store before submitting: install, open, place a test order, and send a test webhook for every topic you handle. See Testing your plugin.
Pre-submission checklist
Before you submit, confirm:
- Your app is served over HTTPS.
- Your app sends the iframe CSP header:
Content-Security-Policy: frame-ancestors 'self' https://*.orbitcommerce.net https://*.myorbitcommerce.net. -
oauth.scopeslists only the scopes you actually use (singular<resource>:<action>form). -
baseUrland everyextensionPoints[].urlpoint at your hosted app and load in an iframe. -
supportUrlanddocumentationUrlare real, working links. - You have tested a full install on a development store (how): the plugin mounts,
orbit.ready()resolves, your core flows work, and your webhooks verify. - Your current release is the one you want reviewed, and its
versionis a valid semver string. - Listing fields are within their length limits.
Next steps
- Getting started: plugin architecture and your first build.
- Development stores and Testing your plugin: where and how to test.
- Scopes and the Scope Catalog: choose the right permissions.
- SDK: runtime calls, billing, and iframe helpers.
- API reference: full endpoint and schema detail.