# 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](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](/guides/development-stores): see [Testing your plugin](/guides/testing). [Getting started](/guides/getting-started) covers the architecture, [Scopes](/guides/scopes) the permission model, and the [SDK guide](/guides/sdk) how your app talks to Orbit at runtime.

## Register or sign in

1. Go to [https://partner.myorbitcommerce.net](https://partner.myorbitcommerce.net).
2. Register a partner account, or sign in if you already have one, and verify your email address.
3. 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](https://api.myorbitcommerce.net/v1/plugin-schema.json) 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? }`, where `url` is a path under `baseUrl` (or an absolute HTTPS URL) that the store dashboard loads in an iframe. `dashboard.main.embed` is 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 in `oauth.scopes`. See [Webhooks](/guides/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. `label` and `icon` are required.
- **`staffPermissions[]`**: the permissions a store hands its own staff for your plugin's screens. Each entry is `{ key, label, default? }`; see [Staff permissions](#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? }`, where `type` is one of `string`, `number`, `boolean` or `select`, and `options` (`{ label, value }`) applies to `select`.

```json
{
  "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](/guides/getting-started#5-configure-csp-and-https-for-iframe-embedding) 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](/guides/scopes) and the full [Scope Catalog](/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](/guides/authentication#verifying-tokens-on-your-own-backend)). 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](/guides/testing#3-point-the-store-at-your-local-app) 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:

```http
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:

```http
POST /api/plugins/mine/{pluginId}/releases/{releaseNumber}/activate
```

Activating 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.

```http
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 most
```

### Versioning

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](/guides/sdk) 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](/guides/development-stores).

Generate a link on the Distribution tab, or with the API:

```http
POST /api/plugins/mine/{pluginId}/install-links
Authorization: Bearer <partner JWT>
Content-Type: application/json
```

Every 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.

```http
GET    /api/plugins/mine/{pluginId}/install-links            # list, with a usable flag
DELETE /api/plugins/mine/{pluginId}/install-links/{linkId}   # revoke
```

`usable` 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](/guides/testing).

## 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.scopes` lists **only** the scopes you actually use (singular `<resource>:<action>` form).
- [ ] `baseUrl` and every `extensionPoints[].url` point at your hosted app and **load in an iframe**.
- [ ] `supportUrl` and `documentationUrl` are real, working links.
- [ ] You have **tested a full install** on a development store ([how](/guides/testing)): 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 `version` is a valid semver string.
- [ ] Listing fields are within their length limits.

## Next steps

- [Getting started](/guides/getting-started): plugin architecture and your first build.
- [Development stores](/guides/development-stores) and [Testing your plugin](/guides/testing): where and how to test.
- [Scopes](/guides/scopes) and the [Scope Catalog](/scope-catalog): choose the right permissions.
- [SDK](/guides/sdk): runtime calls, billing, and iframe helpers.
- [API reference](/api-reference): full endpoint and schema detail.
