# Testing your plugin

Install your plugin on a real store and exercise it end to end — before it is
listed, reviewed, or seen by any merchant.

You do this with an **install link**: a one-time URL you generate for a plugin
you own, which installs it on a store directly. It is not a shortcut around the
platform. An install link runs the same install path as a marketplace install,
so scope consent, tenant isolation and auditing all behave identically. What you
test is what merchants will get.

## What you need

Two things, and they are separate accounts because they do different jobs.

| | Where | What it gives you |
|---|---|---|
| **Partner account** | [partner.myorbitcommerce.net](https://partner.myorbitcommerce.net) | Your plugin: identity, scopes, versions, install links |
| **A store** | — | A real store context and the tokens your plugin calls the API with |

The partner dashboard does not create stores, and development stores are not
self-serve yet — that is on our roadmap. In the meantime, if you are working with
us on an integration, talk to your Orbit contact about arranging a store to
develop against.

## 1. Make your plugin unlisted

Install links are the distribution channel for plugins that are **not** on the
public marketplace, so your plugin's visibility must be `unlisted` or `private`.

| Visibility | Who can install | Install route |
|---|---|---|
| `public` | any merchant | marketplace listing |
| `unlisted` | anyone with a link | install link |
| `private` | one named store | install link bound to that store |

Creating a link for a `public` plugin returns `400` — public plugins are
installed from the listing, and a side-channel for them would only blur the
review story.

While you are building, `unlisted` is what you want. Switch to `public` and
submit for review when you are ready to be listed — see
[Publishing](/guides/publishing).

## 2. Point the plugin at your local app

Your plugin is your own app, and the dashboard embeds it over HTTPS. Browsers
will not embed an insecure origin alongside a secure one, so `http://localhost`
will not load in the dashboard.

Run a tunnel — ngrok, Cloudflare Tunnel, or any HTTPS dev proxy — and set your
manifest's `extensionPoints[].url` to the tunnel URL:

```json
{
  "extensionPoints": [
    {
      "target": "dashboard.menu",
      "url": "https://your-tunnel.ngrok.app/embed",
      "label": "My Plugin"
    }
  ]
}
```

Your app must also allow the embed:

```
Content-Security-Policy: frame-ancestors 'self'
  https://*.orbitcommerce.net https://*.myorbitcommerce.net
```

## 3. Generate an install link

```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 ≥ 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 |

```bash
curl -X POST \
  'https://api.myorbitcommerce.net/api/plugins/mine/my-plugin/install-links' \
  --header 'Authorization: Bearer <partner JWT>' \
  --header 'Content-Type: application/json' \
  --data '{ "maxUses": 1, "expiresAt": "2026-12-31T23:59:59Z" }'
```

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.

The URL looks like:

```
https://<vendor-dashboard>/plugins/install-link/<token>
```

## 4. Install it on the store

Open that URL while signed in to the store. You will see the same consent screen
a merchant sees: your plugin's identity and the exact scopes it is asking for.
Accept, and the plugin is installed and activated.

The granted scopes become your plugin's token scopes for that store. If you
later change `oauth.scopes` in your manifest, reinstall — consent is fixed at
install time, and an existing install keeps the scopes it was granted.

Under the hood the dashboard calls:

```http
GET  /api/plugins/install-links/{token}          # consent summary
POST /api/plugins/install-links/{token}/install  # install + activate
```

Both require `x-store-id` and a store session. You rarely call them directly —
the dashboard does it for you — but they are there if you are scripting a test
environment.

## 5. Exercise it

Your plugin now loads in the dashboard. The iframe handshake fires, the SDK
receives the session token and store id, and your API calls are scoped to that
store.

From here the loop is fast: edit locally, your tunnel serves the change, refresh
the iframe. You only return to the partner dashboard when the manifest changes.

### Calling the API outside the iframe

For Postman, curl, or the [API reference](/api-reference), mint a session token
for the store:

```http
POST /api/plugins/{pluginId}/session-token
Authorization: Bearer <admin JWT for the store>
x-store-id: <store uuid>
```

That token is valid for **one hour**. For longer runs, exchange it:

```http
POST /oauth/token/exchange
Authorization: Bearer <session token>
```

which returns an access token (**one hour**) plus a refresh token (**90 days**).
Refresh via `POST /oauth/token/refresh`. There is no long-lived access token —
see [Authentication](/guides/authentication).

## Managing links

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

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

## Troubleshooting

| Symptom | Cause |
|---|---|
| `400` creating a link | The plugin's visibility is `public`, or `expiresAt` is in the past |
| `403` creating a link | Your partner account does not own that plugin |
| `404` opening a link | Unknown, revoked, expired, or out of uses |
| `403` opening a link | The link is bound to a different store |
| Iframe stays blank | Your app is not reachable over HTTPS, or is missing the `frame-ancestors` header |
| `401` from `/v1/*` | Bad or expired token — **or** the install is missing that scope. Both return `401`; check the granted scopes before assuming the token is at fault |

## Next steps

- [Publishing your plugin](/guides/publishing) — submit for review and get listed
- [Scopes](/guides/scopes) and the [Scope Catalog](/scope-catalog) — pick the right permissions
- [Authentication](/guides/authentication) — the token model in full
