# OAuth Scope Catalog

The scope families a plugin or store API key works with day to day on the Orbit Commerce public API (`api.myorbitcommerce.net/v1/*`, plus the `/oauth/*` token handshake). Most scopes are requestable by a third-party plugin; the remainder are labelled with the credential that can hold them. When a merchant installs your plugin they consent to the scopes you request; the granted set becomes the `scopes` claim on every token your plugin receives, and each endpoint checks for the scope it needs.

The same scope strings govern [store API keys](/guides/api-keys): the merchant picks the key's scopes at creation instead of consenting at install. Every resource scope below is grantable to a key, with two exceptions, each labelled where it appears. The API refuses them at key creation with `400`, and the dashboard's key picker, which lists exactly what the API accepts, never offers them:

- **Plugin-only** (`billing:*`, `email:send`, `settings:*`, `customer-events:create`): these only make sense for an installed plugin and are rejected for keys.
- **Consent screen only** (`page:*`, `store-settings:*`, `tracking-script:*`, `import:*`, plus families not yet on this page: `theme:*`, `template:*`, `currency:*`, `domain:*`, `plugin:*`, `global-overlay:*`, `team:*`, `custom-field-definition:*`, `site-verification:update`): writes that change what every shopper sees, rewrite the catalogue in bulk, or change who controls the shop. They are only ever granted on a screen where the merchant approves them by name (a plugin install, or connecting an AI assistant), never to a store key. See [Consent-screen scopes](#consent-screen-scopes) for pages and store settings, [Tracking scripts](#tracking-scripts-tracking-script) and [Product import](#product-import-import).

This page is the **public contract** for the families it covers: every scope string here is exactly the string the API enforces, so copy them verbatim. It is not yet exhaustive: `/v1` enforces further families (inventory, shipping, discounts, tags, gift cards, returns, quotes, suppliers and more) that this page has still to document. For how scopes are requested, consented to, and checked at runtime, see the [Scopes guide](/guides/scopes); for the endpoint-by-endpoint detail, including the families not yet listed here, see the [API Reference](/api-reference).

> **Naming convention.** Scopes are `<resource>:<action>`. The resource is **singular** and kebab-case (`product`, `customer`, `tracking-script`, never `products` or `trackingScripts`). The action is usually one of `list`, `read`, `create`, `update`, `delete`; a small number of scopes use a purpose verb where a CRUD action would be misleading (`order:fulfill`, `email:send`, `cart:adjust`), and content resources with a draft/live split add `publish` (`page:publish`, `blog-post:publish`). So `product:write` is invalid (`write` is not an action: use `update`), and `products:read` is invalid (the resource must be singular).

A call made **without a token**, or with an invalid or expired one, returns `401 Unauthorized`. A call made with a valid token that is **missing the required scope** also returns `401 Unauthorized`; the error body names the missing scope (`Missing required scopes: …`). The one exception is subscribing to a webhook topic without the topic's scope, which returns `403 Forbidden`.

---

## Products: `product:*`

The product catalogue: variants, prices, inventory, images, and taxonomy associations. The most-used scope set for sync plugins (marketplaces, feed generators, import/export, reviews, search).

| Scope | Grants |
|---|---|
| `product:list` | `GET /v1/products` (filtered, paginated), `GET /v1/products/sync` (lightweight sync variant; follow `meta.nextCursor` with `?cursor=` to page a large catalogue at constant speed). Required for any plugin that iterates the catalogue. With `customer:read` and `b2b-catalog:read`, also `GET /v1/customers/{customerId}/catalog/products`: the products one customer may see, with what they pay (see [Customer catalogue and prices](/guides/customer-catalog)). |
| `product:read` | `GET /v1/products/{id}`, `POST /v1/products/batch-lookup`. Pair with `product:list` for plugins that fetch detail after listing. With `customer:read` and `b2b-catalog:read`, also `GET /v1/customers/{customerId}/catalog/products/{productId}` and `POST /v1/customers/{customerId}/catalog/prices`. |
| `product:create` | `POST /v1/products`. Used by import plugins and marketplace inbound (Amazon → Orbit, eBay → Orbit). |
| `product:update` | `PATCH /v1/products/{id}`, `PATCH /v1/products/bulk-update`. Used by sync plugins to push price, inventory, and SEO updates from external sources. |

A plugin that only **listens** to product events through [webhooks](/guides/webhooks) (`product.created`, `product.updated`) needs `webhook:create` to subscribe plus `product:read` for the topic. The payload carries the product id, not the product: read it with `GET /v1/products/{id}`.

---

## Orders: `order:*`

Reads, create-on-behalf-of and status sync for marketplace plugins, plus fulfilment with tracking for shipping/3PL plugins.

| Scope | Grants |
|---|---|
| `order:list` | `GET /v1/orders`. Iteration for sync and reporting plugins, with filters for status, payment status, fulfilment state, created-date window, and `updatedFrom` for incremental sync. For company orders, filter by `companyId`, `poNumber`, `costCentreId` and `approvalState`. |
| `order:read` | `GET /v1/orders/{id}`, `GET /v1/orders/{id}/fulfillments`. Order and fulfilment detail. |
| `order:create` | `POST /v1/orders`. Used by marketplace plugins and ERP syncs that import orders from an external platform as native Orbit orders. Delivery and billing addresses can be sent inline as `shippingAddress` / `billingAddress` (name, address lines, city, state, postcode, ISO alpha-2 `countryCode`, phone), for source systems that hold the address on the order rather than in a customer address book. The address is frozen onto the order as sent and is not added to the customer's saved addresses; a saved-address id (`shippingAddressId` / `billingAddressId`) wins when both are sent, and billing defaults to shipping when omitted. A company order can carry `poNumber`, `companyLocationId`, `deliveryInstructions` and, per item, `costCentreCode`. Its company comes only from `customerId`, so send the company user's `customerId` with them; see [Orders, approvals and pay on account](/guides/b2b-orders). Lines can name their product by `sku` alone (the create response lists any SKU that linked nothing in `unmatchedSkus`), and `orderNumber` keeps the source system's own order number, unique per store (`409 ORDER_NUMBER_EXISTS`). See [Importing orders from your ERP](/guides/b2b-erp-integration#importing-orders-from-your-erp). |
| `order:update` | `PATCH /v1/orders/{id}` (sync an order's `status`, `paymentStatus`, or `notes`; `paymentStatus: "paid"` also settles the order's open or past-due B2B invoice, which fires `b2b_invoice.paid`; an order awaiting approval refuses a `status` or `paymentStatus` that would change it with `409` `ORDER_AWAITING_APPROVAL`; set custom `metadata`, merged key-by-key, where `null` removes a key, and `source`, `externalId` and the keys the platform owns, such as `payment_flow`, `approval_payment_flow`, `approval_staff_only` and `approval_payment_term_id`, are ignored; toggle `archived`, idempotently), `POST /v1/orders/{id}/tags` (find-or-create 1 to 20 tags by name, each up to 100 characters, and attach them; `overwrite: true` replaces the order's tags with those. An empty list is refused with `400`, so this route cannot leave an order with no tags), `POST /v1/orders/{id}/approve` and `POST /v1/orders/{id}/decline` (decide an order held for approval, with an optional `reason`). |
| `order:fulfill` | `POST /v1/orders/{id}/fulfillments` (fulfil an order fully or partially with tracking number/URL/carrier; omit `items` to fulfil every remaining quantity), `PATCH /v1/orders/{id}/fulfillments/{fulfillmentId}` (correct tracking on a fulfilled order, mark delivered, or cancel/void a shipment). |

`order:update` is deliberately narrow: pricing, line items, currency, and addresses are not editable through it (and a paid + fulfilled order seals those fields). `order:fulfill` is separate from `order:update` because fulfilling has customer-facing side effects: creating a fulfilment with tracking sends the store's Order Shipped email/SMS unless you pass `notifyCustomer: false` (do that when your plugin sends its own notification). Refunds and cancellation workflows stay owned by the storefront and dashboard.

---

## Customers: `customer:*`

Read and write customer records: list and detail, plus create, update and delete on behalf of a merchant (for example, mirroring a CRM into Orbit, or resolving and enriching buyer identity during order import).

| Scope | Grants |
|---|---|
| `customer:list` | `GET /v1/customers`. List the customer base, as marketing and CRM plugins do. |
| `customer:read` | `GET /v1/customers/{id}`, `GET /v1/customers/by-email/{email}`. Customer detail (including tags), and buyer-identity lookup by email (e.g. resolving a customer during order import, or for reviews: "which customer left this review?"). Also the topic scope for the `customer.*` webhook topics. Together with a product scope and `b2b-catalog:read` it opens the customer catalogue view (`/v1/customers/{customerId}/catalog/*`), which includes the prices that customer pays, negotiated B2B prices included. |
| `customer:create` | `POST /v1/customers`. Create a customer, e.g. mirroring a CRM contact into Orbit, or registering a buyer imported from a marketplace. |
| `customer:update` | `PATCH /v1/customers/{id}` (update a customer's details), `POST /v1/customers/{id}/tags` (find-or-create up to 20 tags by name, each up to 100 characters, and attach them; `overwrite: true` replaces the customer's tags, and with an empty list removes every tag, which the order route refuses). |
| `customer:invite` | `POST /v1/customers/{id}/send-invite`. Email the customer the store's account invite: a link where they choose a password for their account on this store and are signed in. There is no recipient or message parameter; the address comes from the customer record. Refused with `409 Conflict` once the account is active (`CUSTOMER_ALREADY_ACTIVE`), or when this store's account with the email has never confirmed it and either already holds another customer record or the store is not open for self-registration (`CUSTOMER_EMAIL_UNCONFIRMED`), or while the customer has closed their own account on this store and it waits to be removed for good, which takes up to 30 days (`CUSTOMER_LOGIN_CLOSED`); `400 Bad Request` (`CUSTOMER_NO_EMAIL`) when the record has no email. Each store has its own shop accounts, so an account with the same email on another store or an Orbit dashboard is never touched. Sending again issues a fresh link. |
| `customer:delete` | `DELETE /v1/customers/{id}`. Remove a customer. This also closes their account on this store: their sessions end and the email can sign up again as a new account. |

---

## Customer timeline: `customer-events:read` / `customer-events:create`

| Scope | Grants |
|---|---|
| `customer-events:read` | `GET /v1/customers/{id}/events` (`before`, `limit`, `types`): a customer's history, newest first, over orders, returns, emails the store sent, abandoned checkouts, conversations, tickets and what plugins recorded. Takes the dashboard permission to read customers to grant, like `customer:read`. |
| `customer-events:create` | `POST /v1/customers/{id}/events` (`type`, `title`, optional `summary`, `route`, `occurredAt`, `externalId`, `data`): records one of your plugin's own events on the timeline, listed as `plugin.<pluginId>.<type>` where the store team and other plugins see it. Plugin-bound. |

See the [Customer timeline guide](/guides/customer-events).

---

## Taxonomies: `taxonomy:*`

The product classification hierarchy (Orbit calls this "taxonomy"; some platforms call it a "category tree"). Read-only on the public API: taxonomy management happens in the dashboard.

| Scope | Grants |
|---|---|
| `taxonomy:list` | `GET /v1/taxonomies`. Hierarchy walk for feed plugins (category mapping). |
| `taxonomy:read` | `GET /v1/taxonomies/{id}`, `GET /v1/taxonomies/by-handle/{handle}`. |

> In Orbit's vocabulary, a taxonomy is the classification tree. What other platforms call a "category" (a curated product collection) is a separate resource with its own scope family: see [Categories](#categories-category) below.

---

## Categories: `category:*`

Curated product collections (the dashboard's Categories): manual or smart, each a browsable storefront destination. Distinct from [taxonomies](#taxonomies-taxonomy), the classification tree.

| Scope | Grants |
|---|---|
| `category:list` | `GET /v1/categories` (paginated). |
| `category:read` | `GET /v1/categories/{id}`, with its linked products. |
| `category:create` | `POST /v1/categories`. |
| `category:update` | `PATCH /v1/categories/{id}`, plus membership edits on a manual category: `POST /v1/categories/{id}/products` (assign products) and `DELETE /v1/categories/{id}/products/{productId}` (remove one). Membership edits are updates *of the category*, so both run under this scope rather than `category:delete`. |
| `category:delete` | `DELETE /v1/categories/{id}`. Deliberately its own scope: a token granted `category:update` cannot destroy a category. |

A category has no separate publish step: `status` is writable on create and update, so a category created `active` is browsable by shoppers on the same request. Hold your own drafts back by creating them inactive.

---

## Brands: `brand:*`

The merchant's own brands. A brand is a storefront destination, not just a label: `/brands/{handle}` is a real route, the brand's description, logo, and SEO fields render on it, and the brand's name appears in the storefront product filter rail. (This is unrelated to the supplier-side brand catalogue, which is not on the public API.)

| Scope | Grants |
|---|---|
| `brand:list` | `GET /v1/brands` (paginated, always ordered by name A-Z; filter with `keyword` and `status`). |
| `brand:read` | `GET /v1/brands/{id}`. Includes `productCount`, the number of products currently carrying the brand: the number that matters before deleting it. |
| `brand:create` | `POST /v1/brands`. A new brand's page is reachable by shoppers as soon as the request returns; there is no draft state. |
| `brand:update` | `PATCH /v1/brands/{id}`. Every writable field is shopper-visible, and renaming the `handle` moves the brand's public URL (a redirect from the old one is created). |
| `brand:delete` | `DELETE /v1/brands/{id}` (soft delete). Further-reaching than it looks: every product carrying the brand goes unbranded on the storefront in the same request, the brand leaves the filter rail, and `/brands/{handle}` stops resolving. There is no restore route. |

---

## Carts: `cart:*`

On-demand read access to a live cart, plus the ability to own adjustment lines pushed onto one through the synchronous `function.cart.adjustments` extension point (conditional fees and surcharges, for example from a customs-duty or payment-surcharge plugin).

| Scope | Grants |
|---|---|
| `cart:read` | `GET /v1/carts/{cartId}` (store-tenanted; the response shape mirrors the `function.cart.adjustments` callout payload, so you learn one cart model across pull and push). Also the topic scope for the `cart.updated` webhook topic, whose payload carries ids only: pair the two so a plugin is notified, then fetches full detail. |
| `cart:adjust` | Not a callable endpoint by itself. Gates whether a plugin receives the `function.cart.adjustments` callout and may add its own adjustment lines back onto the cart; core enforces caps and ownership, so a plugin can only ever touch the lines it added. |

There is deliberately no list endpoint: enumerating a store's live carts is a privacy surface Orbit has not opened.

---

## Custom fields: `custom-field:read` / `custom-field:update`

Custom field **values** on the store's own resources: the columns an import/export or sync plugin needs beyond the built-in fields. Values only, deliberately: definitions (the store's schema) are merchant-managed in the dashboard, and there is no public create/update/delete for them, so a plugin can never mutate a store's schema.

| Scope | Grants |
|---|---|
| `custom-field:read` | `GET /v1/custom-field-definitions` (paginated; filter by `ownerType` and `namespace`), which lists the store's definitions so you can build your column catalogue, and `GET /v1/custom-fields/batch`, which returns values for up to 100 owners per request (`ownerIds` comma-separated, plus `ownerType`; optional `namespace` filter). |
| `custom-field:update` | `POST /v1/custom-fields/set`. Batch-upserts up to 25 values per call, keyed on (owner, namespace, key); each value is validated against the matching definition where one exists. |

Owner types span the platform: `PRODUCT`, `PRODUCT_VARIANT`, `CATEGORY`, `BRAND`, `ORDER`, `DRAFT_ORDER`, `CUSTOMER`, `PAGE`, `BLOG`, `BLOG_POST`, and `CRM_DEAL`. The store is always resolved from your token; a `storeId` in the query or body is ignored.

---

## Tracking scripts: `tracking-script:*`

The conversion-tracking pixels and custom scripts the storefront runs on every page: the well-known pixels (GA4, Meta, TikTok, Snapchat, Pinterest), the Google Ads tag, and scripts of your own. Every write is live from the next page a shopper loads. **Withheld from keys:** a tracking script runs in shoppers' browsers on the storefront, so a store API key cannot hold these scopes (requesting one answers `400`). A plugin requests them at install, and an AI assistant connection can be granted them.

| Scope | Grants |
|---|---|
| `tracking-script:read:store` | `GET /v1/tracking-scripts/snapchat` and `GET /v1/tracking-scripts/google-ads`: the store's Snapchat Pixel or Google Ads tag, or `null`. |
| `tracking-script:update:store` | `PUT`/`DELETE /v1/tracking-scripts/snapchat` (set or remove the Snapchat Pixel id) and `PUT /v1/tracking-scripts/google-ads` (set the Google Ads conversion id and event labels). |
| `tracking-script:read` | `GET /v1/tracking-scripts`: every script of the store, active or not, with each pixel's id and each custom script's body. |
| `tracking-script:update` | `PUT`/`DELETE /v1/tracking-scripts/pixels/{type}` (set or remove a well-known pixel: `type` is `google_analytics`, `facebook_pixel`, `tiktok_pixel`, `snapchat_pixel` or `pinterest_tag`), `POST /v1/tracking-scripts/custom` and `PATCH`/`DELETE /v1/tracking-scripts/custom/{id}` (custom scripts of up to 100 KB, placed in the head, at the start of the body or at its end). |

The `:store` pair is the Snapchat and Google Ads surface; the pair without a suffix covers every pixel and custom scripts.

**Not grantable to store API keys.** A custom tracking script is code the storefront serves on every page, so a leaked key holding these scopes would put someone else's code on the whole shop. Plugins request them on the install consent screen instead.

---

## Settings: `settings:read:store` / `settings:update:store`

Your plugin's own per-store configuration, as defined by its `settingsSchema`. Store-scoped (the `:store` suffix, as with tracking scripts).

| Scope | Grants |
|---|---|
| `settings:read:store` | `GET /v1/settings`. Returns your plugin's settings merged with the schema defaults. |
| `settings:update:store` | `PUT /v1/settings`. Partial-merge update; values are validated against your `settingsSchema`. |

The SDK wraps these as `orbit.settings.get()`, `orbit.settings.getValue(key)`, and `orbit.settings.update({ … })`; see the [SDK guide](/guides/sdk).

---

## Billing: `billing:read` / `billing:update`

Plugin monetisation, for plugins that charge merchants. Use the SDK's billing module (`orbit.billing.*`); these scopes gate what it can do.

| Scope | Grants |
|---|---|
| `billing:read` | Read-only billing: plans, subscription status, and the merchant's saved payment methods (`orbit.billing.getPlans()`, `getSubscriptionStatus()`, `getPaymentMethods()`). |
| `billing:update` | Create charges and manage subscriptions: subscribe, change plan, cancel, restore (`orbit.billing.requestPurchase()`, `subscribeToPlan()`, `cancelSubscription()`, `restoreSubscription()`). |

A plugin that only displays pricing without taking payment needs `billing:read` only. See the billing section of the [SDK guide](/guides/sdk) for the full flow.

---

## Email: `email:send`

Send transactional email through the platform's email service, so your plugin's mail goes out through the store's verified sender identity (DKIM), suppression list, and warm-up handling instead of hand-rolled SMTP.

| Scope | Grants |
|---|---|
| `email:send` | `POST /v1/emails/send` (`to`, `subject`, `html`, optional `replyTo` and `attachments`: up to 3 items of `{filename, content, contentType}`, with `content` base64 encoded and 6 MiB (6,291,456 bytes) in total once decoded. `contentType` is required and must match the file name's extension: `.csv` `text/csv`, `.txt` `text/plain`, `.json` `application/json`, `.xml` `application/xml` or `text/xml`, `.pdf` `application/pdf`, `.zip` `application/zip`, `.xlsx` and `.xls` their Excel types). Sends from the store's verified sender when one is configured, falling back to the platform default address. |

Use it for merchant-facing notifications (sync reports, alerts) and store-branded customer messages your plugin owns. It is not a bulk-campaign API: sends inherit the platform's suppression and rate handling.

---

## Outbound webhooks: `webhook:*`

Subscribe to store events for delivery to your plugin's own HTTPS endpoint. Most plugins that subscribe at runtime request all four scopes so they can manage their own subscriptions through the API. To subscribe to a topic you also need that topic's resource scope (for example, `product:read` for `product.*`); see the [Webhooks guide](/guides/webhooks).

| Scope | Grants |
|---|---|
| `webhook:list` | `GET /v1/webhooks`. List your subscriptions. |
| `webhook:read` | `GET /v1/webhooks/{id}/deliveries`. Recent delivery history for a subscription (useful for debugging). |
| `webhook:create` | `POST /v1/webhooks`. Subscribe to a topic at runtime. |
| `webhook:delete` | `DELETE /v1/webhooks/{id}`. Unsubscribe. |

---

## Navigation: `navigation:*`

Storefront menus and the links inside them. One family covers the menu **and** its items: an item cannot exist without its menu, so there is no separate `navigation-item:*`. Menus have no draft state and the storefront resolves them by handle, which makes every write here live: a menu edit is read by shoppers immediately.

| Scope | Grants |
|---|---|
| `navigation:list` | `GET /v1/navigations` (paginated, ordered by title A-Z; filter with `keyword`). Menu items are not included; read one menu to get its tree. |
| `navigation:read` | `GET /v1/navigations/{id}`: one menu with its full nested item tree. |
| `navigation:create` | `POST /v1/navigations`. The body may carry the whole nested `items` tree, so a menu can arrive fully populated. If its handle is one the theme asks for (say `main-menu`), it fills the storefront header on the creating request. |
| `navigation:update` | `PATCH /v1/navigations/{id}` (retitle, or rename the handle, which is what a theme looks a menu up by), `POST /v1/navigations/{id}/items` (add a link), `PATCH /v1/navigations/{id}/items/{itemId}` (edit one), `POST /v1/navigations/{id}/items/{itemId}/move` (reorder/reparent). |
| `navigation:delete` | `DELETE /v1/navigations/{id}` and `DELETE /v1/navigations/{id}/items/{itemId}`. Hard deletes: there is no soft delete or restore path for menus, deleting a menu takes every item with it, and deleting a single item takes its entire submenu. The item delete deliberately requires this scope, not `navigation:update`: "edit my menus" must not include "destroy a submenu". |

---

## Blogs: `blog:*`

The containers a store's articles are filed under. Most stores have one ("News", seeded at store creation) and only need more for genuinely separate publications. A blog is not itself a storefront page (there is no `/blogs/{handle}` route), but a store's blogs are listed on shopper-reachable surfaces, so creating one is visible without any post inside it. Posts are a separate scope family ([blog posts](#blog-posts-blog-post) below), so granting "write my articles" does not grant "delete the container they live in".

| Scope | Grants |
|---|---|
| `blog:list` | `GET /v1/blogs` (paginated, ordered by title A-Z; filter with `keyword`). |
| `blog:read` | `GET /v1/blogs/{id}`. Includes `postCount`, the number of posts currently filed under the blog: the number that matters before deleting it. |
| `blog:create` | `POST /v1/blogs`. |
| `blog:update` | `PATCH /v1/blogs/{id}`. Renaming a blog changes what shoppers read: its title is printed on the card of every post filed under it in the storefront blog index. |
| `blog:delete` | `DELETE /v1/blogs/{id}` (soft delete). Deleting a blog silently takes every published post inside it off the storefront; the posts survive and return if the blog is restored from the dashboard. There is no hard delete on the public API. |

---

## Blog posts: `blog-post:*`

The articles themselves, with a full draft/publish lifecycle. A post is created as a draft, becomes shopper-visible only through the separately-scoped publish step, and (unlike a page) has no separate draft copy once published: editing a published post rewrites the live article.

| Scope | Grants |
|---|---|
| `blog-post:list` | `GET /v1/blog-posts` (paginated, most recently edited first; filter with `blogId`, `status`, `keyword`). |
| `blog-post:read` | `GET /v1/blog-posts/{id}`, including its body content. |
| `blog-post:create` | `POST /v1/blogs/{blogId}/posts`. Always creates a **draft**: a `status` or `publishedAt` in the body is dropped, so a new post is on no page a shopper can reach until the publish step runs. |
| `blog-post:update` | `PATCH /v1/blog-posts/{id}`. On a published post this edits the live article in the same request; renaming the `handle` moves its public URL (`/blog/{handle}`) and creates an automatic redirect. |
| `blog-post:publish` | `POST /v1/blog-posts/{id}/publish` and `POST /v1/blog-posts/{id}/unpublish`: the only handlers on the posts controller that change what the storefront serves. |
| `blog-post:delete` | `DELETE /v1/blog-posts/{id}` (soft delete, restorable from the dashboard). Deliberately not reachable through `blog-post:update`. |

---

## Media: `media:*`

The store media library: the images and files the dashboard's media manager holds.

| Scope | Grants |
|---|---|
| `media:read` | `GET /v1/media` (paginated, newest first; filter with `keyword`, matched against the filename), `GET /v1/media/{id}`. |
| `media:create` | `POST /v1/media`. Multipart upload, one file per request under the field name `file`; the upload is validated by its **content**, not the Content-Type header, and accepts images, audio, video, Lottie animations, glTF/USDZ models and PDF documents. The filename you send becomes the library display name and must carry a real extension. An optional `folder` text field (1 to 100 characters) files the upload in that top-level library folder, made on first use; the same name always means the same folder. |
| `media:delete` | `DELETE /v1/media/{id}`. The dashboard's delete: the file stops serving at once and there is no restore (it answers `204`). Nothing checks whether the file is in use, so check before deleting. |

`DELETE /v1/media/{id}` needs `media:delete`; removing a folder stays a dashboard action.

---

## Published files: `published-file:*`

Download files a plugin publishes into the store's Files (CSV or ZIP, up to 250 MB) at a CDN link that stays the same when the plugin replaces the file. The link holds a random token, so it cannot be guessed, but anyone who has it can download the file (`"visibility": "public-url"`): never publish private data there. Plugin scopes only: a store API key cannot hold them. See [Publishing downloadable files](/guides/published-files).

| Scope | Grants |
|---|---|
| `published-file:list` | `GET /v1/published-files` (paginated, by slug). Only the calling plugin's files for the store. |
| `published-file:read` | `GET /v1/published-files/{slug}`. |
| `published-file:create` | `POST /v1/published-files/{slug}/uploads` (start: a presigned upload URL for the exact size), then `POST /v1/published-files/{slug}/uploads/{uploadId}/complete` (publish at the fixed URL). Publishing to an existing slug replaces the file and keeps its URL. Files count towards the store's storage allowance. `POST /v1/published-files/{slug}/rotate-url` moves a file to a new unguessable URL and kills the old one. |
| `published-file:delete` | `DELETE /v1/published-files/{slug}`. The file goes to the store's trash and its link stops working. |

---

## SEO: `seo:read`

A read-only health overview of the store's search-engine surface.

| Scope | Grants |
|---|---|
| `seo:read` | `GET /v1/seo/overview`: sitemap entry counts and robots status. |

Redirects are their own resource family (next section), not part of `seo:read`.

---

## Redirects: `redirect:*`

URL redirects, the tool for moving or retiring storefront paths without stranding saved links. There is deliberately no `redirect:update`: a redirect is small enough that the correction path is delete and recreate.

| Scope | Grants |
|---|---|
| `redirect:list` | `GET /v1/redirects` (newest first, capped; `meta.hasMore` tells you when the store has more redirects than one response returns). |
| `redirect:create` | `POST /v1/redirects`. A redirect takes effect **immediately**; there is no draft or publish step. Paths are normalized before storage (lowercased, query string and fragment stripped, trailing slash removed) and chains are flattened: creating `/a` to `/b` while `/b` to `/c` exists stores `/a` to `/c` and repoints existing redirects that targeted `/a`. Read the response (the row as stored) rather than assuming your input was kept, and list first if anything depends on where existing redirects point. |
| `redirect:delete` | `DELETE /v1/redirects/{id}`. Re-breaks whatever link the redirect was rescuing. |

A redirect takes precedence over a live page at the same path, and nothing checks for that collision (the dashboard does not either), so confirm the source path is genuinely retired before creating one.

---

## Analytics: `analytics:read`

Read-only reporting, mirroring the dashboard's analytics payloads.

| Scope | Grants |
|---|---|
| `analytics:read` | `GET /v1/analytics/overview` (sales overview metrics with the returning-customer rate merged in), `GET /v1/analytics/sales-over-time` (time series), `GET /v1/analytics/top-products` (by revenue; `limit` defaults to 10). All three take a required `startDate`/`endDate` range and an optional `comparison` (default `previous_period`). |

---

## Staff directory: `staff:read`

The store's staff directory, read only: who is on the team, so a plugin that assigns work to people (a CRM's deal owners and task assignees, a help desk's agents) can show a name, keep an id and notify an email.

| Scope | Grants |
|---|---|
| `staff:read` | `GET /v1/staff` (paged, `?search=` on name or email, at most 100 a page) and `GET /v1/staff/{userId}`: `id`, `firstName`, `lastName`, `name`, `email`, `role` (the role's display name), `active` (false until an invited person has confirmed their address) and `isPrimaryOwner` (whether the person is the store's primary owner). Also the topic `team_member.removed`, sent when somebody leaves the team, so you can clear what you assigned to them. |

Nothing about what a person may do: no permissions, no rank, no invitations, no seats. `/v1/team`, which has those and writes who can get into the shop, is not available to plugins. A user id that is not on this store's team answers 404 with the same words as an id nobody has. On a dashboard session token the scope is present only for a user who may see the team themselves (Settings > Team); for a staff member who may not, `/v1/staff` answers `401` naming the missing scope, so handle that rather than treating it as an empty team.

---

## Companies: `company:*`

B2B buyer accounts: the company record itself, its users (buyers who sign in and place orders on the company's behalf), pending invitations, locations (billing/shipping addresses, each with its own assigned catalogue and payment term), and cost centres (the budget codes its order lines are charged to). Company users, invitations, locations and cost centres have no independent tenancy: a contact, an invitation, a location or a cost centre cannot outlive its company, so they read and write under `company:*` rather than a scope family of their own, the same way navigation items live under `navigation:*`.

`PATCH /v1/companies/{id}` also carries the company's credit, approval and checkout rules: see [Companies, buyers and locations](/guides/b2b-companies).

Every `/v1/companies` request also requires the store to hold the B2B feature. Without it, reads still work (an ERP that pulls after a downgrade keeps working), but writes are refused with `403 Forbidden` and body `{ "code": "FEATURE_FROZEN", "feature": "b2b" }`.

| Scope | Grants |
|---|---|
| `company:list` | `GET /v1/companies` (paginated; filter by `status`, exact-match `externalId`, free-text `keyword`, and `updatedAtMin`/`updatedAtMax` for incremental sync; `includeDeleted=true` adds deleted companies with their `deletedAt`; sort with `sortBy` (`name`, `status`, `createdAt` or `updatedAt`) and `sortDir`). With `company:read`, `GET /v1/company-users`: the users of every company added or changed in a window, paged, so a sync catches up on membership without reading each company. Removed users are listed only with `includeRemoved=true`, with `removedAt` set. |
| `company:read` | `GET /v1/companies/{id}`, which returns the company **with its locations and contacts, including each contact's linked customer record**, projected down to exactly `id`, `firstName`, `lastName` and `email` (the buyer's phone, notes, consent flags and unsubscribe token are never included). It is still a PII-bearing scope: a buyer's name and email are personal data. Grant it only to integrations that need contact detail. Also: `GET /v1/companies/{companyId}/users`, `GET /v1/companies/{companyId}/invitations`, `GET /v1/companies/{companyId}/locations`, `GET /v1/companies/{companyId}/locations/{locationId}`, `GET /v1/companies/{companyId}/cost-centres`, `GET /v1/companies/{companyId}/cost-centres/{costCentreId}` and `GET /v1/companies/{id}/statement`. With `company:list`, `GET /v1/company-users`. Also the topic scope for the `company.*`, `company_user.*`, `company_location.*` and `cost_centre.*` webhook topics. |
| `company:create` | `POST /v1/companies`. Paired with `company:update` on the upsert route below. |
| `company:update` | `PATCH /v1/companies/{id}` (including `status` to suspend/activate, and `mainContactId`); `PUT /v1/companies/by-external-id/{externalId}` (with `company:create`, see below); and every write on a company's users, invitations, locations, and cost centres: `POST .../users`, `PATCH`/`DELETE .../users/{contactId}`, `POST .../users/{contactId}/send-activation`, `POST .../invitations`, `POST .../invitations/{id}/resend`, `DELETE .../invitations/{id}`, `POST /v1/companies/{companyId}/locations`, `PATCH`/`DELETE /v1/companies/{companyId}/locations/{locationId}`, `PUT .../locations/by-external-ref/{externalRef}`, `POST .../cost-centres`, `PATCH`/`DELETE .../cost-centres/{costCentreId}`, `PUT .../cost-centres` and `PUT .../cost-centres/by-external-ref/{externalRef}`. Removing a company user is a `DELETE` that still requires `company:update`, not a separate delete scope, by the same subordinate-resource reasoning as `company:read` above. |
| `company:delete` | `DELETE /v1/companies/{id}` (soft delete). |

The two assignment routes under a location are the exception to `company:update`: `PATCH .../locations/{locationId}/catalog` is gated on `b2b-catalog:update` alone and `PATCH .../locations/{locationId}/payment-term` on `payment-term:update` alone, so neither also requires `company:update`.

`PUT /v1/companies/by-external-id/{externalId}` is an idempotent upsert keyed on your own ERP id: it requires **both** `company:create` and `company:update`, since it may do either. The path `externalId` always wins over one in the request body; it matches ignoring case and surrounding spaces, and a matched company takes the path's spelling. It returns `{ "company": { … }, "created": true }` on insert or `"created": false` on update. See the [B2B/ERP integration guide](/guides/b2b-erp-integration) for the full sync pattern.

Company users land **pending-activation**: `POST .../users` never accepts a password, and `sendActivationEmail` defaults to `false` so a bulk ERP import never surprise-emails a buyer. Set a `role` preset (`admin`, `buyer`, or `viewer`) for a quick grant, or send an explicit `permissions` object for the 11 individual flags; an explicit `permissions` always wins over `role`. Pass `mainContact: true` to also make the new user the company's main contact. To send (or resend) the set-password email later, once you're ready for the buyer to sign in, call `POST .../users/{contactId}/send-activation`. Each company user gets an account on this store's shop only, separate from any account the same email has on another store or an Orbit dashboard; an email this store already has an account for answers `409 Conflict` with `errorCode: "B2B_EMAIL_HAS_LOGIN"` (invite that person instead). An email whose account on this store was closed by its owner answers `409 Conflict` with `errorCode: "B2B_EMAIL_LOGIN_DELETED"` and `availableFrom`, the time from which the email can be used again (up to 30 days after the account was closed).

---

## Catalogues: `b2b-catalog:*`

Company-specific price books: per-product/variant price overrides, group pricing rules keyed on category, tag, brand, custom product type, or an individual product, and, for a restricted catalogue, the products a company is allowed to see and buy.

Every `/v1/catalogs` request also requires the store to hold the B2B feature; the same read-always, write-refused-when-frozen split as `company:*` applies (`403 Forbidden`, `{ "code": "FEATURE_FROZEN", "feature": "b2b" }`).

| Scope | Grants |
|---|---|
| `b2b-catalog:list` | `GET /v1/catalogs` (paginated; filter by `status`, free-text `keyword`). |
| `b2b-catalog:read` | `GET /v1/catalogs/rule-targets/custom-types` (the distinct merchant-defined product types a `custom_type` rule can target), `GET /v1/catalogs/{id}` (with its price overrides), `GET /v1/catalogs/{id}/prices`, `GET /v1/catalogs/{id}/products`, `GET /v1/catalogs/{id}/taxonomies` and `GET /v1/catalogs/{id}/categories` (the products, taxonomy branches and categories a restricted catalogue shows), `GET /v1/catalogs/{id}/rules`. Also the topic scope for the `b2b_catalog.created` / `b2b_catalog.updated` / `b2b_catalog.deleted` webhook topics. With a product scope and `customer:read`, also every `/v1/customers/{customerId}/catalog/*` route: what one customer may see and the trade prices they pay (see [Customer catalogue and prices](/guides/customer-catalog)). Those routes do not need the B2B feature. |
| `b2b-catalog:create` | `POST /v1/catalogs`. |
| `b2b-catalog:update` | `PATCH /v1/catalogs/{id}` (name, currency, status and `isRestricted`); `PUT /v1/catalogs/{id}/prices` (create or replace one product/variant price override) and `DELETE /v1/catalogs/{id}/prices/{priceId}`; `POST /v1/catalogs/{id}/products`, `DELETE /v1/catalogs/{id}/products/{productId}`, `PUT`/`DELETE /v1/catalogs/{id}/taxonomies/{taxonomyId}` and `PUT`/`DELETE /v1/catalogs/{id}/categories/{categoryId}` (what a restricted catalogue shows; both `PUT`s are idempotent); `POST /v1/catalogs/{id}/rules`, `PATCH /v1/catalogs/{id}/rules/{ruleId}`, `DELETE /v1/catalogs/{id}/rules/{ruleId}` (group pricing rules); and `PATCH /v1/companies/{companyId}/locations/{locationId}/catalog` (assigning a catalogue to a location; this scope alone gates the route, and `company:update` is not additionally required). |
| `b2b-catalog:delete` | `DELETE /v1/catalogs/{id}`. |

`isRestricted: true` on create means the company sees and buys **only** the catalogue's products: those added with `POST /v1/catalogs/{id}/products`, every product filed under a taxonomy added with `PUT /v1/catalogs/{id}/taxonomies/{taxonomyId}` or under any of its descendants, and every product of a category added with `PUT /v1/catalogs/{id}/categories/{categoryId}`. `false` (the default) means every store product is visible, at whatever price the catalogue's overrides and rules resolve to. The vendor dashboard's price-preview/price-check tooling is deliberately not exposed on the public surface.

---

## Payment terms: `payment-term:*`

Net-terms definitions (Net 30, a fixed due date and so on) that a company location can be assigned. The assignment drives the due date on every [B2B invoice](#b2b-invoices-b2b-invoicelist-b2b-invoiceread) an order under that location generates.

Every `/v1/payment-terms` request also requires the store to hold the B2B feature; same read-always, write-refused-when-frozen split (`403 Forbidden`, `{ "code": "FEATURE_FROZEN", "feature": "b2b" }`).

| Scope | Grants |
|---|---|
| `payment-term:list` | `GET /v1/payment-terms` (paginated; filter by `status`, free-text `keyword`). |
| `payment-term:read` | `GET /v1/payment-terms/{id}`. Also the topic scope for the `payment_term.created` / `payment_term.updated` / `payment_term.deleted` webhook topics. |
| `payment-term:create` | `POST /v1/payment-terms` with a `name`, unique in the store ignoring case (another term's name answers `409`), and a `type`. `net` and `external` (an account the store invoices from its own accounts system) require `netDays`: an invoice is due that many days after it is issued. `fixed` requires `dueDate`, the day every invoice on it is due. `receipt` (due when issued) and `fulfillment` (due once the order is fully fulfilled) take neither; a `netDays` or `dueDate` a type does not use is dropped. |
| `payment-term:update` | `PATCH /v1/payment-terms/{id}`, and `PATCH /v1/companies/{companyId}/locations/{locationId}/payment-term` (assigning a term to a location; this scope alone gates the route, and `company:update` is not additionally required). |
| `payment-term:delete` | `DELETE /v1/payment-terms/{id}`. |

---

## B2B invoices: `b2b-invoice:list` / `b2b-invoice:read`

Read-only accounts-receivable view of a store's B2B invoices: one is written for each order placed on payment terms, with the term's `termType`, and for each order placed on account, with `termType: "external"`. There is no public create, update, record-payment, or void route, so ERPs pull an invoice's state through this surface (or through the [`b2b_invoice.*` webhook topics](/guides/webhooks)) rather than minting one, and recording a payment or voiding stays a vendor-dashboard action.

Reads work regardless of B2B entitlement, the same as every other `company:*`-family surface: there simply is no write route here for the entitlement guard to refuse.

| Scope | Grants |
|---|---|
| `b2b-invoice:list` | `GET /v1/b2b-invoices` (paginated; filter by `status`, `companyId`, and `updatedAtMin`/`updatedAtMax` for incremental sync). |
| `b2b-invoice:read` | `GET /v1/b2b-invoices/{id}`. Also the topic scope for the `b2b_invoice.created` / `b2b_invoice.paid` / `b2b_invoice.voided` webhook topics. |

The response never includes `stripeInvoiceId`: that id must never leave the server. It does include `hostedInvoiceUrl`, the Stripe-hosted invoice page you can hand a buyer, but only when the invoice was sent through Stripe. An on-account invoice never is, so its `hostedInvoiceUrl` is always `null`; a payment-terms invoice has one once Stripe has issued it, which needs the store's Stripe account, and a fulfilment-term invoice waits until the order is fully fulfilled. When Orbit settles or voids the invoice, it closes the hosted invoice, so the link can no longer be paid.

---

## Consent-screen scopes

The two families below gate live `/v1` endpoints that **a store API key cannot hold**. Publishing a page or renaming the shop changes what every shopper sees, so these scopes are granted only on a screen where the merchant approves them by name: a plugin that requests them in its `oauth.scopes` gets them on the install consent screen, and an AI assistant gets them when the merchant connects it (with its own tiered read / safe-write / live-write approval). The dashboard's key scope picker does not offer them, and requesting one on a key is rejected with `400`.

### Pages: `page:*`

Storefront pages built with the Orbit page builder: draft/publish lifecycle, revision history, and the element catalog the page content is validated against.

| Scope | Grants |
|---|---|
| `page:list` | No endpoint enforces this scope today: listing pages requires `page:read` (below), so `page:list` currently gates nothing. The string is kept in the catalog for the day pages grow a separate list route; do not request it. |
| `page:read` | `GET /v1/pages` (meta only, no content; filter with `status`/`keyword`), `GET /v1/pages/{id}` (with its draft content), `GET /v1/pages/{id}/revisions`, plus the element catalog: `GET /v1/page-builder/elements` (compact index) and `GET /v1/page-builder/elements/{id}` (full prop schema for one element). |
| `page:create` | `POST /v1/pages`. Always creates a **draft** custom page: a `status` in the body is dropped, so the result is invisible to shoppers until the separately-scoped publish step runs. |
| `page:update` | `PATCH /v1/pages/{id}` (title, handle, and SEO metadata; note this edits a **published** page's live metadata, and blanking a live page's handle would unroute it), `PATCH /v1/pages/{id}/content` (replace the draft content; validated against the element catalog), `POST /v1/pages/{id}/revisions/{revId}/restore` (restore a revision onto the draft). |
| `page:publish` | `POST /v1/pages/{id}/publish`, `POST /v1/pages/{id}/unpublish` and `POST /v1/pages/{id}/set-homepage` (make the page the storefront's homepage): the handlers on the pages controller that change what the storefront serves. |
| `page:delete` | `DELETE /v1/pages/{id}` (soft delete; the page and its revision history are restorable from the dashboard). A live page is deleted in one step and leaves the storefront immediately. Built-in template pages, the homepage, and the default page for a type answer `409`. |

### Store settings: `store-settings:*`

Store-level configuration: what the store *is*, as opposed to `settings:*:store` above, which is a plugin's **own** per-store settings surface.

| Scope | Grants |
|---|---|
| `store-settings:read` | `GET /v1/store-settings`, one grouped read: `general` (order numbering, timezone, unit system), `information` (name, description, domain), `branding` (logo, favicon), and `currency` (primary plus enabled currencies). |
| `store-settings:update` | `PATCH /v1/store-settings`. Deliberately **narrower than the read**: it may write `information.name`, `information.description`, and the `general` keys (order-number prefix/suffix/format, timezone, unit system, weight and dimension units). The domain, both branding fields, the whole currency group, and the next order number are refused with `400`. |

---

## Product import: `import:*`

The `/v1/import` surface imports products from a CSV file in three steps: stage the file, check it, then apply it. Each step has its own scope, so a merchant can let a connection check a file without letting it change the catalogue. A store API key cannot hold any of them: the key scope picker does not offer them and the API rejects them with `400`. A plugin can request them, and the merchant consents at install; an AI assistant connection is granted them through the consent flow a merchant completes when connecting the assistant; see [MCP for merchants](/guides/mcp-merchant).

| Scope | Grants |
|---|---|
| `import:dry-run` | `POST /v1/import/uploads` (stage a CSV file and get an id for it; nothing in the catalogue changes) and `POST /v1/import/products/dry-run` (check a staged file, or a few rows sent inline, and report the problems row by row; nothing is written). |
| `import:apply` | `POST /v1/import/products` (import a staged file or inline rows; it runs as a background job and answers a job id). |
| `import:read` | `GET /v1/import/jobs/{id}` (the progress and result of an import the caller started). |

The file uses the columns in [CSV imports and exports](/guides/csv-imports#the-product-file).

---

## Using this page

1. Find the endpoint you need in the [API Reference](/api-reference).
2. Pick the matching scope from the tables above.
3. Declare it in your plugin's `oauth.scopes` (in the partner-dashboard plugin form, or via JSON import).
4. When a merchant installs your plugin, they see those scopes on the consent screen.
5. After install, your token's `scopes` claim contains everything you declared, and the API enforces that set on every call.

Request only the scopes you actually use: a smaller set is easier for merchants to approve and limits exposure if a token leaks. If you need something that isn't listed here, it isn't available on the public API yet; reach the team at developers@orbitcommerce.net rather than guessing a string, since any scope not in this catalog will never be granted.

---

## Versioning

These scope strings are version-independent: `/v1` and any future version share the same catalog. The OAuth handshake (`/oauth/*`) is rooted at the host (deliberately not under `/vN`), so your token flow never changes when the data API version bumps. New scopes are only ever added alongside the endpoints they protect; plugins that don't request a new scope are unaffected.

---

*Last updated: 2026-10-13.*
