# Mercur Documentation
Source: https://docs.mercurjs.com/home
The open-source enterprise marketplace platform. Composable, API-first, and AI-native, on infrastructure you own.
Mercur is the open-source enterprise marketplace platform. It gives operators
multi-vendor governance: sellers, offers, commissions, order splitting, and
payouts, with role-based access and an auditable change pipeline, on
infrastructure you own. Core commerce runs on the proven Medusa engine, so
products, pricing, carts, orders, and payments are mature from day one.
## Get started
Install Mercur, tour the panels, and watch a multi-vendor order flow.
Governance, ownership, and composability without a closed platform.
How the marketplace platform is architected, module by module.
Version-matched docs, agent skills, and the MCP server.
## Explore the platform
Each marketplace capability is its own module, with concepts, server guides, and a
data-model reference.
Govern who sells, with role-based access and an account lifecycle.
One shared master catalog that every store sells against.
A seller's listing against a master product, with its own price and stock.
A typed attribute catalog that also drives variant axes.
The auditable change pipeline behind every catalog edit.
How a single cart splits into per-seller orders.
Policy-based rules and per-order commission lines.
Split settlement and provider-driven payouts.
Product and seller reviews with moderation.
## Build
Extend the server and both panels the Mercur way.
Stripe Connect and installable blocks.
Deploy Mercur on your own infrastructure.
Install features as source code you own.
## API reference
Fully typed routes for every audience. Start with the
[API conventions](/references/api/conventions) for authentication, seller scoping,
and field selection.
Operator routes under /admin/\*.
Seller-scoped routes under /vendor/\*.
Storefront routes under /store/\*.
Widgets, custom fields, and custom pages.
# Architecture
Source: https://docs.mercurjs.com/learn/architecture
How the Mercur enterprise marketplace platform is built: its layers, building blocks, and how the pieces fit together.
Mercur is the open-source enterprise marketplace platform, built on
[Medusa](https://medusajs.com). It is composable, API-first, and AI-native, and
it runs on infrastructure you own.
Mercur is not a standalone application, and it is not something you assemble from
scratch. Medusa provides the commerce engine, such as products, pricing, carts,
orders, payments, and fulfillment. Mercur adds the marketplace layer on top:
sellers, commissions, order splitting, payouts, and a governed change pipeline,
along with an admin panel and a vendor portal. Operators run the marketplace with
role-based access control and an auditable change history, on a codebase they own
outright.
This page explains how the platform is structured and how its parts fit together.
## High-level architecture
Mercur is layered. Each layer owns one responsibility and talks only to the layer
beneath it, so you can reason about, extend, or replace any layer on its own.
```mermaid theme={null}
graph TD
subgraph Frontend Layer
A[Admin Panel]
B[Vendor Portal]
C[Storefront]
end
subgraph API Layer
D["/admin/*"]
E["/vendor/*"]
F["/store/*"]
end
subgraph Marketplace Layer - Mercur
G[Modules · Workflows · Links · Subscribers · Events]
end
subgraph Commerce Layer - Medusa
H[Products · Orders · Carts · Payments · Fulfillment]
end
I[(PostgreSQL)]
A --> D
B --> E
C --> F
D --> G
E --> G
F --> G
G --> H
H --> I
```
### Commerce layer
Medusa provides the core commerce engine: products, pricing, carts, orders,
payments, fulfillment, promotions, and inventory. Mercur does not replace any of
it. Mercur builds on top through Medusa's extension model, using custom modules,
links, workflows, and API routes. This is the one place the word framework
applies. Medusa is the commerce framework, and Mercur is the platform you run on
it.
### Marketplace layer
This is where Mercur's own code lives, packaged as the `@mercurjs/core` plugin. It
adds marketplace modules such as Seller, Commission, Offer, Payout, Product
Attribute, and Product Edit, plus the workflows that coordinate marketplace
operations like order splitting, product approvals, and commission calculation.
Links connect these modules to Medusa's core entities without modifying the
original models.
### API layer
Mercur exposes three sets of HTTP endpoints, one per audience.
| API | Path | Purpose |
| ---------- | ----------- | -------------------------------------------------------------------------------------- |
| **Admin** | `/admin/*` | Platform administration: manage sellers, configure commission rates, view payouts. |
| **Vendor** | `/vendor/*` | Seller operations: manage products, orders, fulfillment, shipping, inventory, payouts. |
| **Store** | `/store/*` | Storefront: browse sellers, manage carts, check out with order splitting. |
Each route is composed of a request handler, middleware, query configuration, and
Zod validators. The middleware is where access control lives, so every vendor
request is scoped to its own seller's data before the handler runs. See the
[API conventions](/references/api/conventions) for authentication and scoping.
### Panels and clients
Three interfaces consume the APIs.
* **Admin Panel:** a React application on Medusa UI. Operators approve sellers, set commission rates, and monitor payouts across the whole marketplace.
* **Vendor Portal:** a React application for sellers to manage products, orders, fulfillment, and payouts, scoped to their own store.
* **Storefront:** the customer-facing application. Build it with any frontend that consumes the Store API.
Both panels talk to the API through `@mercurjs/client`, a fully typed fetch
wrapper generated from the real route definitions, so requests and responses stay
in sync with the backend.
## Building blocks of the marketplace layer
The marketplace layer is assembled from four Medusa-native primitives. Together
they keep the platform composable: each piece is small, explicit, and replaceable.
### Modules
A module encapsulates the data models and business logic for one domain, such as
Seller or Commission. Each module is self-contained, with its own models, service,
and migrations. Modules never reference each other directly. They communicate
through links and workflows, which keeps domains decoupled.
[Learn about modules](/resources/best-practices/modules).
### Links
A link defines a relationship between a Mercur module and a Medusa core entity
without modifying either model. For example, the product-seller link connects a
Medusa `Product` to a Mercur `Seller` and acts as the allowlist of who may sell
what. Dozens of links wire the marketplace layer into the commerce layer.
[Learn about module links](/resources/best-practices/module-links).
### Workflows
A workflow orchestrates a multi-step operation that spans modules. Workflows
support compensation, which rolls back automatically on failure, and hooks, which
are the extension points you inject custom logic into. The central one is
`completeCartWithSplitOrdersWorkflow`, which validates a cart, splits it by
seller, creates an order for each, allocates payment, and calculates commissions.
[Learn about workflows](/resources/best-practices/workflows).
### Subscribers and events
Workflows emit events. Subscribers listen and run asynchronous side effects, such
as sending notifications, calling webhooks, or transferring payouts. This keeps
the core workflows focused while the platform reacts to change.
[Learn about subscribers and jobs](/resources/best-practices/subscribers-and-jobs).
## Enterprise governance by design
Governance lives in the architecture, not in a bolt-on. The same primitives that
make the platform composable also make it governable.
* **Role-based access control.** `withMercur()` registers a roles module, so vendor requests are scoped to their own seller by default. Operators and sellers each see only what their role permits.
* **An auditable change pipeline.** Every product edit is captured as an immutable `ProductChange` record: who changed what, and who approved it. Low-risk edits auto-confirm, and the rest wait for operator review.
* **Financial accuracy.** All commission arithmetic uses BigNumber with arbitrary precision, so split payments and payouts stay exact to the cent.
* **A governed surface for AI agents.** The typed client, exposed workflows, and `llms.txt` give AI agents structured contracts to build against, inside the same role and review guardrails as human users. Agents extend the platform. They do not bypass its governance.
* **You own the deployment.** Mercur is MIT-licensed and runs on infrastructure you control. Blocks ship as source code, so you own every line, with no hosted vendor in the request path and no commission on gross merchandise value.
## How a multi-vendor order flows
A single customer cart can hold items from many sellers. Order splitting is where
the marketplace, commerce, commission, and payout layers work together.
1. **Customer adds items** from multiple sellers to one cart (Store API).
2. **Cart completion** triggers the split-order workflow (marketplace layer).
3. Items are **grouped by seller**, and a separate order is created for each (commerce and marketplace layers).
4. **Commission lines** are calculated per order from the matching rates (Commission module).
5. **Payment is split** proportionally across the seller orders (commerce layer).
6. Each seller's order is **credited to its payout account** after commission (Payout module).
7. **Events are emitted**, triggering notifications, webhook calls, and other side effects (subscribers).
8. Sellers **manage their orders** through the Vendor Portal (Vendor API).
9. The operator **monitors everything** through the Admin Panel (Admin API).
## Technology stack
| Layer | Technology |
| -------------------- | ------------------------------------------------- |
| Runtime | Node.js 20+, TypeScript |
| Commerce framework | Medusa v2 |
| Database | PostgreSQL |
| Frontend | React 18, React Router, Vite |
| Data fetching | TanStack React Query |
| UI components | Medusa UI, Radix UI |
| Form handling | React Hook Form, Zod |
| Tables | TanStack React Table |
| Build | Turborepo (monorepo), Bun (package manager), tsup |
| Internationalization | i18next |
## Core plugin layout
`@mercurjs/core` is the package that holds all marketplace logic. It is structured
as a standard Medusa plugin.
```
core/src/
├── modules/ # Data models and services
│ ├── seller/ # Seller registration, profiles, members, order groups
│ ├── commission/ # Commission rates, rules, calculation
│ ├── offer/ # Seller listings against the shared product catalog
│ ├── payout/ # Payout accounts, onboarding, payouts
│ ├── product-attribute/ # Typed attribute catalog and values
│ ├── product-edit/ # Product change requests and audit trail
│ └── ... # Media, custom fields, and more
├── links/ # Relationships between modules
├── workflows/ # Multi-step business processes
│ ├── seller/ # Seller lifecycle workflows
│ ├── cart/ # Cart completion with order splitting
│ ├── commission/ # Commission rate and line management
│ ├── payout/ # Payout processing and crediting
│ ├── offer/ # Offer lifecycle
│ ├── product/ # Product approval and seller linking
│ ├── product-edit/ # Change-request lifecycle
│ ├── order-group/ # Order group operations
│ └── ... # Attributes, shipping, inventory, promotions
├── api/ # HTTP route handlers
│ ├── admin/ # Admin API routes
│ ├── vendor/ # Vendor API routes
│ ├── store/ # Store API routes
│ └── hooks/ # Webhook handlers
├── subscribers/ # Event listeners
├── providers/ # Third-party provider integrations
└── jobs/ # Scheduled background tasks
```
## Distribution: blocks you own
Mercur ships features as blocks, not as an opaque dependency. The CLI copies
source code directly into your project, so a block is a self-contained piece of
functionality: a module, a workflow, an API route, or a UI extension.
This is what code ownership means in practice.
* **You own every line** of code in your project.
* **You can modify any block** to fit your business requirements.
* **There are no hidden abstractions** or version conflicts.
* **Updates are explicit.** You diff against the registry and apply the changes you want.
The CLI (`@mercurjs/cli@latest`) scaffolds projects, installs blocks, searches the
registry, and compares local changes against upstream.
## Workflow example
Workflows coordinate multi-step operations with automatic rollback on failure.
Here is a simplified example.
```typescript theme={null}
import {
createWorkflow,
createStep,
StepResponse,
WorkflowResponse,
} from "@medusajs/framework/workflows-sdk"
import { MercurModules } from "@mercurjs/types"
const validateSellerStep = createStep(
"validate-seller",
async ({ seller_id }: { seller_id: string }, { container }) => {
const sellerService = container.resolve(MercurModules.SELLER)
const seller = await sellerService.retrieveSeller(seller_id)
if (seller.status !== "open") {
throw new Error("Seller is not active")
}
return new StepResponse(seller)
}
)
const createProductForSellerWorkflow = createWorkflow(
"create-product-for-seller",
(input: { seller_id: string }) => {
const seller = validateSellerStep({ seller_id: input.seller_id })
// Additional steps: create product, link to seller, and so on.
return new WorkflowResponse({ seller })
}
)
```
## Design principles
These principles explain why the architecture looks the way it does.
* **Enterprise is the noun, composable is the how.** Mercur is a marketplace platform first. Composability, open source, and AI-nativeness are how it becomes a better enterprise choice than a closed platform, not a step down from one.
* **Modular over monolithic.** Each marketplace feature is a separate module you can install, modify, or replace on its own. You do not need all of Mercur to benefit from it.
* **Explicit over implicit.** Relationships are declared through links, not buried in service code. Workflows make multi-step operations visible and debuggable. API routes are file-based and predictable.
* **Extensible over configurable.** Instead of hundreds of config flags, Mercur gives you extension points. Workflows have hooks, providers are pluggable, and models extend through Medusa. When configuration is not enough, you change the source you own.
* **Commerce-aware.** Mercur does not reinvent commerce. It delegates products, pricing, orders, payments, and fulfillment to Medusa and focuses on the marketplace logic that multi-vendor systems need.
## Next steps
Data models, workflows, and events for each marketplace domain.
How features ship as source code you own, not an opaque dependency.
Authentication, seller scoping, and the Admin, Vendor, and Store APIs.
Extend the admin and vendor panels without forking them.
# Overview
Source: https://docs.mercurjs.com/learn/introduction
Set up Mercur, tour the operator and seller panels, and see how a multi-vendor order flows.
## What is Mercur
Mercur is the open-source enterprise marketplace platform. It gives a marketplace
operator real governance over sellers and their teams, onboarding, the catalog
change pipeline, commissions, order splitting, and vendor payouts. Role-based
access control, an auditable change pipeline, and per-seller settlement back all of
it, while it stays composable, API-first, and fully code-owned. You run it on your
own infrastructure.
Core commerce runs on the proven Medusa engine, so products, pricing, carts,
orders, fulfillment, and payments are mature and maintained from day one, and
Mercur focuses on the marketplace domain on top.
## Requirements
* [Node.js v20+](https://nodejs.org/en/download) (LTS)
* [Bun v1.3+](https://bun.sh) (recommended package manager)
* [Git](https://git-scm.com/downloads)
* PostgreSQL v14+
The quickest way to run PostgreSQL locally is with Docker:
```bash theme={null}
docker run -d --name mercur-postgres \
-e POSTGRES_PASSWORD=postgres \
-p 5432:5432 postgres:16
```
## Create a project
Create a new project with the CLI. It downloads a template, installs
dependencies, creates and migrates the database, seeds data, and starts the dev
server.
```bash theme={null}
bun create mercur-app@latest
```
The CLI prompts for a project name and a template (`basic` or `plugin`), then
handles the rest. Useful flags:
| Flag | Description |
| --------------------------------- | ------------------------------------- |
| `--template ` | Template to use (`basic` or `plugin`) |
| `--db-connection-string ` | Full PostgreSQL connection string |
| `--no-deps` | Skip dependency installation |
| `--skip-db` | Skip database setup |
## Open your marketplace
Once the dev server is running, the surfaces are available at:
| Surface | URL |
| ------------ | --------------------------------- |
| API | `http://localhost:9000` |
| Admin Panel | `http://localhost:9000/dashboard` |
| Vendor Panel | `http://localhost:9000/seller` |
You don't need to create any accounts to start. When the CLI finishes, it opens
the admin invite page at `http://localhost:9000/dashboard/invite` with a token
already filled in for `admin@mercur-test.com`. Set a password there and you are
signed in as the operator.
The seed also creates three approved demo sellers, so you can sign in to the
Vendor Panel right away. There is no registration or approval step to go through.
| Seller | Email | Password |
| -------------------- | ---------------------- | ------------- |
| Sole Society | `seller@mercur.dev` | `supersecret` |
| Kickz Corner | `kickz@mercur.dev` | `supersecret` |
| Trailhead Outfitters | `trailhead@mercur.dev` | `supersecret` |
To add a brand-new store instead, open the Vendor Panel and register at
`http://localhost:9000/seller/register`, then complete the onboarding wizard. The
new seller appears in the Admin Panel's approval queue.
Restart the dev server any time from your project directory:
```bash theme={null}
cd
bun dev
```
## Tour the platform
A Mercur project ships three role-based surfaces, one per audience. Each is backed
by the same marketplace modules and governed by role-based access.
### Admin Panel
The operator dashboard at `/dashboard`. This is where you run the marketplace.
* **Govern sellers:** approve, suspend, or terminate stores, and manage their members and roles.
* **Review catalog changes:** every seller edit lands in an approval queue as an attributed, immutable change you confirm or decline.
* **Set the economics:** configure commission rules across products, categories, and sellers.
* **Watch the money:** see orders across every seller and monitor payouts marketplace-wide.
### Vendor Panel
The seller portal at `/seller`, scoped so a seller only ever sees its own store.
* **List products:** create offers against the shared catalog with a seller's own SKU, price, inventory, and shipping.
* **Fulfill orders:** view, fulfill, and refund orders, and handle returns.
* **Get paid:** complete provider onboarding and track payouts.
* **Run a team:** invite members and assign roles.
### Store API
The storefront API under `/store/*` that your customer-facing frontend talks to.
It exposes marketplace discovery (sellers and offers) and a cart that can span
multiple sellers, then splits it into per-seller orders at checkout.
## How a multi-vendor order flows
The clearest way to see what the platform does is to follow one order from cart to
payout. A single customer cart can hold items from several sellers.
A customer adds offers from different sellers to a single cart through the Store API.
Completing the cart runs the split-order workflow. Items are grouped by seller, and a separate order is created for each, all linked under one order group with a shared display id.
For each order, Mercur resolves the matching commission rule and records the commission lines. All arithmetic uses arbitrary precision, so totals stay exact.
Payment is split across the per-seller orders, and each seller's earnings settle to their connected account through the payout provider, minus commission.
The operator sees the whole order group; each seller sees only its slice. Every
step is governed by the same roles and recorded for audit.
## Next steps
How the marketplace platform is architected, module by module.
Every marketplace capability, with its data models and workflows.
Bundled docs, agent skills, and the MCP server.
# Migration to 2.0
Source: https://docs.mercurjs.com/learn/migration-to-2-0
Port an existing Mercur 1.x project to 2.x, the latest release.
> A step-by-step guide to porting an existing Mercur 1.x project to 2.x, the latest release.
Mercur 2.0 replaces the monolithic plugin architecture (`@mercurjs/b2c-core`) with a block-based model (`@mercurjs/core` plus registry blocks). This guide is about porting your existing 1.x code to a 2.x project. For setting up a fresh project instead, see [Installation](/learn/introduction), which already uses the latest names.
## Before you start
Most users do not need to migrate much. Core and the official registry blocks cover most standard marketplace functionality, and the admin and vendor panels ship 34+ pages out of the box. You only need to port your own custom modules, workflows, routes, and any domain-specific dashboard pages that core does not already provide.
Here is what replaced what between 1.x and 2.x:
| 1.x | 2.x |
| -------------------------------------- | ------------------------------------------------------------------------ |
| `@mercurjs/b2c-core` (monolithic) | `@mercurjs/core`, all core modules built in |
| `@mercurjs/commission` (separate) | Built into core |
| `@mercurjs/algolia`, reviews, requests | Registry blocks (`mercurjs add ...`) |
| `@medusajs/admin-vite-plugin` | `@mercurjs/dashboard-sdk` (virtual modules) |
| `@medusajs/js-sdk` (manual hooks) | `@mercurjs/client` (generated typed client) |
| Custom admin and vendor pages | `@mercurjs/admin` and `@mercurjs/vendor`, complete panels out of the box |
| `apps/backend/` | `packages/api/` |
| `src/routes/` (admin) | `src/pages/` (file-based routing) |
| Yarn plus Turbo | bun (recommended) |
The MedusaJS v2 foundation (modules, workflows, links, subscribers, API routes), the data model patterns (MikroORM, service layer), and your environment variables (`DATABASE_URL`, CORS, secrets) are all unchanged.
**Using a version older than 1.4.0?** Your admin panel code lives inside the backend repo, not a separate app. When scanning for custom admin code to port, look there instead of `apps/admin/`. Everything else in this guide applies identically.
## Step 1: Start from a fresh 2.x project
Set up a working 2.x project first (see [Installation](/learn/introduction)), then port your 1.x code into it. Do not upgrade the old project in place.
## Step 2: Map your packages
Replace 1.x packages with their 2.x equivalents:
| 1.x package | 2.x equivalent |
| ---------------------------------- | -------------------------------- |
| `@mercurjs/b2c-core` | `@mercurjs/core` |
| `@mercurjs/commission` | Built into core |
| `@mercurjs/algolia` | Block: `mercurjs add algolia` |
| `@mercurjs/resend` | No 2.x equivalent, port manually |
| `@mercurjs/payment-stripe-connect` | No 2.x equivalent, port manually |
| `@mercurjs/stripe-tax-provider` | No 2.x equivalent, port manually |
| `@medusajs/admin-vite-plugin` | `@mercurjs/dashboard-sdk` |
| `@medusajs/js-sdk` | `@mercurjs/client` |
Several features that were separate packages in 1.x are now installed as registry blocks. Install these instead of porting their 1.x package code:
`reviews`, `requests`, `wishlist`, `team-management`, `algolia`, `vendor-notifications`, `vendor-chat`, `product-import-export`.
The `seller`, `payout`, and `commission` modules are built into core, so there is nothing to port for those.
## Step 3: Map your directories
| 1.x | 2.x |
| ------------------------- | ------------------------ |
| `apps/backend/src/*` | `packages/api/src/*` |
| `apps/admin/src/routes/` | `apps/admin/src/pages/` |
| `apps/vendor/src/routes/` | `apps/vendor/src/pages/` |
## Step 4: Port custom backend code
Copy each kind of custom code into `packages/api/src/` and update imports from `@mercurjs/b2c-core` to `@mercurjs/core`.
* **Modules**: copy to `packages/api/src/modules/` and register them in `medusa-config.ts`.
* **Workflows**: copy to `packages/api/src/workflows//`. Do not create barrel `index.ts` files, as they conflict with block installation.
* **API routes**: copy to `packages/api/src/api/`. Type both generics so codegen can read them, then run `bunx @mercurjs/cli@latest codegen`.
```typescript theme={null}
export const GET = async (
req: AuthenticatedMedusaRequest,
res: MedusaResponse
) => { ... }
```
* **Links and subscribers**: copy to `packages/api/src/links/` and `src/subscribers/`. Do not duplicate links that core already provides (seller to product, seller to order, and so on).
* **Custom providers**: copy to `packages/api/src/providers/`, then make two required changes:
```typescript theme={null}
// medusa-config.ts must use the ./src/ prefix
resolve: './src/providers/my-provider'
// provider index.ts must import from framework/utils
import { Modules, ModuleProvider } from "@medusajs/framework/utils"
```
## Step 5: Port custom dashboard code
Only needed if you have custom pages that core admin and vendor do not cover. Update imports and move pages from `src/routes/` to `src/pages/` with a `export default`.
| Old import | New import |
| ------------------------------------- | ---------------------------------------------------------------------- |
| `@medusajs/js-sdk` | `@mercurjs/client` |
| `@custom-types/*` | `@mercurjs/types` |
| `@hooks/*`, `@components/*`, `@lib/*` | Keep local, or `@mercurjs/dashboard-shared` if the symbol exists there |
## Step 6: Rename Order Set to Order Group
The 1.x `OrderSet` entity is renamed to `OrderGroup` in 2.x. This is a breaking change that affects database tables, API endpoints, workflow names, event names, and types.
| Aspect | 1.x | 2.x |
| ---------------- | --------------------------------------- | ----------------------------------------------------------- |
| Entity and table | `order_set` | `order_group` |
| ID prefix | `os_` | `og_` |
| API endpoints | `/admin/order-sets`, `/store/order-set` | `/admin/order-groups`, `/store/order-groups` |
| Workflows | `getFormattedOrderSetListWorkflow` | `getOrderGroupsListWorkflow`, `getOrderGroupDetailWorkflow` |
| Events | `OrderSetWorkflowEvents` | `OrderGroupWorkflowEvents` |
| Types | `OrderSetDTO` | `OrderGroupDTO` (from `@mercurjs/types`) |
Two fields were removed from the group:
* **`payment_collection_id`**: payment collections are now linked at the individual order level. Query the linked orders to get the payment collection.
* **`sales_channel_id`**: the sales channel is stored on each individual order.
Two fields are now computed at query time rather than stored: **`seller_count`** (distinct sellers across linked orders) and **`total`** (sum of order totals).
To migrate:
1. Update imports: `OrderSetDTO` to `OrderGroupDTO` (from `@mercurjs/types`).
2. Update API calls: `/order-sets` to `/order-groups`.
3. Update workflow references: `getFormattedOrderSetListWorkflow` to `getOrderGroupsListWorkflow`.
4. Update event listeners: `OrderSetWorkflowEvents` to `OrderGroupWorkflowEvents`.
5. If you read `payment_collection_id` or `sales_channel_id` from the order set, read them from the individual orders instead (via the `order_group_order` link).
See [Order Group](/platform/order-group/overview) for the full 2.x data model and API reference.
## Step 7: Upgrade to the latest release
After 2.0, the Medusa plugin was renamed from `@mercurjs/core-plugin` to `@mercurjs/core`. The package contents are the same. If you started from a current 2.x install, you are already on the new name and can skip this step.
### Swap the dependency
```bash theme={null}
bun remove @mercurjs/core-plugin
bun add @mercurjs/core
```
### Replace the name in config and source
In `packages/api/medusa-config.ts` and anywhere under `packages/api/src/**`, replace every occurrence of `@mercurjs/core-plugin` with `@mercurjs/core`. A repo-wide find-and-replace is safe. This applies to `resolve` values and imports alike:
```ts theme={null}
// before
resolve: "@mercurjs/core-plugin/modules/seller"
import { createSellerWorkflow } from "@mercurjs/core-plugin/workflows"
// after
resolve: "@mercurjs/core/modules/seller"
import { createSellerWorkflow } from "@mercurjs/core/workflows"
```
The same applies to `@mercurjs/core-plugin/modules/`, `/workflows`, `/links`, and `/api`. Installed registry blocks live under `packages/api/src/`, so the same find-and-replace covers them.
### Reinstall, migrate, and rebuild
```bash theme={null}
bun install
bun run medusa db:migrate
bun run build
```
The database migrations are non-destructive. If TypeScript still reports `@mercurjs/core-plugin`, you missed an import: re-run the find-and-replace.
## Known limitations
These areas do not currently have full 1.x parity and require manual migration for now:
* **`TaxCode`**: no 2.x equivalent today. Port the old logic manually if your project depends on it.
* **`SecondaryCategory`**: no 2.x equivalent, and none is planned. Port the old logic manually if your project depends on it.
## Next steps
Set up a fresh 2.x project to port your code into.
The full 2.x data model that replaced Order Set.
# Why Mercur
Source: https://docs.mercurjs.com/learn/why-mercur
Why teams choose Mercur to run an enterprise marketplace: governance, ownership, and composability without a closed platform.
The enterprise marketplace platform for teams that need governance, ownership, and
control, not a closed SaaS.
Mercur runs a multi-vendor marketplace on infrastructure you own. It gives an
operator the governance a marketplace needs (role-based access, an auditable
change pipeline, and per-seller settlement) while staying composable, API-first,
and AI-native. You get the control of an enterprise platform without the closed
code or a commission on gross merchandise value.
## Governance, built in
A marketplace operator has to control who sells, what they change, and how money
moves. Mercur builds that into the platform instead of bolting it on.
* **Role-based access control:** members belong to stores with roles resolved per store, and every vendor request is scoped to its own seller. Access to one store never leaks into another.
* **Auditable change pipeline:** every catalog edit is an immutable, attributed change with an approval queue, so you always know who changed what and who approved it.
* **Per-seller settlement:** policy-based commissions, order splitting, and provider-driven payouts settle each seller independently and to the cent.
## You own the platform
* **Own the code:** blocks ship as source you copy into your project. There are no black-box dependencies, and you can modify any line.
* **Own the deployment:** self-host on your own cloud, on-premise, or a private network, with no hosted vendor in the request path.
* **No lock-in:** Mercur is MIT-licensed, with no transaction fees and no commission on gross merchandise value.
## Composable, not monolithic
Closed platforms give you a fixed feature set behind a console. Mercur gives you
primitives.
* Install only the modules, workflows, and UI extensions you need.
* Extend core flows through hooks and the typed client, not by patching source.
* Replace any layer, such as search, notifications, or payouts, with your own.
Composable is not a step down from enterprise. It is how you make the platform fit
your business instead of the other way around.
## AI-native, enterprise-governed
Mercur is built so AI agents extend your marketplace within guardrails. Typed
contracts, build gates, and version-matched docs mean an agent's change is correct
by construction or it fails to compile. Agents work inside the same roles and
review pipeline as people. They extend the platform, they do not bypass its
governance.
## A proven commerce core
Mercur does not reinvent commerce. Products, pricing, carts, orders, and payments
run on the proven Medusa engine, so that layer is mature, maintained infrastructure
from day one. On top of it, Mercur delivers the full marketplace platform:
governance, multi-vendor orders, commissions, and payouts that a single-store
engine does not provide.
## Mercur vs closed marketplace platforms
Closed platforms such as Mirakl, VTEX, or Spryker deliver marketplace governance,
but behind proprietary code, a fixed runtime, and a commission on your GMV. Mercur
gives you the same operator governance with the opposite trade-offs.
| | Closed platform | Mercur |
| ------------- | ---------------------- | ---------------------------------- |
| Code | Proprietary and opaque | Open source, you own it |
| Hosting | Vendor-hosted | Self-hosted on your infrastructure |
| Extensibility | Configuration only | Composable modules and workflows |
| AI | Not agent-native | AI-native and governed |
| Pricing | Commission on GMV | No GMV fees, MIT-licensed |
## Who it's for
Mercur fits teams that need to run a real multi-vendor marketplace with operator
governance, but want to own the code and the infrastructure rather than rent a
closed platform.
## Next steps
Set up Mercur and run a marketplace locally.
How the marketplace platform is architected, module by module.
Every marketplace capability, with its data models and workflows.
Bundled docs, agent skills, and the MCP server.
# Attribute types
Source: https://docs.mercurjs.com/platform/attribute/concepts/attribute-types
The five attribute types and the values they hold.
In this document, you'll learn about the attribute record, its five types, and
the values attached to it.
## Product attribute
An attribute is a typed field in the shared catalog, represented by the
`ProductAttribute` data model (table `product_attribute`, id prefix `pattr`).
Each attribute carries a `name`, an optional `handle`, a `rank` for ordering, and
a `type` that decides how its values are validated and rendered.
```ts theme={null}
const { result } = await createProductAttributesWorkflow(container).run({
input: {
attributes: [
{
name: "Material",
type: "single_select",
values: [{ name: "Cotton" }, { name: "Wool" }],
},
],
},
})
```
The `type` field is one of the `AttributeType` enum values:
| Type | Value | Holds |
| ------------- | --------------- | --------------------------------------------------- |
| Single select | `single_select` | One choice from a fixed list of values |
| Multi select | `multi_select` | Several choices from a fixed list of values |
| Text | `text` | A free-form string |
| Unit | `unit` | A numeric measurement (e.g. weight, capacity) |
| Toggle | `toggle` | A boolean, backed by seeded `true` / `false` values |
## Product attribute value
The choices for select-style attributes are `ProductAttributeValue` records
(table `product_attribute_value`, id prefix `pattrval`). Each value belongs to
one attribute (`attribute_id`), has its own `name`, `handle`, and `rank`, and is
deleted along with its parent attribute.
`single_select` and `multi_select` attributes hold a list of predefined
`ProductAttributeValue` records. `text` and `unit` create a value on the fly
from the entered content when attached to a product. `toggle` is seeded with
its `true` / `false` values and never creates new ones.
Use `is_required` to enforce that a product must carry a value for the
attribute, and `is_active` to retire an attribute from new use without deleting
its history.
# Global vs inline & filtering
Source: https://docs.mercurjs.com/platform/attribute/concepts/global-vs-inline
Catalog-wide attributes, product-scoped attributes, and storefront filtering.
In this document, you'll learn the difference between global and inline
attributes and how an attribute becomes a storefront filter.
## Scope
Every `ProductAttribute` is either **global** or **inline**, decided by its
`product_id` field:
| Scope | `product_id` | Meaning |
| ------ | ------------ | ------------------------------------------------------------------------------------------------------------------- |
| Global | `null` | A catalog entry, reusable across any product, listed in the operator's attribute catalog |
| Inline | set | A one-off attribute scoped to a single product, created from that product's form and hidden from the global catalog |
```ts theme={null}
// Global: reusable across the catalog
{ name: "Material", type: "single_select", values: [{ name: "Cotton" }] }
// Inline: scoped to one product, created as it's attached
{ title: "Gift wrap", type: "toggle", product_id: "prod_123" }
```
A global attribute is defined once and attached to many products. An inline
attribute is created in the same step it is attached and only ever describes that
one product. It is useful for the occasional one-off field that doesn't belong in
the shared vocabulary.
Attributes describe products in the **shared master catalog**. They are never
owned by a store. A seller sells against a master product through an offer, and
the product's attributes come from the catalog, not from the seller.
## Filtering
An attribute with `is_filterable` set to `true` is exposed as a storefront
filter, letting shoppers narrow the catalog by its values. Leave it `false` for
descriptive-only attributes that shouldn't appear as facets.
Filtering pairs naturally with variant axes: a filterable `multi_select` axis
like Color both generates variants and lets shoppers filter by them. A
descriptive `text` attribute like Care instructions is usually left
non-filterable.
# Variant axes
Source: https://docs.mercurjs.com/platform/attribute/concepts/variant-axes
How is_variant_axis mirrors a native ProductOption and generates variants.
In this document, you'll learn how an attribute becomes the axis a product's
variants are generated from, and the mirror links that keep the two in sync.
## Variant-axis attribute
A `multi_select` attribute marked `is_variant_axis` is more than a descriptor.
It defines a dimension along which a product varies, such as Size or Color. When
such an attribute is created, the `ProductAttribute` model records the id of a native
Medusa `ProductOption` in its `product_option_id` field, and each of its
`ProductAttributeValue`s records the matching `ProductOptionValue` id in
`product_option_value_id`.
```ts theme={null}
await createProductAttributesWorkflow(container).run({
input: {
attributes: [
{
name: "Size",
type: "multi_select",
is_variant_axis: true,
values: [{ name: "S" }, { name: "M" }, { name: "L" }],
},
],
},
})
```
Because the attribute mirrors a real `ProductOption`, the values a product
selects along that axis are exactly what Medusa uses to generate its variants.
## Mirror links
The attribute catalog and Medusa's product options are two separate modules, so
the relationship is kept as a pair of **read-only mirror links**:
| Mirror | FK on the attribute side | Points to |
| -------------------- | ----------------------------------------------- | -------------------- |
| Attribute → option | `ProductAttribute.product_option_id` | `ProductOption` |
| Value → option value | `ProductAttributeValue.product_option_value_id` | `ProductOptionValue` |
Both are 1:1 and have no pivot table. The foreign key lives on the attribute
record itself.
The mirror links are **read-only**. They're resolved from the FK on the
attribute record; you never write the relationship through the link. The
workflows keep the option and the attribute in step whenever an axis attribute
or its values change.
Only `multi_select` attributes can be variant axes. The other four types
(`single_select`, `text`, `unit`, `toggle`) describe a product but never
generate variants.
# Attach attributes to a product
Source: https://docs.mercurjs.com/platform/attribute/guides/attach-attributes-to-a-product
Attach, detach, and update a product's attributes in one batch call.
In this guide, you'll learn how to manage all of a product's attributes from
server code through a single batch workflow.
Mercur exposes `createAndLinkProductAttributesToProductWorkflow`, the engine
behind the product attribute batch endpoint. One call can attach new attributes,
detach existing ones, and update selections, applied in the order
**remove → add → update** so a same-call remove and re-add of one attribute
resolves correctly.
## Run the batch workflow
```ts title="src/api/custom/products/[id]/attributes/route.ts" theme={null}
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
import { createAndLinkProductAttributesToProductWorkflow } from "@mercurjs/core/workflows"
export async function POST(req: MedusaRequest, res: MedusaResponse) {
await createAndLinkProductAttributesToProductWorkflow(req.scope).run({
input: {
product_id: req.params.id,
add: [
// Existing select attribute: link chosen values
{ id: "pattr_material", value_ids: ["pattrval_cotton"] },
// Existing text / unit / toggle attribute: set a scalar
{ id: "pattr_thread_count", value: 400 },
// Inline attribute created and attached in one step
{ title: "Gift wrap", type: "toggle", value: true },
],
remove: ["pattr_legacy_field"],
update: [{ id: "pattr_color", add: ["pattrval_blue"], remove: ["pattrval_red"] }],
},
})
res.sendStatus(200)
}
```
## The three operations
Each entry in `add` is one of the `ProductAttributeBatchAdd` forms:
| Form | Shape | Effect |
| ----------------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------- |
| Existing select / axis | `{ id, value_ids }` | Links the referenced values to the product |
| Existing text / unit / toggle | `{ id, value }` | `text`/`unit` create and link a value; `toggle` links the seeded `true`/`false` value |
| Inline axis | `{ title, values, is_variant_axis: true }` | Creates an exclusive option, a scoped attribute, and the value mirror |
| Inline non-axis | `{ title, type, value \| values }` | Creates a scoped attribute plus its value(s) and links them |
`remove` takes attribute ids to detach; `update` carries
`ProductAttributeBatchUpdate` entries adjusting an existing selection.
For a variant-axis attribute, `value_ids` is the per-product **subset** of the
mirror option's values. The product's variants are generated from exactly that
subset. See [Variant axes](/platform/attribute/concepts/variant-axes).
The batch workflow composes the lower-level
`addProductAttributesToProductWorkflow`,
`removeProductAttributesFromProductWorkflow`, and
`updateProductAttributesOnProductWorkflow`. Reach for those directly when you
only need one of the three operations.
# Create a variant axis
Source: https://docs.mercurjs.com/platform/attribute/guides/create-a-variant-axis
Generate product variants from a multi_select axis attribute.
In this guide, you'll learn how to create a variant-axis attribute and use it to
generate a product's variants from server code.
A variant axis is a `multi_select` attribute with `is_variant_axis` set. Creating
one mirrors a native Medusa `ProductOption`, so the values a product selects along
the axis become the dimensions Medusa uses to generate variants.
## Create the axis attribute
```ts title="src/api/custom/attributes/route.ts" theme={null}
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
import { createProductAttributesWorkflow } from "@mercurjs/core/workflows"
export async function POST(req: MedusaRequest, res: MedusaResponse) {
const { result } = await createProductAttributesWorkflow(req.scope).run({
input: {
attributes: [
{
name: "Size",
type: "multi_select",
is_variant_axis: true,
is_filterable: true,
values: [{ name: "S" }, { name: "M" }, { name: "L" }],
},
],
},
})
res.status(201).json({ attribute: result[0] })
}
```
The workflow creates the `ProductAttribute`, a mirror `ProductOption`, and a
`ProductAttributeValue` for each option value. It wires `product_option_id` and
`product_option_value_id` behind the scenes.
## Attach it to a product
Attach the axis to a product and pass the subset of values that product offers.
Medusa generates a variant for each selected value.
```ts theme={null}
import { createAndLinkProductAttributesToProductWorkflow } from "@mercurjs/core/workflows"
await createAndLinkProductAttributesToProductWorkflow(req.scope).run({
input: {
product_id: "prod_shirt",
add: [{ id: "pattr_size", value_ids: ["pattrval_s", "pattrval_m"] }],
},
})
```
Only `multi_select` attributes can be variant axes. The `value_ids` you pass
are the per-product subset of the axis's values. Only those become variants.
## Inline axes
To create a product-scoped axis in the same step it's attached, pass the inline
form instead of an existing id. This creates an exclusive `ProductOption`, a
scoped attribute (`product_id` set), and the value mirror in one call:
```ts theme={null}
await createAndLinkProductAttributesToProductWorkflow(req.scope).run({
input: {
product_id: "prod_shirt",
add: [{ title: "Cut", is_variant_axis: true, values: ["Slim", "Regular"] }],
},
})
```
Inline axes are ideal for a one-off dimension a single product needs. Reach for
a global axis attribute when the same dimension, such as Size or Color, recurs
across the catalog. See [Global vs inline](/platform/attribute/concepts/global-vs-inline).
# Create an attribute
Source: https://docs.mercurjs.com/platform/attribute/guides/create-an-attribute
Create a typed catalog attribute with createProductAttributesWorkflow.
In this guide, you'll learn how to create a global catalog attribute from your
own server code, for example in a seed script, a custom API route, or a catalog
import.
Mercur exposes a `createProductAttributesWorkflow` that creates one or more
`ProductAttribute` records along with their values. Run it from any place that
has access to the Medusa container.
## Run the workflow
```ts title="src/api/custom/attributes/route.ts" theme={null}
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
import { createProductAttributesWorkflow } from "@mercurjs/core/workflows"
export async function POST(req: MedusaRequest, res: MedusaResponse) {
const { result } = await createProductAttributesWorkflow(req.scope).run({
input: {
attributes: [
{
name: "Material",
type: "single_select",
is_filterable: true,
values: [{ name: "Cotton" }, { name: "Wool" }, { name: "Linen" }],
},
],
},
})
res.status(201).json({ attribute: result[0] })
}
```
The workflow creates the attribute, its `ProductAttributeValue` records, and,
for a `multi_select` axis, the mirror `ProductOption`. It also emits the
`product-attribute.created` event.
Leaving `product_id` unset creates a **global** attribute reusable across the
catalog. Passing a `product_id` creates an **inline** attribute scoped to a
single product. See [Global vs inline](/platform/attribute/concepts/global-vs-inline).
## Associate categories
Pass `category_ids` on an attribute to associate it with product categories
through the category link in the same call:
```ts theme={null}
await createProductAttributesWorkflow(req.scope).run({
input: {
attributes: [
{
name: "Thread count",
type: "unit",
category_ids: ["pcat_bedding"],
},
],
},
})
```
## Attach custom data
The workflow accepts an `additional_data` payload passed to its
`productAttributesCreated` hook, letting you persist marketplace-specific data
alongside the attribute without forking the workflow.
```ts theme={null}
await createProductAttributesWorkflow(req.scope).run({
input: {
attributes: [{ name: "Material", type: "single_select" }],
additional_data: { imported_from: "legacy-pim" },
},
})
```
# Attribute
Source: https://docs.mercurjs.com/platform/attribute/overview
An operator-managed, typed attribute catalog for describing and filtering the shared product catalog.
Use Mercur to define a typed vocabulary for your products, such as colors,
materials, sizes, capacities, or warranty toggles, and reuse it across the whole
marketplace.
The Attribute domain is an operator-managed catalog of typed fields that attach
to products in the shared master catalog. Attributes describe products
consistently and drive storefront filters. For variant axes, they generate the
product variants shoppers pick between. Every attribute is one of five types.
Each one is either a global catalog entry reused everywhere or an inline field
scoped to a single product.
**Attribute = the `ProductAttribute` entity.** The catalog is owned by the
Product Attribute module (`MercurModules.PRODUCT_ATTRIBUTE`, id prefix
`pattr`). Products are the shared master catalog. Attributes never belong to a
store. Sellers list against master products through offers.
## Key features
* **Five typed forms:** `single_select`, `multi_select`, `text`, `unit`, and `toggle`, each with its own validation and UI shape.
* **Variant axes:** a `multi_select` attribute marked `is_variant_axis` mirrors a native Medusa `ProductOption` and generates product variants.
* **Global or inline:** reuse a global catalog attribute across products, or attach a one-off attribute scoped to a single product.
* **Storefront filtering:** an `is_filterable` flag exposes an attribute as a shopper-facing facet.
* **Batch attach:** attach, detach, and update all of a product's attributes through a single engine workflow.
* **Ordered and governed:** `rank`, `is_active`, and `is_required` control ordering, availability, and required-field enforcement.
## Get started
Learn how the domain fits together.
The five attribute types and the values they hold.
How `is_variant_axis` mirrors a `ProductOption` and generates variants.
Catalog-wide attributes, product-scoped attributes, and filtering.
## Examples
Build against the Attribute domain in your own code.
Run `createProductAttributesWorkflow` from a route or seed script.
Attach, detach, and update attributes in one batch call.
Generate variants from a `multi_select` axis attribute.
## Resources
Data models, links, workflows, service methods, and events for the Attribute
domain.
The `ProductAttribute` and `ProductAttributeValue` entities.
How the Attribute domain links to products, categories, and options.
Catalog and product-attachment workflows.
Module service methods for working with records directly.
Events emitted as attributes and values change.
# Data models
Source: https://docs.mercurjs.com/platform/attribute/reference/data-models
The data models owned by the Attribute (Product Attribute) domain.
The Attribute domain is owned by the **Product Attribute module**. This reference
lists its data models and their fields. For the full module overview, see the
[Attribute module overview](/platform/attribute/overview).
## ProductAttribute
Table `product_attribute`, id prefix `pattr`. A typed catalog attribute.
| Field | Type | Notes |
| ------------------- | ------- | -------------------------------------------------------------------------- |
| `id` | text | Primary key |
| `name` | text | Searchable |
| `handle` | text | Nullable; unique when set |
| `description` | text | Nullable |
| `type` | enum | `AttributeType`: `single_select`, `multi_select`, `text`, `unit`, `toggle` |
| `is_required` | boolean | Default `false` |
| `is_filterable` | boolean | Default `false`; exposes the attribute as a storefront filter |
| `is_variant_axis` | boolean | Default `false`; `multi_select` axis that generates variants |
| `rank` | number | Default `0`; ordering |
| `is_active` | boolean | Default `true` |
| `created_by` | text | Nullable |
| `product_id` | text | Nullable; non-null = product-scoped (inline), null = global |
| `product_option_id` | text | Nullable; FK to the mirror `ProductOption` (axis attributes) |
| `metadata` | json | Nullable |
Relations: `values` (one-to-many `ProductAttributeValue`, deleted with the
attribute).
## ProductAttributeValue
Table `product_attribute_value`, id prefix `pattrval`. A selectable value that
belongs to one attribute.
| Field | Type | Notes |
| ------------------------- | ------- | ------------------------------------------------------------- |
| `id` | text | Primary key |
| `name` | text | The value label |
| `handle` | text | Nullable; unique per attribute when set |
| `rank` | number | Default `0`; ordering |
| `is_active` | boolean | Default `true` |
| `product_option_value_id` | text | Nullable; FK to the mirror `ProductOptionValue` (axis values) |
| `metadata` | json | Nullable |
Relations: `attribute` (belongs to `ProductAttribute` via `attribute_id`).
`product_option_id` on the attribute and `product_option_value_id` on the value
are the foreign keys behind the read-only mirror links to Medusa's product
options. See [Links](/platform/attribute/reference/links).
# Event reference
Source: https://docs.mercurjs.com/platform/attribute/reference/events
Events emitted by the Attribute domain, for subscribers and side effects.
The Attribute domain emits events as attributes and their values change.
Subscribe to them to run side effects, such as reindexing storefront filters,
syncing an external PIM, or kicking off follow-up workflows, instead of polling.
```ts title="src/subscribers/attribute-created.ts" theme={null}
import type { SubscriberArgs, SubscriberConfig } from "@medusajs/framework"
export default async function attributeCreatedHandler({
event,
container,
}: SubscriberArgs<{ id: string }>) {
const attributeId = event.data.id
// ...reindex filters, sync to an external system, etc.
}
export const config: SubscriberConfig = {
event: "product-attribute.created",
}
```
## Attribute events
| Event | Emitted when | Payload |
| --------------------------- | ----------------------- | -------- |
| `product-attribute.created` | An attribute is created | `{ id }` |
| `product-attribute.updated` | An attribute changes | `{ id }` |
| `product-attribute.deleted` | An attribute is deleted | `{ id }` |
## Value events
| Event | Emitted when | Payload |
| --------------------------------- | ------------------ | -------- |
| `product-attribute-value.created` | A value is created | `{ id }` |
| `product-attribute-value.updated` | A value changes | `{ id }` |
| `product-attribute-value.deleted` | A value is deleted | `{ id }` |
# Links to other modules
Source: https://docs.mercurjs.com/platform/attribute/reference/links
How the Attribute (Product Attribute) domain links to products, categories, and options.
Modules in Mercur never reference each other directly. They're connected through
**module links**. The Product Attribute module links into Medusa's product module
in several ways: to products, to categories, and, for variant axes, to product
options through mirror links. Once a link is defined, you retrieve related records
with `query.graph` using the link alias.
```ts theme={null}
const { data: products } = await query.graph({
entity: "product",
fields: ["id", "title", "scoped_attributes.*"],
})
```
## Products
| Link | Table | Relationship |
| --------------------------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------ |
| **Product → attribute** (`scoped_attributes`) | None | Read-only; a product's inline (product-scoped) attributes, resolved from `ProductAttribute.product_id` |
| **Product ↔ attribute value** | `product_attribute_value_link` | Many-to-many pivot; the values selected on a product |
## Categories
| Link | Table | Relationship |
| --------------------------------------- | ---------------------------- | -------------------------------------------------------------------- |
| **Attribute ↔ category** (`categories`) | `product_category_attribute` | Many-to-many; the product categories an attribute is associated with |
## Product options (mirror links)
| Link | FK | Relationship |
| ------------------------ | ----------------------------------------------- | ---------------------------------------------------------------------- |
| **Attribute → option** | `ProductAttribute.product_option_id` | Read-only 1:1; the mirror `ProductOption` for a variant-axis attribute |
| **Value → option value** | `ProductAttributeValue.product_option_value_id` | Read-only 1:1; the mirror `ProductOptionValue` for an axis value |
The mirror links (Attribute → option, Value → option value) and the product
scope link (`scoped_attributes`) are **read-only**. They're resolved from the
FK on the owning record and can't be written through the link itself. The
workflows keep the mirror in sync when axis attributes and values change.
# Service reference
Source: https://docs.mercurjs.com/platform/attribute/reference/service
The Product Attribute module service, with methods for working with records directly.
The Product Attribute module exposes a service you can resolve from the Medusa
container to read and write records directly, without going through a workflow.
Use it inside custom services, subscribers, or scheduled jobs.
```ts theme={null}
import { MercurModules } from "@mercurjs/types"
const attributeModuleService = container.resolve(
MercurModules.PRODUCT_ATTRIBUTE,
)
const [attributes, count] =
await attributeModuleService.listAndCountProductAttributes({
type: "single_select",
})
```
## Generated methods
Each data model gets a standard set of auto-generated methods. For
`ProductAttribute`:
| Method | Description |
| -------------------------------------------------- | ---------------------------------- |
| `createProductAttributes(data)` | Create one or more attributes |
| `retrieveProductAttribute(id, config?)` | Retrieve an attribute by id |
| `listProductAttributes(filters?, config?)` | List attributes matching filters |
| `listAndCountProductAttributes(filters?, config?)` | List attributes with a total count |
| `updateProductAttributes(data)` | Update one or more attributes |
| `deleteProductAttributes(ids)` | Delete one or more attributes |
The same set exists for `ProductAttributeValue`: `createProductAttributeValues`,
`retrieveProductAttributeValue`, `listProductAttributeValues`,
`listAndCountProductAttributeValues`, `updateProductAttributeValues`, and
`deleteProductAttributeValues`.
Prefer [workflows](/platform/attribute/reference/workflows) for anything with
side effects, such as creating an axis attribute, attaching attributes to a
product, or keeping the mirror `ProductOption` in sync. The service writes records
directly and does **not** emit events, maintain the option mirror, or run
compensation.
# Workflows
Source: https://docs.mercurjs.com/platform/attribute/reference/workflows
Attribute catalog and product-attachment workflows.
This reference lists the workflows for the Attribute domain. Import them from
`@mercurjs/core/workflows` and run them against the Medusa container.
## Catalog workflows
Manage the attribute catalog and its values.
| Workflow | Input | Purpose |
| -------------------------------------- | ------------------------------------ | ------------------------------------------------------ |
| `createProductAttributesWorkflow` | `{ attributes[], additional_data? }` | Create attributes (+ values, + mirror option for axes) |
| `updateProductAttributesWorkflow` | `{ selector, update }` | Update attribute fields |
| `deleteProductAttributesWorkflow` | `{ ids[] }` | Delete attributes (fails if still linked) |
| `createProductAttributeValuesWorkflow` | `{ values[], additional_data? }` | Add values to attributes |
| `updateProductAttributeValuesWorkflow` | `{ selector, update }` | Update values |
| `deleteProductAttributeValuesWorkflow` | `{ ids[] }` | Delete values |
| `upsertProductAttributeValuesWorkflow` | `{ attribute_id, values[] }` | Create or update an attribute's values in one call |
## Product-attachment workflows
Attach attributes to products in the shared master catalog.
| Workflow | Input | Purpose |
| ------------------------------------------------- | ---------------------------------------- | ----------------------------------------------------------------------- |
| `createAndLinkProductAttributesToProductWorkflow` | `{ product_id, add?, remove?, update? }` | Batch engine: attach, detach, and update, applied remove → add → update |
| `addProductAttributesToProductWorkflow` | `{ product_id, add[] }` | Attach attributes (existing or inline) to a product |
| `removeProductAttributesFromProductWorkflow` | `{ product_id, ... }` | Detach attributes from a product |
| `updateProductAttributesOnProductWorkflow` | `{ product_id, ... }` | Update a product's attribute selections |
`createAndLinkProductAttributesToProductWorkflow` is the engine behind the
product attribute batch endpoint; it composes the three single-purpose
attachment workflows. See
[Attach attributes to a product](/platform/attribute/guides/attach-attributes-to-a-product).
To work with records directly instead of through a workflow, see the
[Service reference](/platform/attribute/reference/service). To run side effects
when an attribute changes, see the
[Event reference](/platform/attribute/reference/events).
# Master products
Source: https://docs.mercurjs.com/platform/catalog/concepts/master-products
The shared catalog, why products aren't seller-owned, and submission attribution.
In this document, you'll learn how Mercur models products as a single shared
catalog rather than per-seller listings.
## Product
A product is a **master product** in a catalog shared by the whole marketplace,
represented by Medusa's native `Product` data model (table `product`, id prefix
`prod`). Products carry the usual commerce fields such as `title`, `handle`,
`description`, `status`, variants, options, and images, and are **not owned by
any store**. Creating a product adds it to the shared catalog. Multiple stores
can then sell the same master product.
```ts theme={null}
const { result } = await createProductsWorkflow(container).run({
input: {
products: [
{
title: "Aeron Chair",
status: "proposed",
seller_ids: ["sel_123"],
},
],
created_by: "usr_123",
},
})
```
A store never sells a bare master product directly. It sells against one by
creating an [offer](/platform/offer/overview). The offer carries the store's
own SKU, price, inventory, and shipping profile, while the master product
holds the shared catalog data everyone shares.
## Attribution, not ownership
Because the catalog is shared, the creator of a product does **not** own it.
When a store submits a new product, Mercur records the submission as an immutable
audit entry (a `PRODUCT_ADD` action in the product-change pipeline) so you know
who proposed it. That attribution is for review and history only. Once
published, the product belongs to the shared catalog like any other.
Attribution is recorded automatically by `createProductsWorkflow` through its
`created_by` input. You don't manage it by hand. See the
[status lifecycle](/platform/catalog/concepts/status-lifecycle) for how a
submission becomes a published catalog product.
# The store allowlist
Source: https://docs.mercurjs.com/platform/catalog/concepts/product-seller-allowlist
The product_seller link that controls which stores may sell a master product.
In this document, you'll learn how Mercur controls which stores are allowed to
sell a shared master product.
## Product seller
Since the catalog is shared, Mercur needs a way to say *which* stores may sell a
given master product. That's the **product–seller allowlist**: a many-to-many
link between Medusa's `Product` and Mercur's `Seller`, stored in the
`product_seller` table.
```ts theme={null}
const { data: products } = await query.graph({
entity: "product",
fields: ["id", "title", "sellers.id", "sellers.name"],
filters: { id: "prod_123" },
})
```
A store appears in a product's `sellers` list only if it has been allowlisted.
Being on the allowlist is what lets a store create an
[offer](/platform/offer/overview) against the master product. Without it, the
store cannot list that product for sale.
The allowlist link is created automatically for the submitting store when a
product is created with `seller_ids`, and managed afterwards with
`linkSellersToProductWorkflow`. See
[Allowlist stores](/platform/catalog/guides/allowlist-stores).
## Category allowlist
Product categories carry the same pattern through the `category_seller` link. It
is a many-to-many association between `ProductCategory` and `Seller` that scopes
which categories a store is associated with. Manage it with
`linkSellersToProductCategoryWorkflow`.
The allowlist governs the **right to sell**, not the sale itself. A store on
the allowlist still has to create an offer to actually list the product.
Allowlisting alone doesn't put anything on the storefront.
# Status lifecycle
Source: https://docs.mercurjs.com/platform/catalog/concepts/status-lifecycle
How a master product moves from draft to proposed, published, or rejected.
In this document, you'll learn about the product status lifecycle and how a
submission becomes a live catalog product.
## Status
A product's state is held in the `status` field of the `Product` model, typed by
Medusa's `ProductStatus` enum. A master product moves through four statuses:
```
┌────────┐ submit ┌──────────┐ approve ┌────────────┐
│ draft │ ──────────►│ proposed │ ──────────►│ published │
└────────┘ └────┬─────┘ └────────────┘
│ reject
▼
┌────────────┐
│ rejected │
└────────────┘
```
| Status | Meaning |
| ----------- | ------------------------------------------------------------ |
| `draft` | Work in progress, not yet submitted for review |
| `proposed` | Submitted, awaiting operator review |
| `published` | Approved and live in the shared catalog, sellable via offers |
| `rejected` | Turned down during review |
Vendor-created products default to **`proposed`**. A store submits a product
for review rather than publishing it directly. An operator (or a low-risk
auto-confirm rule) is what promotes it to `published`.
## Transitions
Each review transition has a dedicated Mercur workflow so the audit trail,
events, and side effects run consistently:
| Workflow | Transition |
| ------------------------------ | --------------------------------------------------- |
| `confirmProductsWorkflow` | `proposed` → `published` |
| `rejectProductWorkflow` | `proposed` → `rejected` |
| `requestProductChangeWorkflow` | stays `proposed`, asks the submitter for a revision |
Every transition validates that the product is currently `proposed` before it
runs, and records an immutable `STATUS_CHANGE` (or `CHANGE_REQUESTED`) action in
the product-change pipeline for a full history of who reviewed what.
Reviewing a product doesn't touch offers. Publishing makes the master product
sellable, but each store still lists it independently through its own
[offer](/platform/offer/overview).
# Variants, categories & collections
Source: https://docs.mercurjs.com/platform/catalog/concepts/variants-categories-collections
The native Medusa Product structure the shared catalog is built on.
In this document, you'll learn about the structural models that organize the
shared catalog, all of them native to Medusa's Product module.
## Product variant
A variant is a purchasable configuration of a master product, represented by the
`ProductVariant` data model (table `product_variant`, id prefix `variant`).
Variants are generated from a product's options, including Mercur attributes
marked as variant axes, and hold the SKU-level structure of the catalog entry.
```ts theme={null}
const { data: products } = await query.graph({
entity: "product",
fields: ["id", "title", "variants.id", "variants.title", "options.*"],
filters: { id: "prod_123" },
})
```
Offer-based inventory and pricing live on the [offer](/platform/offer/overview),
not the variant. The variant defines the shared catalog structure. Each store's
stock and price come from its own offer.
## Categories & collections
Products are organized with Medusa's native grouping models:
| Model | Purpose |
| ------------------- | -------------------------------------------------------- |
| `ProductCategory` | Hierarchical, nestable classification (id prefix `pcat`) |
| `ProductCollection` | Flat, curated grouping (id prefix `pcol`) |
| `ProductTag` | Free-form labels for filtering |
| `ProductType` | A single type classification per product |
Assign products to a category with `assignProductsToCategoryWorkflow`, which
keeps each product in exactly one category at a time.
Categories participate in the marketplace layer too: the `category_seller`
link scopes which stores a category is associated with, mirroring the product
[allowlist](/platform/catalog/concepts/product-seller-allowlist).
# Allowlist stores
Source: https://docs.mercurjs.com/platform/catalog/guides/allowlist-stores
Grant and revoke a store's right to sell a master product from server code.
In this guide, you'll learn how to control which stores may sell a shared master
product by managing the `product_seller` allowlist from your own server code.
A store can only create an [offer](/platform/offer/overview) against a master
product if it's on that product's allowlist. Mercur exposes
`linkSellersToProductWorkflow` to add and remove stores in a single call.
## Add and remove stores
```ts title="src/api/custom/allowlist/route.ts" theme={null}
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
import { linkSellersToProductWorkflow } from "@mercurjs/core/workflows"
export async function POST(req: MedusaRequest, res: MedusaResponse) {
await linkSellersToProductWorkflow(req.scope).run({
input: {
id: req.params.id, // product id
add: ["sel_123", "sel_456"],
remove: ["sel_789"],
},
})
res.sendStatus(200)
}
```
`add` and `remove` are both optional. Pass either or both. Adding a store
that's already allowlisted is idempotent. Removing a store revokes its right
to sell the product going forward.
## Allowlist a category
Categories use the same shape through `linkSellersToProductCategoryWorkflow`,
scoping which stores a category is associated with:
```ts theme={null}
import { linkSellersToProductCategoryWorkflow } from "@mercurjs/core/workflows"
await linkSellersToProductCategoryWorkflow(container).run({
input: {
id: "pcat_123",
add: ["sel_123"],
},
})
```
Allowlisting grants the **right to sell**, not the listing itself. After a
store is allowlisted, it still has to create an offer for the product to
appear on its storefront.
# Create a master product
Source: https://docs.mercurjs.com/platform/catalog/guides/create-a-master-product
Create a master product programmatically with createProductsWorkflow.
In this guide, you'll learn how to add a product to the shared catalog from your
own server code, for example in a seed script, a custom API route, or an import
flow.
Mercur exposes a `createProductsWorkflow` that creates the `Product` record,
attaches attributes and variants, records the submission for audit, and
optionally allowlists the submitting store. Run it from any place that has access
to the Medusa container.
## Run the workflow
```ts title="src/api/custom/route.ts" theme={null}
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
import { createProductsWorkflow } from "@mercurjs/core/workflows"
export async function POST(req: MedusaRequest, res: MedusaResponse) {
const { result } = await createProductsWorkflow(req.scope).run({
input: {
products: [
{
title: "Aeron Chair",
status: "proposed",
seller_ids: ["sel_123"],
},
],
created_by: req.auth_context.actor_id,
},
})
res.status(201).json({ product: result[0] })
}
```
Passing `seller_ids` allowlists those stores for the product as it's created,
so the submitting store can immediately create an
[offer](/platform/offer/overview) against it. `created_by` records who
submitted the product for the audit trail.
## Default status
A product created without an explicit `status` follows the marketplace's review
flow. Vendor-created products default to **`proposed`**, submitted for operator
review rather than published outright. Set `status: "draft"` to keep a product
private until it's ready, or `status: "published"` from a trusted operator flow
to skip review. See the
[status lifecycle](/platform/catalog/concepts/status-lifecycle).
## Attach custom data
The workflow accepts an `additional_data` payload passed to its hooks, letting
you persist marketplace-specific data alongside the product without forking the
workflow.
```ts theme={null}
await createProductsWorkflow(req.scope).run({
input: {
products: [{ title: "Aeron Chair", seller_ids: ["sel_123"] }],
created_by: "usr_123",
additional_data: { source: "supplier-feed" },
},
})
```
# Publish or reject a product
Source: https://docs.mercurjs.com/platform/catalog/guides/publish-or-reject-a-product
Move a proposed master product to published or rejected from server code.
In this guide, you'll learn how to review a submitted product from your own
server code. Each transition has a dedicated workflow so the audit trail, events,
and side effects run consistently.
A product enters review as `proposed`. From there you can publish it, reject it,
or ask the submitter for a revision. Every workflow validates that the product is
currently `proposed` before it runs.
## Publish a product
Move one or more `proposed` products to `published` with
`confirmProductsWorkflow`:
```ts title="src/api/custom/publish/route.ts" theme={null}
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
import { confirmProductsWorkflow } from "@mercurjs/core/workflows"
export async function POST(req: MedusaRequest, res: MedusaResponse) {
await confirmProductsWorkflow(req.scope).run({
input: {
product_ids: [req.params.id],
actor_id: req.auth_context.actor_id,
internal_note: "Looks good",
},
})
res.sendStatus(200)
}
```
## Reject a product
```ts theme={null}
import { rejectProductWorkflow } from "@mercurjs/core/workflows"
await rejectProductWorkflow(container).run({
input: {
product_id: "prod_123",
message: "Images don't match the description",
actor_id: "usr_123",
},
})
```
## Request a revision
To send the submission back for changes without rejecting it, use
`requestProductChangeWorkflow`. The product stays `proposed` and a
`CHANGE_REQUESTED` action is recorded for the submitter to act on:
```ts theme={null}
import { requestProductChangeWorkflow } from "@mercurjs/core/workflows"
await requestProductChangeWorkflow(container).run({
input: {
product_id: "prod_123",
message: "Please add a size variant",
actor_id: "usr_123",
},
})
```
These workflows require the product to be `proposed`. Running them against a
`draft`, `published`, or `rejected` product fails validation rather than
forcing the transition.
## React to review outcomes
To run your own side effects when a product is published or rejected, subscribe
to the events these workflows emit rather than polling. See the
[Event reference](/platform/catalog/reference/events) for the event names.
# Catalog
Source: https://docs.mercurjs.com/platform/catalog/overview
Master products, variants, categories, and the store allowlist that governs who may sell what.
Use Mercur to govern one shared product catalog and control which stores are
allowed to sell what.
Master-data governance lives here. The Catalog domain keeps products as a single
shared source of truth, controls access to it through an allowlist over who may
sell each product, and holds every submission to an approval lifecycle before it
goes live. On top of that governance layer it holds master products, variants,
categories, collections, tags, and types. A store never owns a product. It sells
against a shared master product through an [offer](/platform/offer/overview).
**Catalog = Medusa's Product module + Mercur's marketplace layer.** Products,
variants, categories, and collections are Medusa's native `Product` module.
Mercur adds the `product_seller` allowlist (which stores may sell a product),
the `draft` → `proposed` → `published` / `rejected` status lifecycle, and its
own product workflows. Products are **shared master products**, not
seller-owned, and a store sells one via an [offer](/platform/offer/overview).
## Key features
* **Shared master data:** products live in one catalog, not owned by any store. Creating a product adds it to the shared catalog.
* **Allowlist access control:** the `product_seller` link governs which stores may sell a given master product.
* **Approval governance:** a `draft` → `proposed` → `published` / `rejected` lifecycle, with vendor-created products defaulting to `proposed`.
* **Attribution, not ownership:** the creator of an unreviewed submission is recorded for audit, but the product still belongs to the shared catalog.
* **Native Medusa structure:** variants, categories, collections, tags, and types come straight from Medusa's Product module.
* **Sold via offers:** a store lists a master product by creating an offer that carries its SKU, price, and inventory.
## Get started
Learn how the domain fits together.
The shared catalog, why products aren't seller-owned, and submission attribution.
The `product_seller` link that controls who may sell a product.
Draft, proposed, published, and rejected, plus how products move between them.
Variants, categories, collections, tags, and types from Medusa's Product module.
## Examples
Build against the Catalog domain in your own code.
Run `createProductsWorkflow` from a route or seed script.
Grant and revoke a store's right to sell a product in code.
Move a proposed product to published or rejected.
## Resources
Data models, workflows, service methods, and events for the Catalog domain.
The `Product`, `ProductVariant`, `ProductCategory`, and the `product_seller` table.
How the catalog links to sellers, offers, attributes, and media.
Mercur's product create, review, and allowlist workflows.
Medusa's Product module service, resolved with `Modules.PRODUCT`.
Events emitted as products are created and reviewed.
# Data models
Source: https://docs.mercurjs.com/platform/catalog/reference/data-models
The Medusa Product models the catalog is built on, plus Mercur's marketplace layer.
The Catalog domain is owned by **Medusa's Product module**, with a marketplace
layer added by Mercur. This reference lists the models at the Mercur-relevant
level. It doesn't restate every Medusa product field. For the full module, see
the [Medusa Product module](https://docs.medusajs.com/resources/commerce-modules/product).
## Product
Table `product`, id prefix `prod`. The shared master product, not owned by any
store. The marketplace-relevant fields:
| Field | Type | Notes |
| -------------------------- | ---- | --------------------------------------------------------------------------------- |
| `id` | text | Primary key |
| `title` | text | Searchable |
| `handle` | text | Unique |
| `subtitle` / `description` | text | Nullable |
| `status` | enum | `ProductStatus`: `draft` / `proposed` / `published` / `rejected`, default `draft` |
| `thumbnail` | text | Nullable |
| `metadata` | json | Nullable |
Relations used by the marketplace: `variants`, `options`, `categories`,
`collection`, `tags`, `type`, `images` (native Medusa), plus Mercur's `sellers`
(the allowlist), `offers`, `scoped_attributes`, and `changes` (audit history).
## product\_seller
The **allowlist**: a many-to-many link table between `Product` and `Seller`
controlling which stores may sell a master product.
| Column | References |
| ------------ | ------------ |
| `product_id` | `product.id` |
| `seller_id` | `seller.id` |
A store on this table can create an [offer](/platform/offer/overview) against the
product; a store not on it cannot.
## ProductVariant
Table `product_variant`, id prefix `variant`. A purchasable configuration of a
master product, generated from its options and variant-axis attributes. Holds the
SKU-level catalog structure. Offer-scoped price and inventory live on the offer,
not here.
## ProductCategory
Table `product_category`, id prefix `pcat`. Hierarchical, nestable
classification. Participates in the marketplace layer through the
`category_seller` allowlist and the `media_images` link.
## ProductCollection
Table `product_collection`, id prefix `pcol`. Flat, curated grouping of products,
with a `media_images` link for collection artwork.
`ProductTag` and `ProductType` round out the native grouping models: free-form
labels and a single type classification per product, respectively. They carry
no Mercur-specific columns.
# Event reference
Source: https://docs.mercurjs.com/platform/catalog/reference/events
Events emitted by the Catalog domain, for subscribers and side effects.
The Catalog domain emits events as products are created and reviewed. Subscribe
to them to run side effects instead of polling, such as sending notifications,
syncing external systems, or reindexing search.
```ts title="src/subscribers/product-published.ts" theme={null}
import type { SubscriberArgs, SubscriberConfig } from "@medusajs/framework"
export default async function productPublishedHandler({
event,
container,
}: SubscriberArgs<{ id: string }>) {
const productId = event.data.id
// ...reindex search, notify the seller, etc.
}
export const config: SubscriberConfig = {
event: "product.published",
}
```
## Product events
| Event | Emitted when | Payload |
| -------------------------- | ---------------------------------------------- | ----------------------------- |
| `product.created` | A master product is created | `{ id }` |
| `product.published` | A proposed product is approved (`→ published`) | `{ id, internal_note? }` |
| `product.rejected` | A proposed product is rejected | `{ id, message? }` |
| `product.change-requested` | A revision is requested on a proposed product | `{ id, message?, actor_id? }` |
These are the marketplace lifecycle events emitted by Mercur's product
workflows. Medusa's Product module also emits its own native events (e.g.
`product.updated`, `product-variant.created`) for lower-level changes. See the
[Medusa events reference](https://docs.medusajs.com/resources/events-reference).
# Links to other modules
Source: https://docs.mercurjs.com/platform/catalog/reference/links
How the catalog links to sellers, offers, attributes, and media.
Modules in Mercur never reference each other directly. They connect through
**module links**. The Catalog (Medusa's `Product` module) is wired into the
marketplace layer with a set of links defined in Mercur core. Once a link is
defined, you retrieve related records with `query.graph` using the link alias.
```ts theme={null}
const { data: products } = await query.graph({
entity: "product",
fields: ["id", "title", "sellers.*", "offers.*", "changes.*"],
})
```
## Marketplace
| Linked module | Relationship |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Seller** | Many-to-many allowlist (`product_seller`) that controls which stores may sell a master product. Categories carry the same via `category_seller`. |
| **Offer** | A product has many offers (`offer.product_id`, read-only). Offers are how a store sells against the master product. |
| **Product change** | A product has many change records (`product.changes`, read-only): the immutable submission and review audit trail. |
## Attributes
| Linked module | Relationship |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Product attribute** | Product-scoped attributes resolve under `product.scoped_attributes` (read-only). Categories link attributes through `product_category_attribute`. |
## Media
| Linked module | Relationship |
| ------------- | ----------------------------------------------------------------------------------------------------- |
| **Media** | Categories and collections link their artwork through the `media_images` alias (a `MediaImage` list). |
Read-only links (Offer, Product change, scoped attributes) are resolved from
the field on the owning record and can't be written through the link itself.
The `media_images` alias is deliberately **not** the bare `images` alias. That
would shadow the native `Product.images` relation and break product queries.
# Service reference
Source: https://docs.mercurjs.com/platform/catalog/reference/service
Medusa's Product module service: methods for working with records directly.
The catalog is backed by **Medusa's Product module**, not a Mercur-specific one.
Resolve its service from the container with the `Modules.PRODUCT` key to read and
write products, variants, categories, and collections directly, without going
through a workflow. Use it inside custom services, subscribers, or scheduled jobs.
```ts theme={null}
import { Modules } from "@medusajs/framework/utils"
const productModuleService = container.resolve(Modules.PRODUCT)
const [products, count] = await productModuleService.listAndCountProducts({
status: "published",
})
```
Because this is Medusa's own module, the service key is `Modules.PRODUCT` from
`@medusajs/framework/utils`, **not** a `MercurModules` key. Mercur adds its
marketplace behavior (allowlist, review lifecycle, audit trail) as
[workflows](/platform/catalog/reference/workflows) layered on top of this
service.
## Generated methods
Each data model gets a standard set of auto-generated methods. For `Product`:
| Method | Description |
| ----------------------------------------- | -------------------------------- |
| `createProducts(data)` | Create one or more products |
| `retrieveProduct(id, config?)` | Retrieve a product by id |
| `listProducts(filters?, config?)` | List products matching filters |
| `listAndCountProducts(filters?, config?)` | List products with a total count |
| `updateProducts(data)` | Update one or more products |
| `deleteProducts(ids)` | Delete one or more products |
The same set exists for every model in the module, such as `ProductVariant`,
`ProductCategory`, `ProductCollection`, `ProductTag`, and `ProductType` (e.g.
`createProductVariants`, `listProductCategories`, `updateProductCollections`).
Prefer [workflows](/platform/catalog/reference/workflows) for anything with
side effects, such as submissions, review transitions, and allowlist changes. The
service writes records directly and does **not** run the marketplace layer,
emit Mercur's product events, or record the audit trail.
# Workflows
Source: https://docs.mercurjs.com/platform/catalog/reference/workflows
Mercur's product create, review, and allowlist workflows.
This reference lists the Mercur workflows for the Catalog domain. Import them
from `@mercurjs/core/workflows` and run them against the Medusa container. They
wrap Medusa's native product flows to add the marketplace layer: the store
allowlist, the review lifecycle, and the audit trail.
## Product workflows
| Workflow | Input | Purpose |
| ------------------------------ | ---------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| `createProductsWorkflow` | `{ products[], created_by, additional_data? }` | Create master products, attach attributes/variants, record the submission, allowlist `seller_ids` |
| `confirmProductsWorkflow` | `{ product_ids[], actor_id?, internal_note? }` | `proposed` → `published` |
| `rejectProductWorkflow` | `{ product_id, message?, actor_id? }` | `proposed` → `rejected` |
| `requestProductChangeWorkflow` | `{ product_id, message?, actor_id? }` | Ask the submitter for a revision (stays `proposed`) |
## Allowlist & organization workflows
| Workflow | Input | Purpose |
| -------------------------------------- | ----------------------- | ------------------------------------------- |
| `linkSellersToProductWorkflow` | `{ id, add?, remove? }` | Add/remove stores on a product's allowlist |
| `linkSellersToProductCategoryWorkflow` | `{ id, add?, remove? }` | Add/remove stores on a category's allowlist |
| `assignProductsToCategoryWorkflow` | `{ id, add?, remove? }` | Assign products to a single category |
For plain create/update/delete of products, variants, and categories, Mercur
reuses Medusa's native core-flows (`createProductsWorkflow`,
`updateProductsWorkflow`, `createProductVariantsWorkflow`, and so on) under the
hood. The workflows above are the Mercur-specific entry points that add the
allowlist, review lifecycle, and audit history.
To work with records directly instead of through a workflow, see the
[Service reference](/platform/catalog/reference/service). To run side effects
when a product changes, see the
[Event reference](/platform/catalog/reference/events).
# Order commission lines
Source: https://docs.mercurjs.com/platform/commission/concepts/order-commission-lines
Per-order commission lines, how they're computed, and BigNumber precision.
This page covers how commission is recorded on an order and kept in sync as the
order changes.
## Commission line
A commission line is the resolved commission for a single order line. It's
represented by the `CommissionLine` data model (table `commission_line`, id
prefix `comline`). Each line anchors to either an item (`item_id`) or a shipping
method (`shipping_method_id`). It records the rate that matched
(`commission_rate_id`, `code`), the applied `rate`, and the computed `amount`.
```ts theme={null}
// A commission line, as written by the refresh workflow
{
item_id: "ordli_123",
shipping_method_id: null,
commission_rate_id: "comrate_123",
code: "standard-a1b2c3",
rate: 10,
amount: 250, // 10% of a 2500 subtotal
description: null,
}
```
Lines are generated automatically during checkout. When the cart is split into
per-seller orders, `refreshOrderCommissionLinesWorkflow` runs against each new
order. Shipping lines carry a `"Shipping Commission"` description. Item lines
carry none.
## Recomputed on change
Commission lines are **derived** data, not a one-time snapshot. The lines are
recomputed whenever an order's composition changes, such as when an order edit
is confirmed, a return is received, or a claim or exchange is created. That
keeps the seller's commission tracking what the customer actually kept.
The refresh is **idempotent**: it deletes any existing lines for the affected
items and shipping methods, then inserts the freshly computed set. Re-running it
never duplicates lines.
Computed lines carry no `id`. The module's `upsertCommissionLines` deletes by
anchor (`item_id` / `shipping_method_id`) before inserting, which is what
makes repeated refreshes safe.
## BigNumber precision
All commission arithmetic uses Medusa's `MathBN` (BigNumber). It's arbitrary
precision, so percentages and per-currency amounts never accumulate
floating-point error. A percentage amount is `subtotal × value ÷ 100`. A fixed
amount is the per-currency value (or the fallback `value`). Each is computed in
BigNumber before being stored.
Commission lines feed the payout pipeline: when a seller's payout is
calculated, the order's commission lines are read straight from the commission
module and deducted from the amount transferred to the seller.
# Rule matching
Source: https://docs.mercurjs.com/platform/commission/concepts/rule-matching
The five dimensions, most-specific-wins resolution, tie-breaks, and shipping.
This page covers how Mercur decides which commission rate applies to a given
order line.
## Commission rule
A commission rule scopes a rate to part of the catalog. It's represented by the
`CommissionRule` data model (table `commission_rule`, id prefix `comrule`). A
rule is a `reference` / `reference_id` pair that belongs to one rate. The
`reference` names the dimension, and the `reference_id` names the specific
record.
```ts theme={null}
await batchCommissionRulesWorkflow(container).run({
input: {
commission_rate_id: "comrate_123",
create: [
{ reference: "seller", reference_id: "sel_123" },
{ reference: "product_category", reference_id: "pcat_shoes" },
],
},
})
```
A rate with **no** rules is a catch-all that matches every line. A rate with
rules only matches lines that satisfy them.
## The five dimensions
A rule's `reference` is one of five dimensions, each resolved against the order
line's product:
| `reference` | Matches when |
| -------------------- | -------------------------------------------------------- |
| `product` | The line's product id equals `reference_id` |
| `product_type` | The product's type id equals `reference_id` |
| `product_collection` | The product's collection id equals `reference_id` |
| `product_category` | One of the product's categories equals `reference_id` |
| `seller` | The seller behind the line's offer equals `reference_id` |
Products are the shared master catalog. The `seller` dimension resolves
through the **offer** on the order line (`item.offer.seller_id`), not through
product ownership.
## Most-specific-wins
When several rates match a line, resolution is **AND across dimensions, OR
within a dimension**. Rules are grouped by `reference`. A rate matches only when
**every** group it defines has at least one matching rule. Among the matching
rates, the one scoped on the **most distinct dimensions** wins.
```
Rate A: seller = sel_123 (specificity 1)
Rate B: seller = sel_123 AND category = pcat_shoes (specificity 2) ← wins
```
Specificity is the count of **distinct dimensions** a rate scopes on, not the
number of rules. Two `product_category` rules on one rate still count as a
single dimension (they OR together).
## Tie-break
When two matching rates have equal specificity, the **oldest** rate wins. Rates
are evaluated `created_at` ascending, so the earliest-created rate is the
deterministic winner.
## Shipping commission
Item commission is resolved per line as above. Shipping is different: a
shipping method is commissioned **only** by the global rate, and only when its
`include_shipping` flag is on. No scoped rate can commission shipping.
`include_tax` is a separate, per-rate toggle. When on, the line's `tax_total`
is added to the base amount before the rate is applied. This holds for both
item and shipping commission.
# Rules & rates
Source: https://docs.mercurjs.com/platform/commission/concepts/rules-and-rates
The commission rate, its fixed and percentage forms, and per-currency amounts.
This page covers how a commission rate is modeled and the two ways it can
express the marketplace's cut.
## Commission rate
A commission rate is the number the marketplace takes from a sale. It's
represented by the `CommissionRate` data model (table `commission_rate`, id
prefix `comrate`). A rate has a `name`, a unique `code`, a `type`, and a
`value`, plus the `include_tax` and `include_shipping` toggles.
```ts theme={null}
const { result } = await createCommissionRatesWorkflow(container).run({
input: [
{
name: "Standard",
type: CommissionRateType.PERCENTAGE,
value: 10,
},
],
})
```
A rate is either **percentage** or **fixed**, set by `type`
(`CommissionRateType`):
| Type | How `value` is read |
| ------------ | ----------------------------------------------------- |
| `percentage` | A percent of the line's base amount (e.g. `10` → 10%) |
| `fixed` | A flat amount deducted per line |
A rate's `code` is unique. When you create a rate without one, the module
auto-generates a URL-safe code from the `name` (e.g. `"Standard"` →
`standard-a1b2c3`).
## Per-currency amounts
A fixed rate can carry a different amount for each currency. This is
represented by the `CommissionRateValue` data model (table
`commission_rate_value`, id prefix `comval`). Each value pairs a `currency_code`
with an `amount`, and the calculation picks the value matching the order's
currency.
```ts theme={null}
await createCommissionRatesWorkflow(container).run({
input: [
{
name: "Flat fee",
type: CommissionRateType.FIXED,
value: 5, // fallback when no per-currency value matches
values: [
{ currency_code: "usd", amount: 5 },
{ currency_code: "eur", amount: 4 },
],
},
],
})
```
When no `values` entry matches the order's currency, a fixed rate falls back
to its scalar `value`. Percentage rates ignore `values` entirely. A percent
is currency-independent.
## The global commission
Every marketplace has exactly one **Global Commission**. This is the rate with
`is_default` set to `true`. Mercur seeds it at boot (a `0%` percentage rate
named `Default`) so a rate always exists, and it applies whenever no
more-specific rate matches a line.
The global rate is also the **only** rate that can commission shipping. See
[Rule matching](/platform/commission/concepts/rule-matching) for how
specificity and shipping are resolved.
# Batch-update commission rules
Source: https://docs.mercurjs.com/platform/commission/guides/batch-update-rules
Create, update, and delete a rate's rules in a single call.
In this guide, you'll learn how to manage the rules that scope a commission rate
from your own server code, adding, changing, and removing them in one atomic
operation.
## Run the workflow
`batchCommissionRulesWorkflow` applies creates, updates, and deletes to a single
rate's rules in parallel. Pass the target `commission_rate_id` and any of the
`create`, `update`, and `delete` arrays.
```ts title="src/api/custom/rules/route.ts" theme={null}
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
import { batchCommissionRulesWorkflow } from "@mercurjs/core/workflows"
export async function POST(req: MedusaRequest, res: MedusaResponse) {
const { result } = await batchCommissionRulesWorkflow(req.scope).run({
input: {
commission_rate_id: "comrate_123",
create: [
{ reference: "seller", reference_id: "sel_123" },
{ reference: "product_category", reference_id: "pcat_shoes" },
],
update: [{ id: "comrule_456", reference_id: "pcat_boots" }],
delete: ["comrule_789"],
},
})
res.json(result)
}
```
The result groups the affected rules as `{ created, updated, deleted }`.
Every rule's `reference` must be one of the five dimensions: `product`,
`product_type`, `product_collection`, `product_category`, or `seller`. The
`reference_id` is the id of the specific record in that dimension.
## How scoping changes matching
Adding rules **narrows** a rate. Rules on the same dimension OR together. Rules
across dimensions AND together. The two `create` rules above make the rate match
only lines that are both from seller `sel_123` **and** in category `pcat_shoes`,
raising the rate's specificity to `2`.
Increasing a rate's specificity makes it win over less-specific rates on the
lines it matches. See
[Rule matching](/platform/commission/concepts/rule-matching) for how
most-specific-wins and tie-breaks resolve.
# Create a commission rate
Source: https://docs.mercurjs.com/platform/commission/guides/create-a-rate
Create a commission rate programmatically with createCommissionRatesWorkflow.
In this guide, you'll learn how to create a commission rate from your own server
code. This is useful in a seed script, a custom API route, or an onboarding flow.
Mercur exposes a `createCommissionRatesWorkflow` that creates one or more
`CommissionRate` records. Run it from any place that has access to the Medusa
container.
## Run the workflow
```ts title="src/api/custom/route.ts" theme={null}
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
import { createCommissionRatesWorkflow } from "@mercurjs/core/workflows"
import { CommissionRateType } from "@mercurjs/types"
export async function POST(req: MedusaRequest, res: MedusaResponse) {
const { result } = await createCommissionRatesWorkflow(req.scope).run({
input: [
{
name: "Standard",
type: CommissionRateType.PERCENTAGE,
value: 10,
},
],
})
res.status(201).json({ commission_rate: result[0] })
}
```
The workflow takes an **array** of rates and returns the created records. When
you omit `code`, the module generates a unique one from `name`.
## Create a fixed, per-currency rate
For a flat fee, use `CommissionRateType.FIXED` and pass per-currency `values`.
The scalar `value` is the fallback when no currency matches.
```ts theme={null}
await createCommissionRatesWorkflow(req.scope).run({
input: [
{
name: "Flat fee",
type: CommissionRateType.FIXED,
value: 5,
values: [
{ currency_code: "usd", amount: 5 },
{ currency_code: "eur", amount: 4 },
],
},
],
})
```
## Scope the rate
A rate created without rules is a catch-all. To scope it to part of the catalog,
attach rules with
[`batchCommissionRulesWorkflow`](/platform/commission/guides/batch-update-rules).
Only the global rate (`is_default`) may commission shipping. To let the global
rate take a cut of shipping, update it with `include_shipping: true` via
`updateCommissionRatesWorkflow`.
# Refresh order commission lines
Source: https://docs.mercurjs.com/platform/commission/guides/refresh-order-commission-lines
Recompute an order's commission lines after it changes.
In this guide, you'll learn how to recompute the commission lines for an order
from your own server code. Mercur already refreshes lines automatically at
checkout and on order changes. Reach for this workflow when you change an order
outside those paths, or when backfilling.
## Run the workflow
`refreshOrderCommissionLinesWorkflow` reads each order, resolves the matching
rate for every item and shipping method, and writes the resulting
`CommissionLine` records.
```ts title="src/api/custom/refresh/route.ts" theme={null}
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
import { refreshOrderCommissionLinesWorkflow } from "@mercurjs/core/workflows"
export async function POST(req: MedusaRequest, res: MedusaResponse) {
const { result } = await refreshOrderCommissionLinesWorkflow(req.scope).run({
input: { order_ids: [req.params.id] },
})
res.json({ commission_lines: result })
}
```
The workflow takes an array of `order_ids`, so you can refresh many orders in
one run. This is useful for backfilling after you change your commission
configuration.
## Idempotency
The refresh is a **delete-then-insert** for each affected item and shipping
method, so re-running it never duplicates lines. You can call it as often as you
need without cleaning up first.
## When it runs automatically
You rarely need to call this by hand. Mercur runs it for you:
* **At checkout:** as each per-seller order is created from the split cart.
* **On order changes:** a subscriber re-runs it when an order edit is
confirmed, or a return, claim, or exchange is created.
To hook your own logic into these moments, subscribe to the same order events
rather than re-running this workflow. See the
[Events reference](/platform/commission/reference/events).
# Commission
Source: https://docs.mercurjs.com/platform/commission/overview
Configure how the marketplace takes its cut of every seller order.
Use Mercur to keep financial control over what share of each sale the
marketplace keeps and each seller earns.
Financial control over the marketplace runs through commission. You set
policy-based **rates** (fixed or percentage) and scope them to parts of your
catalog with **rules**. Mercur resolves the right rate for every order line by
most-specific-wins and writes an auditable **commission line**. That line is the
exact amount deducted before the seller is paid out, computed in arbitrary
precision so the numbers reconcile.
**Commission = the `CommissionRate` + `CommissionRule` entities.** A rate is
the number, either a percentage or a fixed amount. Rules scope that rate to a
slice of the catalog. Every marketplace has one **Global Commission**. This is
the `is_default` rate that applies when nothing more specific matches.
## Key features
* **Fixed or percentage:** a percentage of the line, or a flat per-currency amount.
* **Per-currency amounts:** fixed rates carry an amount per currency, falling back to a default `value`.
* **Five scoping dimensions:** match on `product`, `product_type`, `product_collection`, `product_category`, and `seller`.
* **Most-specific-wins:** the rate scoped on the most dimensions wins, and ties break to the oldest rate.
* **Shipping commission:** only the global rate may commission shipping (`include_shipping`).
* **Automatic order lines:** a commission line is generated per item and recomputed when an order changes.
* **BigNumber arithmetic:** all commission math uses arbitrary precision for financial accuracy.
## Get started
Learn how the domain fits together.
Fixed vs. percentage rates, per-currency amounts, and the global rate.
The five dimensions, most-specific-wins, tie-breaks, and shipping.
How per-order lines are computed and kept in sync.
## Examples
Build against the Commission domain in your own code.
Run `createCommissionRatesWorkflow` from a route or seed script.
Create, update, and delete a rate's rules in one call.
Recompute commission for an order after it changes.
## Resources
Data models, workflows, service methods, and events for the Commission domain.
`CommissionRate`, `CommissionRule`, and related entities.
How Commission connects to catalog, sellers, and orders.
Rate, rule, and order-line workflows.
Module service methods for working with records directly.
How commission stays in sync with order changes.
# Data models
Source: https://docs.mercurjs.com/platform/commission/reference/data-models
The data models owned by the Commission domain.
The Commission domain is owned by the **Commission module**. This reference
lists its data models and their fields. For the full module overview, see the
[Commission overview](/platform/commission/overview).
## CommissionRate
Table `commission_rate`, id prefix `comrate`. The rate the marketplace takes
from a sale.
| Field | Type | Notes |
| ------------------ | --------- | ----------------------------------------------------------- |
| `id` | text | Primary key |
| `name` | text | Searchable |
| `code` | text | Unique, searchable; auto-generated from `name` when omitted |
| `type` | enum | `CommissionRateType`, `fixed` or `percentage` |
| `value` | bigNumber | Percent (for `percentage`) or fallback amount (for `fixed`) |
| `currency_code` | text | Nullable; when set, the rate applies only to that currency |
| `include_tax` | boolean | Default `false`; add `tax_total` to the base amount |
| `include_shipping` | boolean | Default `false`; only meaningful on the global rate |
| `is_enabled` | boolean | Default `true`; only enabled rates are evaluated |
| `is_default` | boolean | Default `false`; the single Global Commission |
Relations: `rules` (one-to-many `CommissionRule`), `values` (one-to-many
`CommissionRateValue`).
## CommissionRule
Table `commission_rule`, id prefix `comrule`. Scopes a rate to a slice of the
catalog.
| Field | Type | Notes |
| ----------------- | -------- | ----------------------------------------------------------------------------------------------- |
| `id` | text | Primary key |
| `reference` | text | The dimension: `product`, `product_type`, `product_collection`, `product_category`, or `seller` |
| `reference_id` | text | The id of the record in that dimension |
| `commission_rate` | relation | The rate this rule belongs to (`belongsTo`) |
## CommissionRateValue
Table `commission_rate_value`, id prefix `comval`. A per-currency amount for a
fixed rate.
| Field | Type | Notes |
| ----------------- | --------- | -------------------------------------------- |
| `id` | text | Primary key |
| `currency_code` | text | The currency this amount applies to |
| `amount` | bigNumber | The flat commission for that currency |
| `commission_rate` | relation | The rate this value belongs to (`belongsTo`) |
## CommissionLine
Table `commission_line`, id prefix `comline`. The resolved commission for one
order line.
| Field | Type | Notes |
| -------------------- | --------- | --------------------------------------------------- |
| `id` | text | Primary key |
| `item_id` | text | Nullable; the order line item this line commissions |
| `shipping_method_id` | text | Nullable; the shipping method this line commissions |
| `commission_rate_id` | text | Nullable; the rate that matched |
| `code` | text | The matched rate's code |
| `rate` | float | The applied rate |
| `amount` | bigNumber | The computed commission amount |
| `description` | text | Nullable; `"Shipping Commission"` on shipping lines |
A commission line anchors to **either** `item_id` **or** `shipping_method_id`.
It references those records by id, not through a module link.
# Event reference
Source: https://docs.mercurjs.com/platform/commission/reference/events
How the Commission domain stays in sync with order changes.
The Commission domain does **not** emit its own domain events. Commission lines
are derived data, so instead of broadcasting changes, the module **subscribes**
to order lifecycle events and recomputes lines whenever an order's composition
changes.
## Events it reacts to
Mercur ships a subscriber (`order-commission-refresh-handler`) that re-runs
`refreshOrderCommissionLinesWorkflow` for the affected order on each of these
events:
| Event | Emitted when |
| -------------------------------------- | -------------------------- |
| `OrderEditWorkflowEvents.CONFIRMED` | An order edit is confirmed |
| `OrderWorkflowEvents.RETURN_RECEIVED` | A return is received |
| `OrderWorkflowEvents.CLAIM_CREATED` | A claim is created |
| `OrderWorkflowEvents.EXCHANGE_CREATED` | An exchange is created |
Because the refresh is idempotent (delete-then-insert), reacting to several
events for the same order never duplicates lines.
## Run your own side effects
To run logic when an order's commission changes, subscribe to the same order
events the module listens to, then read the refreshed lines from the commission
module.
```ts title="src/subscribers/commission-changed.ts" theme={null}
import type { SubscriberArgs, SubscriberConfig } from "@medusajs/framework"
import { OrderWorkflowEvents } from "@medusajs/framework/utils"
export default async function commissionChangedHandler({
event,
container,
}: SubscriberArgs<{ order_id?: string; id?: string }>) {
const orderId = event.data.order_id ?? event.data.id
if (!orderId) {
return
}
// ...read commission lines for the order, notify, sync an external ledger, etc.
}
export const config: SubscriberConfig = {
event: OrderWorkflowEvents.RETURN_RECEIVED,
}
```
Commission is also refreshed at checkout, as each per-seller order is created
from the split cart. That refresh happens as a **step inside** the checkout
workflow rather than via a separate event.
# Links to other modules
Source: https://docs.mercurjs.com/platform/commission/reference/links
How the Commission domain connects to the catalog, sellers, and orders.
Most Mercur modules connect to each other through **module links** (`defineLink`)
resolved with `query.graph`. The Commission module is deliberately different: it
defines **no** module links. Instead it connects to the rest of the marketplace
through **soft references**. These are plain id fields resolved at calculation
time. This keeps commission configuration independent of the catalog and order
modules it scopes against.
## Rules → catalog & sellers
A `CommissionRule` points at a record in another domain through its
`reference` / `reference_id` pair, resolved against the order line's product when
commission is calculated:
| `reference` | Points at | Resolved from |
| -------------------- | ---------------- | ------------------------------ |
| `product` | A master product | `item.product.id` |
| `product_type` | A product type | `item.product.type_id` |
| `product_collection` | A collection | `item.product.collection_id` |
| `product_category` | A category | `item.product.categories[].id` |
| `seller` | A store | `item.offer.seller_id` |
Products are the shared master catalog, not seller-owned. The `seller`
dimension resolves through the **offer** on the order line, which carries the
selling store.
## Lines → orders
A `CommissionLine` records which order line it commissions through its
`item_id` (an order line item) or `shipping_method_id` (a shipping method).
These are stored as plain ids, so the payout pipeline reads an order's lines by
querying the commission module directly rather than traversing a link.
Because these are soft references, deleting a product, category, or order does
not cascade to commission records. Rules and lines simply stop matching or are
refreshed on the next order change.
# Service reference
Source: https://docs.mercurjs.com/platform/commission/reference/service
The Commission module service and its methods for working with records directly.
The Commission module exposes a service you can resolve from the Medusa
container to read and write records directly, without going through a workflow.
Use it inside custom services, subscribers, or scheduled jobs.
```ts theme={null}
import { MercurModules } from "@mercurjs/types"
const commissionModuleService = container.resolve(MercurModules.COMMISSION)
const [rates, count] = await commissionModuleService.listAndCountCommissionRates(
{ is_enabled: true }
)
```
## Generated methods
Each data model gets a standard set of auto-generated methods. For
`CommissionRate`:
| Method | Description |
| ------------------------------------------------ | ------------------------------------------------ |
| `createCommissionRates(data)` | Create one or more rates (auto-generates `code`) |
| `retrieveCommissionRate(id, config?)` | Retrieve a rate by id |
| `listCommissionRates(filters?, config?)` | List rates matching filters |
| `listAndCountCommissionRates(filters?, config?)` | List rates with a total count |
| `updateCommissionRates(data)` | Update one or more rates |
| `deleteCommissionRates(ids)` | Delete one or more rates |
The same set exists for every model in the module: `CommissionRule`,
`CommissionRateValue`, and `CommissionLine` (e.g. `createCommissionRules`,
`listCommissionRateValues`, `deleteCommissionLines`).
## Calculation & line methods
| Method | Description |
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------- |
| `getCommissionLines(context)` | Resolve the commission lines for a calculation context (items + shipping in a currency), most-specific-wins, without persisting |
| `upsertCommissionLines(lines)` | Replace lines by anchor (`item_id` / `shipping_method_id`), making refreshes idempotent |
Prefer [workflows](/platform/commission/reference/workflows) for order-line
refreshes and rate deletion. `refreshOrderCommissionLinesWorkflow` builds the
calculation context from the order and calls these methods for you;
`deleteCommissionRatesWorkflow` validates that a rate is safe to delete first.
# Workflows
Source: https://docs.mercurjs.com/platform/commission/reference/workflows
Commission rate, rule, and order-line workflows.
This reference lists the workflows for the Commission domain. Import them from
`@mercurjs/core/workflows` and run them against the Medusa container.
## Rate workflows
| Workflow | Input | Purpose |
| ------------------------------- | --------------------------- | ------------------------------------------- |
| `createCommissionRatesWorkflow` | `CreateCommissionRateDTO[]` | Create rates (auto-generates `code`) |
| `updateCommissionRatesWorkflow` | `UpdateCommissionRateDTO[]` | Update rate fields |
| `deleteCommissionRatesWorkflow` | `{ ids[] }` | Delete rates (validates deletability first) |
## Rule workflows
| Workflow | Input | Purpose |
| ------------------------------ | --------------------------------------------------- | ----------------------------------------------------- |
| `batchCommissionRulesWorkflow` | `{ commission_rate_id, create?, update?, delete? }` | Create, update, and delete a rate's rules in one call |
## Order-line workflows
| Workflow | Input | Purpose |
| ------------------------------------- | ----------------- | ------------------------------------------------------------- |
| `refreshOrderCommissionLinesWorkflow` | `{ order_ids[] }` | Recompute and upsert an order's commission lines (idempotent) |
`deleteCommissionRatesWorkflow` exposes a `commissionRatesDeleted` hook, and
`batchCommissionRulesWorkflow` runs its create / update / delete steps in
parallel. `refreshOrderCommissionLinesWorkflow` also runs as a step inside the
cart-split checkout workflow.
To work with records directly instead of through a workflow, see the
[Service reference](/platform/commission/reference/service). To run side effects
when an order changes, see the
[Event reference](/platform/commission/reference/events).
# Pricing & inventory
Source: https://docs.mercurjs.com/platform/offer/concepts/pricing-and-inventory
Offer-scoped prices on the shared price set, and inventory linked to the offer.
In this document, you'll learn how an offer carries its own price and inventory
without owning the master variant.
## Offer-scoped pricing
An offer's prices don't live on a private price set. They live on the **master
variant's shared `PriceSet`**, with each offer-owned row scoped by a
`PriceRule` on the `offer_id` attribute. That's how many stores price the same
variant independently: every price row Mercur writes for an offer is stamped with
that offer's id, and reads filter the set back down to just that offer's rows.
```ts theme={null}
// Every offer price row is written with an offer_id rule on the shared price set
prices: [
{
amount: 2500,
currency_code: "usd",
rules: { offer_id: "offer_123" },
},
]
```
The offer side reads its price ladder through the writable `offer ↔ price`
list-link, so `offer.prices` resolves in a single query traversal. Each row is a
standard Medusa money amount and supports `min_quantity` / `max_quantity` for
quantity-break pricing.
Because prices sit on the shared price set scoped by `offer_id`, the master
variant is never mutated per store. The variant keeps a single price set, and
the `offer_id` rule partitions it per offer.
## Offer-scoped inventory
An offer's stock is held in Medusa `InventoryItem` records that link to the
**offer**, not to the variant. The `offer ↔ inventory_item` link is a list-link
whose pivot table (`offer_inventory_item`) carries a `required_quantity` column,
so one offer can draw on several inventory items, each with its own required
quantity.
Offer inventory links to the **offer**, not the variant. `variant.inventory_items`
is empty for offer-based orders. Always resolve stock through
`offer.inventory_items`, never through the variant.
When you create an offer, its `inventory_items` entries each create a brand-new
`InventoryItem` (with optional starting `stock_levels`) and link it to the offer
in the same workflow run. An offer must have at least one inventory item.
```ts theme={null}
inventory_items: [
{
sku: "ACME-WIDGET-01",
required_quantity: 1,
stock_levels: [{ location_id: "sloc_1", stocked_quantity: 50 }],
},
]
```
The inventory items an offer creates are also linked to the store, so they show
up under the store's inventory. To change the set of items on an existing
offer, use the [batch inventory workflow](/platform/offer/guides/manage-offer-inventory).
# Shipping
Source: https://docs.mercurjs.com/platform/offer/concepts/shipping
How an offer carries its own shipping profile.
In this document, you'll learn how an offer determines how its items ship.
## Shipping profile
Every offer points at a store's own **shipping profile** through its
`shipping_profile_id` field, joined via the read-only `offer ↔ shipping_profile`
link. The profile is what ties the offer's items to the store's shipping options
at checkout, so each store fulfills its slice of a multi-seller cart with its own
rates.
```ts theme={null}
await createOffersWorkflow(container).run({
input: {
offers: [
{
seller_id: "sel_123",
created_by: "mem_123",
variant_id: "variant_123",
shipping_profile_id: "sp_123",
sku: "ACME-WIDGET-01",
prices: [{ amount: 2500, currency_code: "usd" }],
inventory_items: [{ stock_levels: [{ location_id: "sloc_1", stocked_quantity: 50 }] }],
},
],
},
})
```
## Master products carry no profile
Master products live in the shared catalog and have no shipping profile of their
own. To keep multi-seller carts from losing their shipping methods on refresh,
`createOffersWorkflow` links the offer's **master product** to the offer's
shipping profile. The product↔profile link is one-to-one, so the first offer's
profile wins for a given master product. Later offers (including other stores'
offers on the same product) skip the link if a profile is already attached.
The one-to-one product↔profile link is a checkout-refresh accommodation, not
the source of truth for how a store ships. Each offer still carries its own
`shipping_profile_id`, which is what drives that store's fulfillment.
# What is an offer
Source: https://docs.mercurjs.com/platform/offer/concepts/what-is-an-offer
The offer record, how it points at a master product variant, and its SKU.
In this document, you'll learn what an offer is and how it relates to the shared
master catalog.
## Offer
An offer is a store's listing against a master product variant, represented by
the `Offer` data model (table `offer`, id prefix `offer`). It's a thin
marketplace-side record: it holds the store (`seller_id`), the master variant it
points at (`variant_id`), the master product (`product_id`), the store's own
`sku`, optional `ean` / `upc` barcodes, and the shipping profile it ships with.
Price and inventory are attached through links rather than stored on the row.
```ts theme={null}
const { result } = await createOffersWorkflow(container).run({
input: {
offers: [
{
seller_id: "sel_123",
created_by: "mem_123",
variant_id: "variant_123",
shipping_profile_id: "sp_123",
sku: "ACME-WIDGET-01",
prices: [{ amount: 2500, currency_code: "usd" }],
inventory_items: [{ stock_levels: [{ location_id: "sloc_1", stocked_quantity: 50 }] }],
},
],
},
})
```
**Products are not seller-owned.** The master product and its variants live in
the shared catalog. An offer never modifies the product. It points at a
variant and layers the store's own commercial terms on top.
## Offer vs. master product
A single master variant can back many offers, one per store that sells it. The
`Offer` carries the identity of that particular listing:
* `product_id` / `variant_id`: the master records the offer points at (joined through read-only links).
* `seller_id`: the store that owns the offer (read-only link to the store).
* `sku`: the store's own stock-keeping unit for this listing.
* `ean` / `upc`: barcodes, snapshotted off the linked variant when not supplied.
A store's `sku` is **unique within that store** (`(seller_id, sku)`, enforced
while `deleted_at IS NULL`). Two different stores may reuse the same SKU string
for their own offers.
## One offer per variant
Each offer points at exactly one variant, so a store's offers on a product mirror
that product's variants. When offers are grouped by store
(`group_by_seller`), the service computes a `variant_count` and an `offer_ids`
list for the grouped `(product, seller)` row. The dashboards use it to act on
all of a store's offers on a product at once.
# Bulk-create offers
Source: https://docs.mercurjs.com/platform/offer/guides/bulk-create-offers
List many offers against the master catalog in a single workflow run.
In this guide, you'll learn how to create many offers at once, for example when
onboarding a store's catalog or running a CSV import.
`createOffersWorkflow` accepts an array of offers, so a single run can list a
store against many master variants at once. Each entry is independent and carries
its own SKU, prices, inventory, and shipping profile.
## Run the workflow with many offers
```ts title="src/api/custom/bulk/route.ts" theme={null}
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
import { createOffersWorkflow } from "@mercurjs/core/workflows"
export async function POST(req: MedusaRequest, res: MedusaResponse) {
const { result } = await createOffersWorkflow(req.scope).run({
input: {
offers: [
{
seller_id: "sel_123",
created_by: "mem_123",
variant_id: "variant_a",
shipping_profile_id: "sp_123",
sku: "ACME-A-01",
prices: [{ amount: 2500, currency_code: "usd" }],
inventory_items: [{ stock_levels: [{ location_id: "sloc_1", stocked_quantity: 50 }] }],
},
{
seller_id: "sel_123",
created_by: "mem_123",
variant_id: "variant_b",
shipping_profile_id: "sp_123",
sku: "ACME-B-01",
prices: [{ amount: 4000, currency_code: "usd" }],
inventory_items: [{ stock_levels: [{ location_id: "sloc_1", stocked_quantity: 20 }] }],
},
],
},
})
res.status(201).json({ offers: result })
}
```
The batch is validated as a whole: if any entry references a missing variant,
omits its inventory items, or reuses an existing `(seller_id, sku)` pair, the
run fails and its steps are compensated, so no partial offers are left behind.
## Update many offers
`updateOffersWorkflow` mirrors the same array shape for edits. Each entry is
keyed by the offer `id`; supplying a `prices` array **replaces** the offer's
price ladder (rows with an `id` are updated in place, rows without one are added,
and omitted rows are removed), while leaving `prices` out keeps the ladder
untouched.
```ts theme={null}
import { updateOffersWorkflow } from "@mercurjs/core/workflows"
await updateOffersWorkflow(req.scope).run({
input: {
offers: [
{ id: "offer_a", sku: "ACME-A-02" },
{
id: "offer_b",
prices: [{ amount: 3500, currency_code: "usd" }],
},
],
},
})
```
Both workflows emit one event per affected offer (`offer.created` /
`offer.updated`). Subscribe to those events to run downstream side effects like
re-indexing search. See the [Event reference](/platform/offer/reference/events).
# Create an offer
Source: https://docs.mercurjs.com/platform/offer/guides/create-an-offer
Create a single offer programmatically with createOffersWorkflow.
In this guide, you'll learn how to create an offer from your own server code,
for example in a custom API route, a seed script, or an import flow.
Mercur exposes a `createOffersWorkflow` that creates the `Offer` record, its
inventory items, and its price rows, and wires up every link in one run. Run it
from any place that has access to the Medusa container.
## Run the workflow
```ts title="src/api/custom/route.ts" theme={null}
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
import { createOffersWorkflow } from "@mercurjs/core/workflows"
export async function POST(req: MedusaRequest, res: MedusaResponse) {
const { result } = await createOffersWorkflow(req.scope).run({
input: {
offers: [
{
seller_id: "sel_123",
created_by: "mem_123",
variant_id: "variant_123",
shipping_profile_id: "sp_123",
sku: "ACME-WIDGET-01",
prices: [{ amount: 2500, currency_code: "usd" }],
inventory_items: [
{
stock_levels: [{ location_id: "sloc_1", stocked_quantity: 50 }],
},
],
},
],
},
})
res.status(201).json({ offer: result[0] })
}
```
Each offer must reference an existing master `variant_id` and include at least
one `inventory_items` entry. The SKU must be unique within the store.
Reusing an existing `(seller_id, sku)` pair is rejected.
## What the workflow wires up
A single run does more than insert a row:
* Creates a new `InventoryItem` per `inventory_items` entry (with any `stock_levels`) and links each to the offer. Remember, inventory links to the **offer**, not the variant.
* Writes each price onto the master variant's shared price set, stamped with an `offer_id` rule, and links the price rows to the offer.
* Links the offer to its store, product, variant, and shipping profile.
* Emits `offer.created`.
## Attach custom data
The workflow accepts an `additional_data` payload that is passed to its
`offersCreated` hook, letting you persist marketplace-specific data alongside the
offer without forking the workflow.
```ts theme={null}
await createOffersWorkflow(req.scope).run({
input: {
offers: [
{
seller_id: "sel_123",
created_by: "mem_123",
variant_id: "variant_123",
shipping_profile_id: "sp_123",
sku: "ACME-WIDGET-01",
prices: [{ amount: 2500, currency_code: "usd" }],
inventory_items: [{ stock_levels: [{ location_id: "sloc_1", stocked_quantity: 50 }] }],
},
],
additional_data: { source: "csv-import" },
},
})
```
# Manage offer inventory
Source: https://docs.mercurjs.com/platform/offer/guides/manage-offer-inventory
Attach, update, and detach inventory items on an existing offer.
In this guide, you'll learn how to change the inventory items linked to an
existing offer from your own server code.
Because inventory links to the **offer** (not the variant), the set of inventory
items backing an offer is managed through the `offer ↔ inventory_item` link.
`batchOfferInventoryItemsWorkflow` applies creates, updates, and deletes to that
link in a single run.
## Run the batch workflow
```ts title="src/api/custom/offer-inventory/route.ts" theme={null}
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
import { batchOfferInventoryItemsWorkflow } from "@mercurjs/core/workflows"
export async function POST(req: MedusaRequest, res: MedusaResponse) {
const { result } = await batchOfferInventoryItemsWorkflow(req.scope).run({
input: {
offer_id: "offer_123",
create: [{ inventory_item_id: "iitem_new", required_quantity: 2 }],
update: [{ inventory_item_id: "iitem_existing", required_quantity: 5 }],
delete: ["iitem_stale"],
},
})
res.json(result)
}
```
The result reports the links that were `created`, `updated`, and `deleted`.
## Rules the workflow enforces
* Items in `create` must **not** already be linked to the offer; items in `update` and `delete` **must** already be linked.
* An `inventory_item_id` can't appear in more than one section, and can't be duplicated within a section.
* `create` items must reference existing `InventoryItem` records; `required_quantity` defaults to `1`.
* Deleting an item that isn't linked to the offer surfaces a 404 rather than silently no-op'ing.
This workflow manages the **links** between an offer and existing inventory
items and their `required_quantity`. To create brand-new inventory items with
starting stock as part of listing an offer, pass `inventory_items` to
[`createOffersWorkflow`](/platform/offer/guides/create-an-offer) instead.
The workflow emits `offer.updated` and exposes an `offerInventoryItemsBatched`
hook carrying the `offer_id`, the batch result, and any `additional_data` you
passed in.
# Offer
Source: https://docs.mercurjs.com/platform/offer/overview
How a store sells against the shared master catalog, with its own SKU, price, inventory, and shipping.
Use offers to let each store sell against Mercur's shared master catalog on its
own terms.
The offer is the central concept of the marketplace. Products live in a single
**master catalog** that no store owns; an offer is the record that connects a
store to a master product variant. It carries everything that makes that listing
the store's own: its SKU, its price, its inventory, and its shipping profile.
Cart and order line items link back to the exact offer that was purchased, so the
whole order lifecycle knows which store fulfills and gets paid.
**Products are shared; offers are owned.** A store never owns a product. It
publishes an offer against a master product variant. Two stores selling the
same product each have their own offer, with their own SKU, price, and stock.
## Key features
* **Per-store listings:** one offer per store per variant, each with its own SKU (unique within a store).
* **Offer-scoped pricing:** prices live on the master variant's shared price set, scoped by an `offer_id` rule so every store prices independently.
* **Offer-scoped inventory:** inventory items link to the **offer**, not the variant, so stock never leaks between stores.
* **Per-offer shipping:** each offer points at the store's own shipping profile.
* **Order attribution:** cart and order line items link to the purchased offer, driving fulfillment, commission, and payouts.
* **Bulk operations:** create and update many offers, and batch an offer's inventory links, in a single workflow run.
## Get started
Learn how the domain fits together:
The offer record, how it points at a master variant, and its SKU.
Offer-scoped prices on the shared price set and offer-linked inventory.
How an offer carries its own shipping profile.
## Examples
Build against the Offer domain in your own code:
Run `createOffersWorkflow` with a price and inventory.
List many offers against the catalog in one run.
Attach, update, and detach inventory items on an offer.
## Resources
Data models, workflows, service methods, and events for the Offer domain:
The `Offer` entity and its fields.
How the Offer domain links to other modules.
Create, update, delete, and inventory-batch workflows.
Module service methods for working with records directly.
Events emitted as offers change.
# Data models
Source: https://docs.mercurjs.com/platform/offer/reference/data-models
The data models owned by the Offer domain.
The Offer domain is owned by the **Offer module**. This reference lists its data
model and fields. For the full module overview, see the
[Offer overview](/platform/offer/overview).
## Offer
Table `offer`, id prefix `offer`. A store's listing against a master product
variant. Price and inventory are attached through links rather than stored on the
row.
| Field | Type | Notes |
| --------------------- | ------ | -------------------------------------------------------------- |
| `id` | text | Primary key |
| `seller_id` | text | The store that owns the offer (read-only link) |
| `variant_id` | text | The master `ProductVariant` the offer points at |
| `product_id` | text | The master `Product` the variant belongs to |
| `shipping_profile_id` | text | The store's shipping profile for this offer |
| `sku` | text | Searchable; unique within a store (see below) |
| `ean` | text | Nullable, searchable; snapshotted off the variant when omitted |
| `upc` | text | Nullable, searchable; snapshotted off the variant when omitted |
| `created_by` | text | The member that created the offer |
| `variant_count` | number | Computed; only set when grouping by store |
| `metadata` | json | Nullable |
Uniqueness: `(seller_id, sku)` is unique while `deleted_at IS NULL`, so a store
can't reuse a SKU across its live offers, but different stores may share SKU
strings. Indexes also cover `variant_id`, `product_id`, `seller_id`,
`shipping_profile_id`, `ean`, and `upc`.
## Linked data
The offer's price ladder, inventory, and related records aren't columns on the
`offer` table. They're joined through links and only present when requested:
| Relation | Source |
| ----------------------------- | ------------------------------------------------------------------------------------- |
| `prices` | Offer-owned rows on the master variant's shared price set (`offer ↔ price` list-link) |
| `inventory_items` | `offer ↔ inventory_item` list-link; each row carries `required_quantity` |
| `seller` | Read-only `offer ↔ seller` link |
| `product` / `product_variant` | Read-only `offer ↔ product` / `offer ↔ variant` links |
| `shipping_profile` | Read-only `offer ↔ shipping_profile` link |
`variant_count` (and the companion `offer_ids` list) are computed only when
offers are listed grouped by store (`group_by_seller`). On ungrouped reads they
are absent.
See the [Links reference](/platform/offer/reference/links) for the full set of
module links.
# Event reference
Source: https://docs.mercurjs.com/platform/offer/reference/events
Events emitted by the Offer domain, for subscribers and side effects.
The Offer domain emits events as offers change. Subscribe to them to run side
effects instead of polling, such as re-indexing search, syncing external
systems, or kicking off follow-up workflows.
```ts title="src/subscribers/offer-created.ts" theme={null}
import type { SubscriberArgs, SubscriberConfig } from "@medusajs/framework"
export default async function offerCreatedHandler({
event,
container,
}: SubscriberArgs<{ id: string; product_id: string }>) {
const offerId = event.data.id
// ...re-index the offer, notify the store, etc.
}
export const config: SubscriberConfig = {
event: "offer.created",
}
```
## Offer events
| Event | Emitted when | Payload |
| --------------- | ------------------------------------------------- | -------------------- |
| `offer.created` | An offer is created | `{ id, product_id }` |
| `offer.updated` | An offer's row, prices, or inventory links change | `{ id, product_id }` |
| `offer.deleted` | An offer is deleted | `{ id, product_id }` |
`createOffersWorkflow` and `updateOffersWorkflow` emit one event per affected
offer. `batchOfferInventoryItemsWorkflow` emits `offer.updated` with a single
`{ id }` payload for the batched offer.
# Links to other modules
Source: https://docs.mercurjs.com/platform/offer/reference/links
How the Offer domain links to other modules across the marketplace.
Modules in Mercur never reference each other directly. They connect through
**module links**. The offer sits at the center of the marketplace, so it links
out to the store, the master catalog, pricing, inventory, fulfillment, and the
cart and order line items that reference it. Once a link is defined, you retrieve
related records with `query.graph` using the link alias.
```ts theme={null}
const { data: offers } = await query.graph({
entity: "offer",
fields: ["id", "sku", "prices.*", "inventory_items.*", "seller.name"],
})
```
## Catalog & store
| Linked module | Relationship |
| ------------------- | ---------------------------------------------------------------------- |
| **Product** | An offer points at one master product (`offer.product_id`, read-only). |
| **Product variant** | An offer points at one master variant (`offer.variant_id`, read-only). |
| **Seller** | An offer belongs to one store (`offer.seller_id`, read-only). |
## Pricing & inventory
| Linked module | Relationship |
| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------- |
| **Price** | An offer has many prices: a writable list-link to the offer-owned rows on the master variant's shared price set. |
| **Inventory item** | An offer has many inventory items: a writable list-link (`offer_inventory_item`) whose pivot carries a `required_quantity` column. |
## Fulfillment
| Linked module | Relationship |
| -------------------- | ---------------------------------------------------------------------------------- |
| **Shipping profile** | An offer ships with one shipping profile (`offer.shipping_profile_id`, read-only). |
## Cart & order
| Linked module | Relationship |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Cart line item** | A cart line item links to the offer it added; one offer can back many cart line items (list-link on the line-item side). |
| **Order line item** | An order line item links to the purchased offer; one offer can back many order line items across child orders (list-link on the line-item side). |
Read-only links (Product, Variant, Seller, Shipping profile) resolve from a
field on the `offer` row and can't be written through the link itself. The
Price and Inventory-item links are writable and are managed by the offer
workflows.
The cart- and order-line-item links are list-links **on the line-item side** so
the same offer can be added to many carts and placed on many orders. Without
that, Medusa would enforce a 1:1 line-item ↔ offer relationship and block
re-use.
# Service reference
Source: https://docs.mercurjs.com/platform/offer/reference/service
The Offer module service and its methods for working with records directly.
The Offer module exposes a service you can resolve from the Medusa container to
read and write records directly, without going through a workflow. Use it inside
custom services, subscribers, or scheduled jobs.
```ts theme={null}
import { MercurModules } from "@mercurjs/types"
const offerModuleService = container.resolve(MercurModules.OFFER)
const [offers, count] = await offerModuleService.listAndCountOffers({
seller_id: "sel_123",
})
```
## Generated methods
The `Offer` model gets a standard set of auto-generated methods:
| Method | Description |
| --------------------------------------- | ------------------------------ |
| `createOffers(data)` | Create one or more offers |
| `retrieveOffer(id, config?)` | Retrieve an offer by id |
| `listOffers(filters?, config?)` | List offers matching filters |
| `listAndCountOffers(filters?, config?)` | List offers with a total count |
| `updateOffers(data)` | Update one or more offers |
| `deleteOffers(ids)` | Delete one or more offers |
## Group by seller
`listOffers` and `listAndCountOffers` accept a `group_by_seller` filter. When
set, the service collapses offers to one row per `(product, seller)` group and
populates `variant_count` and `offer_ids` on each returned offer.
```ts theme={null}
const [grouped] = await offerModuleService.listAndCountOffers({
product_id: "prod_123",
group_by_seller: true,
})
```
The service writes the `offer` row and its computed fields directly. It does
**not** create the offer's prices, inventory items, or module links, and it
does **not** emit events or run compensation. Prefer the
[workflows](/platform/offer/reference/workflows) for anything that must wire up
pricing, inventory, or links.
# Workflows
Source: https://docs.mercurjs.com/platform/offer/reference/workflows
Offer workflows for creating, updating, deleting, and batching inventory.
This reference lists the workflows for the Offer domain. Import them from
`@mercurjs/core/workflows` and run them against the Medusa container.
## Offer workflows
| Workflow | Input | Purpose |
| ---------------------------------- | ----------------------------------------------------------- | ------------------------------------------------------- |
| `createOffersWorkflow` | `{ offers[], additional_data? }` | Create offers with prices, inventory, and links |
| `updateOffersWorkflow` | `{ offers[], additional_data? }` | Update offer rows and rewrite their price ladders |
| `deleteOffersWorkflow` | `{ ids[], additional_data? }` | Delete offers |
| `batchOfferInventoryItemsWorkflow` | `{ offer_id, create?, update?, delete?, additional_data? }` | Add, update, and detach an offer's inventory-item links |
## Hooks
Each workflow exposes hooks so you can extend it without forking:
| Workflow | Hooks |
| ---------------------------------- | ---------------------------------------- |
| `createOffersWorkflow` | `validate`, `offersCreated` |
| `updateOffersWorkflow` | `validate`, `offersUpdated` |
| `deleteOffersWorkflow` | `offersDeleted` |
| `batchOfferInventoryItemsWorkflow` | `validate`, `offerInventoryItemsBatched` |
## Behavior notes
* **Prices** are written on the master variant's shared price set, each row scoped by an `offer_id` `PriceRule`. On update, supplying `prices` replaces the ladder with the given rows; omitting it leaves prices untouched.
* **Inventory** items passed to `createOffersWorkflow` are created and linked to the offer (and the store) in the same run. Every offer needs at least one.
* Workflows run with compensation, so a failed create rolls back the inventory items, prices, and links it had already created.
To work with records directly instead of through a workflow, see the
[Service reference](/platform/offer/reference/service). To run side effects when
an offer changes, see the [Event reference](/platform/offer/reference/events).
# Computed totals
Source: https://docs.mercurjs.com/platform/order-group/concepts/computed-totals
Why an order group's seller count and total are derived at query time, not stored.
This page covers how an order group's `seller_count` and `total` are calculated
and why they aren't persisted.
## Computed, not stored
Two fields on the `OrderGroup` model are marked `computed()`. They never hold a
value in the `order_group` row.
```ts theme={null}
const OrderGroup = model.define("order_group", {
// ...
seller_count: model.number().computed(),
total: model.bigNumber().computed(),
})
```
`seller_count` is the number of distinct sellers with a child order in the group.
`total` is the sum of those child orders' current totals. Both are resolved
by aggregating across the group's linked orders each time the group is read,
rather than being written once at creation.
Storing these values would immediately go stale: child orders can be refunded,
returned, or canceled after the group is created, changing both the total and,
effectively, the active seller set.
## How they're aggregated
When you read a group, the Seller module's order-group repository joins the group
to its child orders (and each order to its seller and order summary) and folds
them up:
* `seller_count`: a distinct count of the linked sellers
* `total`: the sum of each child order's current order total
```ts theme={null}
const orderGroup = await sellerModuleService.retrieveOrderGroup("og_123")
// orderGroup.seller_count -> e.g. 3
// orderGroup.total -> e.g. 24900 (sum across child orders)
```
Because `total` is a `bigNumber`, group totals stay precise no matter how many
child orders and currencies contribute to the aggregate.
## Aggregated child statuses
The retrieve and list workflows layer the same idea onto child orders. They
derive each order's `payment_status` and `fulfillment_status` from its payment
collections and fulfillments at read time, so the group reflects the live state
of every seller's slice without any denormalized status column.
# Order splitting
Source: https://docs.mercurjs.com/platform/order-group/concepts/order-splitting
How a multi-seller cart becomes a group of independent per-seller orders.
This page covers how a single cart spanning multiple sellers is split into
per-seller orders at checkout.
## Why carts are split
A marketplace cart can hold offers from many different sellers. Each seller
fulfills, ships, and settles independently, so a single combined order would be
impossible to operate. Instead, on completion the cart is divided along seller
boundaries. Every seller with items in the cart gets its own `Order`, and all of
those orders are attached to one parent `OrderGroup`.
The split is driven by the `completeCartWithSplitOrdersWorkflow`. It groups the
cart's line items by the seller behind each offer, and builds one order per
seller from that seller's items and shipping methods.
```ts theme={null}
import { completeCartWithSplitOrdersWorkflow } from "@mercurjs/core/workflows"
const { result } = await completeCartWithSplitOrdersWorkflow(container).run({
input: { cart_id: "cart_123" },
})
// result.order_group_id -> the parent group
```
Items are grouped by `item.offer.seller_id`. Sellers list against the shared
master catalog through **offers**, so the offer, not the product, is what
ties a line item to a seller.
## What the split produces
For each seller in the cart the workflow, in one transaction:
* creates a child `Order` with that seller's line items and shipping methods
* links each order to the group (`order_group_order`) and to its seller (`order_order_seller_seller`)
* mirrors the line-item → offer links onto the new order lines
* splits payment captures proportionally across the child orders
* reserves offer-scoped inventory and refreshes commission lines per order
Once every child order is created, the workflow emits `order.placed` for the
orders and `order_group.created` for the group.
Promotions are attributed per seller: a seller-scoped promotion links only to
that seller's child order, while marketplace-wide promotions are applied as
cart adjustments and belong to no single order.
## Independent child orders
After the split, each child order lives its own life. Fulfillment, returns, and
refunds are handled per order, so one seller can ship while another is still
preparing, without affecting the rest of the group. The group remains the
single reference the shopper uses to see the purchase as a whole.
# The order group
Source: https://docs.mercurjs.com/platform/order-group/concepts/the-order-group
The aggregate record over a multi-seller purchase, its display id, and cart link.
This page covers the order group record and how it aggregates the per-seller
orders created from a single cart.
## Order group
An order group is the shopper-facing wrapper over a multi-seller purchase. It is
represented by the `OrderGroup` data model (table `order_group`, id prefix
`og`). When a cart containing offers from more than one seller is completed, the
cart is split into one child order per seller. All of those orders are attached
to a single group.
```ts theme={null}
const OrderGroup = model.define("order_group", {
id: model.id({ prefix: "og" }).primaryKey(),
display_id: model.autoincrement(),
seller_count: model.number().computed(),
customer_id: model.text().nullable(),
total: model.bigNumber().computed(),
cart_id: model.text(),
})
```
The group carries a human-readable `display_id`, an auto-incrementing integer,
so shoppers and operators can reference the purchase without exposing the
internal id. `customer_id` records who placed it. `seller_count` and `total`
are computed at read time. See [Computed totals](/platform/order-group/concepts/computed-totals).
A group is created even for a single-seller cart, so every completed
marketplace order has exactly one parent group regardless of how many sellers
it spans.
## The cart link
Each group holds a `cart_id` pointing back to the cart it was created from. This
is exposed as a **read-only** link to the Cart module. The cart is frozen
(`completed_at` is set) the moment the split runs, so the reference is a
historical record, not something you write through.
```ts theme={null}
const { data: groups } = await query.graph({
entity: "order_group",
fields: ["id", "display_id", "cart.id", "orders.id"],
})
```
Because the cart is immutable after checkout, the `cart_id` is safe to treat as
a stable audit pointer to the exact basket the shopper paid for.
## Child orders
The group doesn't store line items itself. Those live on the child `Order`
records, linked through the `order_group_order` table. Loading a group's
`orders.*` gives you each seller's slice, each with its own fulfillment, payment,
returns, and refunds.
# List order groups
Source: https://docs.mercurjs.com/platform/order-group/guides/list-order-groups
Page through order groups and scope them to a seller with getOrderGroupsListWorkflow.
In this guide, you'll learn how to list order groups from server code, page
through the results, and optionally scope them to a single seller.
Mercur exposes a `getOrderGroupsListWorkflow` that returns groups with their
aggregated child orders and a total count for pagination.
## Run the workflow
```ts title="src/api/custom/order-groups/route.ts" theme={null}
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
import { getOrderGroupsListWorkflow } from "@mercurjs/core/workflows"
export async function GET(req: MedusaRequest, res: MedusaResponse) {
const { result } = await getOrderGroupsListWorkflow(req.scope).run({
input: {
fields: ["id", "display_id", "total", "seller_count"],
variables: {
skip: 0,
take: 20,
order: { created_at: "DESC" },
},
},
})
res.json({
order_groups: result.rows,
count: result.metadata?.count ?? 0,
})
}
```
The workflow returns `{ rows, metadata }`, where `metadata` carries the `count`,
`skip`, and `take` you need to drive pagination.
Filters go inside `variables`: the group repository understands `id`,
`customer_id`, `seller_id`, `status`, `sales_channel_id`, `created_at`,
`updated_at`, and a free-text `q` (matched against group id and customer id).
## Scope to a seller
Pass a `sellerId` to get a vendor's slice. The workflow filters each group's
child orders down to that seller, so vendors only ever see their own orders
within a group.
```ts theme={null}
await getOrderGroupsListWorkflow(req.scope).run({
input: {
fields: ["id", "display_id"],
variables: { seller_id: "sel_123", take: 20 },
sellerId: "sel_123",
},
})
```
Admin surfaces call this workflow with no `sellerId` for platform-wide
visibility. Vendor surfaces pass the resolved seller so both the query and the
returned child orders stay scoped to that store.
# Retrieve an order group
Source: https://docs.mercurjs.com/platform/order-group/guides/retrieve-an-order-group
Load an order group and its aggregated child orders with getOrderGroupDetailWorkflow.
In this guide, you'll learn how to load a single order group together with its
child orders from your own server code.
Mercur exposes a `getOrderGroupDetailWorkflow` that fetches the group, expands
its child orders, and derives each order's payment and fulfillment status. Run it
from any place that has access to the Medusa container.
## Run the workflow
```ts title="src/api/custom/order-group/[id]/route.ts" theme={null}
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
import { getOrderGroupDetailWorkflow } from "@mercurjs/core/workflows"
export async function GET(req: MedusaRequest, res: MedusaResponse) {
const { result: order_group } = await getOrderGroupDetailWorkflow(
req.scope
).run({
input: {
order_group_id: req.params.id,
fields: ["id", "display_id", "total", "seller_count", "cart_id"],
},
})
res.json({ order_group })
}
```
The workflow always expands the group's child orders regardless of the `fields`
you pass, so `order_group.orders` is populated with each seller's slice.
The workflow only fetches heavy relations when you ask for them: include a
`payment_collections` field to get per-order `payment_status`, and a
`fulfillments` field to get `fulfillment_status`. Otherwise those collections
are stripped from the response to keep it lean.
## Follow the cart link
The group's read-only `cart_id` points back to the immutable cart it came from.
Expand it through the module link when you need the original basket:
```ts theme={null}
await getOrderGroupDetailWorkflow(req.scope).run({
input: {
order_group_id: req.params.id,
fields: ["id", "cart.id", "cart.email", "orders.id", "orders.total"],
},
})
```
Prefer the workflow over reading the record directly. It does the child-order
status aggregation for you. The raw
[service method](/platform/order-group/reference/service) returns only the
group row and its computed totals.
# Split a cart into orders
Source: https://docs.mercurjs.com/platform/order-group/guides/split-a-cart
Complete a multi-seller cart into a group of per-seller orders with completeCartWithSplitOrdersWorkflow.
In this guide, you'll learn how the checkout split works and how to run it from
your own server code, such as a custom complete-cart route.
Mercur replaces Medusa's single-order checkout with
`completeCartWithSplitOrdersWorkflow`. It takes a cart that may hold offers from
several sellers, creates one order per seller, and wraps them in an `OrderGroup`.
## Run the workflow
```ts title="src/api/store/carts/[id]/complete/route.ts" theme={null}
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
import { completeCartWithSplitOrdersWorkflow } from "@mercurjs/core/workflows"
export async function POST(req: MedusaRequest, res: MedusaResponse) {
const { result } = await completeCartWithSplitOrdersWorkflow(req.scope).run({
input: { cart_id: req.params.id },
})
res.json({ order_group_id: result.order_group_id })
}
```
The workflow is idempotent per cart: if a group already exists for the cart it
returns the existing `order_group_id` instead of splitting again. It acquires a
lock on the cart id for the duration of the split so concurrent completions can't
create duplicate orders.
Line items are grouped by `item.offer.seller_id`. Sellers sell against the
shared master catalog through **offers**, so a line item's seller comes from
its offer, never from product ownership.
## What happens during the split
For the cart, the workflow validates payments and per-seller shipping, then in a
single transaction:
* creates the parent `OrderGroup` (`customer_id`, `cart_id`)
* creates one child `Order` per seller from that seller's items and shipping
* links each order to the group, its seller, and the originating cart
* mirrors line-item → offer links, reserves offer inventory, and splits payment captures proportionally
* refreshes commission lines per order and marks the cart `completed_at`
Finally it emits `order.placed` for the created orders and `order_group.created`
for the group.
The workflow exposes hooks `validate`, `beforePaymentAuthorization`, and
`orderGroupCreated`, so you can inject marketplace-specific logic around the
split without forking it.
# Order Group
Source: https://docs.mercurjs.com/platform/order-group/overview
Wrap a multi-seller cart into one shopper-facing order made of independent per-seller orders.
Use Order Groups to turn a single customer cart that spans multiple sellers into
one coherent order for the shopper.
A marketplace cart can contain offers from several sellers at once. On checkout,
Mercur splits that cart into one child order per seller and wraps them in an
**Order Group**. This is the aggregate the shopper sees as "their order". Each
child order is then fulfilled, returned, and refunded independently, while the
group gives you a single handle over the whole purchase.
An order group is the `OrderGroup` entity. It lives in the **Seller module**
(id prefix `og`), not a module of its own. There is no `ORDER_GROUP` module
key. Resolve its service through `MercurModules.SELLER`.
## Key features
* **Multi-seller checkout:** one cart with offers from many sellers becomes one group of per-seller orders.
* **Human-readable id:** an auto-incrementing `display_id` the shopper and operator can reference.
* **Immutable cart link:** a read-only `cart_id` back to the originating cart. Carts are frozen after checkout.
* **Computed totals:** `seller_count` and `total` are derived at query time, never stored.
* **Independent child orders:** fulfillment, returns, and refunds happen per seller order.
* **Scoped visibility:** admins see every group platform-wide. Vendors see only their slice.
## Get started
Learn how the domain fits together.
The aggregate entity, its `display_id`, and the read-only cart link.
How a multi-seller cart is split into per-seller child orders.
Why `seller_count` and `total` are calculated at query time.
## Examples
Build against Order Groups in your own code.
Load a group and its aggregated child orders.
Page through groups, optionally scoped to a seller.
Complete a multi-seller cart into a group of orders.
## Resources
Data models, links, workflows, service methods, and events for Order Groups.
The `OrderGroup` entity and its fields.
How order groups link to carts, orders, sellers, and offers.
Splitting, retrieving, and listing order groups.
Seller module methods for working with groups directly.
Events emitted as order groups are created.
# Data models
Source: https://docs.mercurjs.com/platform/order-group/reference/data-models
The data model owned by the Order Group domain.
The Order Group domain is owned by the **Seller module**. There is no separate
order-group module. This reference lists its data model and fields. For the full
module overview, see the [Order Group overview](/platform/order-group/overview).
## OrderGroup
Table `order_group`, id prefix `og`. The aggregate over the per-seller orders
created from a single cart.
| Field | Type | Notes |
| --------------------------- | --------- | ------------------------------------------------- |
| `id` | text | Primary key (prefix `og`) |
| `display_id` | integer | Auto-incrementing, human-readable reference |
| `customer_id` | text | Nullable; who placed the group |
| `cart_id` | text | The originating cart (read-only link to Cart) |
| `seller_count` | number | **Computed**: distinct sellers with a child order |
| `total` | bigNumber | **Computed**: sum of child order totals |
| `created_at` / `updated_at` | dateTime | Timestamps |
| `deleted_at` | dateTime | Nullable; soft-delete marker |
Relations: `orders` (one-to-many through the `order_group_order` link table),
`cart` (read-only, via `cart_id`).
`seller_count` and `total` are marked `computed()`. They hold no value on the
row and are aggregated from the group's child orders each time it's read. See
[Computed totals](/platform/order-group/concepts/computed-totals).
The `cart_id` link is **read-only**. The cart is frozen (`completed_at` is set)
the moment the split runs, so it's a historical reference and can't be written
through the group.
# Event reference
Source: https://docs.mercurjs.com/platform/order-group/reference/events
Events emitted by the Order Group domain, for subscribers and side effects.
The Order Group domain emits an event when a group is created during checkout.
Subscribe to it to run side effects such as sending an order confirmation,
syncing external systems, or kicking off follow-up workflows, instead of polling.
```ts title="src/subscribers/order-group-created.ts" theme={null}
import type { SubscriberArgs, SubscriberConfig } from "@medusajs/framework"
export default async function orderGroupCreatedHandler({
event,
container,
}: SubscriberArgs<{ id: string }>) {
const orderGroupId = event.data.id
// ...send a confirmation, notify sellers, etc.
}
export const config: SubscriberConfig = {
event: "order_group.created",
}
```
## Order group events
| Event | Emitted when | Payload |
| --------------------- | ----------------------------------------------- | -------- |
| `order_group.created` | A cart is split and its parent group is created | `{ id }` |
The same checkout split also emits `order.placed` for each child order created.
Subscribe to `order.placed` when you need to react per seller order, and to
`order_group.created` when you need the purchase as a whole.
# Links to other modules
Source: https://docs.mercurjs.com/platform/order-group/reference/links
How the Order Group domain links to carts, orders, sellers, and offers.
Modules in Mercur never reference each other directly. They connect through
**module links**. The `OrderGroup` entity (owned by the Seller module) sits at
the center of a completed multi-seller purchase, linking the cart it came from to
the per-seller orders it produced. Once a link is defined, you retrieve related
records with `query.graph` using the link alias.
```ts theme={null}
const { data: groups } = await query.graph({
entity: "order_group",
fields: ["id", "display_id", "cart.id", "orders.id", "orders.total"],
})
```
## Group links
| Linked module | Relationship |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| **Cart** | A group references the one cart it was created from (`order_group.cart_id`, **read-only**). Carts are immutable after checkout. |
| **Order** | A group has many child orders, one per seller, through the `order_group_order` table. |
## Order links
The child orders produced by the split carry their own marketplace links:
| Linked module | Relationship |
| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Seller** | Each order belongs to one seller (`order_order_seller_seller`); a seller has many orders. |
| **Offer** | Each order line item links to the offer it was purchased from (`order_line_item` → `offer`, list on the line-item side so one offer can back many order lines). |
The `cart_id` link is resolved from the field on the group and can't be written
through the link itself. The split sets it once, at creation.
To scope a group to a single seller, filter its child orders by
`orders.seller.id`. The list workflow does exactly this when you pass a
`sellerId`. See [List order groups](/platform/order-group/guides/list-order-groups).
# Service reference
Source: https://docs.mercurjs.com/platform/order-group/reference/service
The Seller module service and its methods for working with order group records directly.
Order groups are owned by the **Seller module**, so you resolve the same service
you'd use for stores. Resolve it from the Medusa container to read and write
`OrderGroup` records directly, without going through a workflow. Use it inside
custom services, subscribers, or scheduled jobs.
```ts theme={null}
import { MercurModules } from "@mercurjs/types"
const sellerModuleService = container.resolve(MercurModules.SELLER)
const [orderGroups, count] = await sellerModuleService.listAndCountOrderGroups({
customer_id: "cus_123",
})
```
There is no `ORDER_GROUP` module key. The `OrderGroup` model is registered on
the Seller module, so its service methods live on `MercurModules.SELLER`.
## Generated methods
The `OrderGroup` model gets the standard set of auto-generated methods:
| Method | Description |
| -------------------------------------------- | --------------------------------------------------------------- |
| `createOrderGroups(data)` | Create one or more order groups |
| `retrieveOrderGroup(id, config?)` | Retrieve a group by id (with computed `seller_count` / `total`) |
| `listOrderGroups(filters?, config?)` | List groups matching filters |
| `listAndCountOrderGroups(filters?, config?)` | List groups with a total count |
| `updateOrderGroups(data)` | Update one or more groups |
| `deleteOrderGroups(ids)` | Delete one or more groups |
The list, count, and retrieve methods run through the module's order-group
repository, which aggregates each group's child orders to fill in the computed
`seller_count` and `total`. Supported filters include `id`, `customer_id`,
`seller_id`, `status`, `sales_channel_id`, `created_at`, `updated_at`, and `q`.
Prefer [workflows](/platform/order-group/reference/workflows) for reads that
need aggregated child-order status, and for the checkout split. The service
returns the group row and its computed totals but does **not** expand child
orders' payment/fulfillment status, emit events, or run compensation.
# Workflows
Source: https://docs.mercurjs.com/platform/order-group/reference/workflows
Order group workflows, service methods, and events.
This reference lists the workflows, service methods, and events for the Order
Group domain. Import workflows from `@mercurjs/core/workflows` and run them
against the Medusa container.
## Checkout workflow
| Workflow | Input | Purpose |
| ------------------------------------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `completeCartWithSplitOrdersWorkflow` | `{ cart_id }` | Split a multi-seller cart into per-seller orders and create the parent `OrderGroup`. Returns `{ order_group_id }`. Idempotent per cart. |
## Read workflows
| Workflow | Input | Purpose |
| ----------------------------- | ----------------------------------- | ------------------------------------------------------------------------------------- |
| `getOrderGroupDetailWorkflow` | `{ order_group_id, fields }` | Load one group with its child orders and derived per-order payment/fulfillment status |
| `getOrderGroupsListWorkflow` | `{ fields, variables?, sellerId? }` | List groups with a count; `sellerId` scopes each group's child orders to a seller |
The read workflows always expand the group's child `orders`. Heavy relations
(`payment_collections`, `fulfillments`) are only kept in the response when you
request a matching field, and are used to derive each order's `payment_status`
and `fulfillment_status`.
## Step
| Step | Input | Purpose |
| ---------------------- | --------------------------- | ---------------------------------------------------------------------------------------------- |
| `createOrderGroupStep` | `{ customer_id?, cart_id }` | Create the `OrderGroup` record (used inside the checkout workflow, with a compensating delete) |
To work with records directly instead of through a workflow, see the
[Service reference](/platform/order-group/reference/service). To run side effects
when a group is created, see the [Event reference](/platform/order-group/reference/events).
# Account lifecycle
Source: https://docs.mercurjs.com/platform/payout/concepts/account-lifecycle
Payout account statuses and the webhook-driven transitions between them.
In this document, you'll learn about the payout account lifecycle and how its
status stays in sync with the provider.
## Status
A payout account's state is held in the `status` field of the `PayoutAccount`
model, typed by the `PayoutAccountStatus` enum. An account moves through four
statuses.
```
┌──────────┐
│ PENDING │
└────┬─────┘
│ account.activated
▼
┌────────────┐ ┌────────┐
│ RESTRICTED │◄─►│ ACTIVE │
└────────────┘ └───┬────┘
│ account.rejected
▼
┌──────────┐
│ REJECTED │
└──────────┘
```
| Status | Meaning |
| ------------ | ------------------------------------------------------------------ |
| `PENDING` | Account created, provider onboarding not yet complete. |
| `ACTIVE` | Fully onboarded. Can receive payouts. |
| `RESTRICTED` | Provider flagged the account, for example missing KYC. No payouts. |
| `REJECTED` | Provider permanently disabled the account. |
Payouts are only created against an `ACTIVE` account. The module rejects a
payout for an account in any other status.
## Webhook-driven transitions
Unlike the store lifecycle, payout account transitions are **not** operator
actions. They follow the provider. The provider sends a webhook, a subscriber
resolves it to an action, and `processPayoutForWebhookWorkflow` updates the
status.
| Webhook action | Resulting status |
| -------------------- | ---------------- |
| `account.activated` | `ACTIVE` |
| `account.restricted` | `RESTRICTED` |
| `account.rejected` | `REJECTED` |
```ts theme={null}
// Inside processPayoutForWebhookWorkflow
when({ input }, ({ input }) => input.action === "account.activated")
.then(() =>
updatePayoutAccountStep({ id: input.data!.id, status: PayoutAccountStatus.ACTIVE })
)
```
A `RESTRICTED` account is not terminal. Once the seller resolves the
provider's requirements, the provider emits `account.activated` again and the
account returns to `ACTIVE`. `REJECTED` is the only permanent state.
## Payout status
An individual transfer carries its own `PayoutStatus` (`PENDING` → `PROCESSING`
→ `PAID`, or `FAILED` / `CANCELED`). Provider webhooks advance it through the
same workflow. See [The payout pipeline](/platform/payout/concepts/payout-pipeline).
# Accounts & onboarding
Source: https://docs.mercurjs.com/platform/payout/concepts/accounts-and-onboarding
The payout account, its onboarding record, and provider-specific data.
In this document, you'll learn how a seller connects to a payment provider and
where provider-specific data lives.
## Payout account
A payout account is the seller's connection to the payment provider. It is the
record funds are transferred to. A payout account is represented by the
`PayoutAccount` data model (table `payout_account`, id prefix `pacc`). It is
created for a seller through `createPayoutAccountWorkflow`, which also links the
account to the store.
```ts theme={null}
const { result } = await createPayoutAccountWorkflow(container).run({
input: {
seller_id: "sel_123",
context: { /* forwarded to the provider */ },
data: { /* forwarded to the provider */ },
},
})
```
Creating an account is a two-step operation. The module first persists the
`PayoutAccount`. It then calls the provider to create the connected account and
stores what the provider returns in the account's `data` field. A seller has
**exactly one** payout account.
A store can only have **one** payout account. `createPayoutAccountWorkflow`
validates that the seller doesn't already have one before creating it.
## Onboarding
Before an account can receive funds, the seller usually has to complete
provider-side setup, such as identity verification, bank details, or KYC. That
state is held in the `Onboarding` data model (table `onboarding`, id prefix
`onb`). The record is a one-to-one satellite of the payout account.
```ts theme={null}
await createOnboardingWorkflow(container).run({
input: {
account_id: "pacc_123",
context: { return_url: "https://store.example.com/settings/payouts" },
},
})
```
The workflow asks the provider to produce onboarding data (for Stripe Connect,
an onboarding link) and stores it on the record. Running it again on an account
that already has an onboarding record **updates** it rather than creating a
second one.
## Provider data
The `data` JSON field on `PayoutAccount`, `Onboarding`, and `Payout` is where
provider-specific values live, such as the Stripe account id, onboarding URLs, or
transfer references. The module never interprets these fields. It forwards them
to and from the provider.
`context` carries per-request hints such as an `idempotency_key` or a
`return_url`, while `data` carries the durable provider payload. Both are
passed straight through the `IPayoutProvider` interface.
# The payout pipeline
Source: https://docs.mercurjs.com/platform/payout/concepts/payout-pipeline
Capture check, payment capture, daily payout, transfer, and the provider interface.
In this document, you'll learn how an authorized payment becomes a transfer to a
seller, and how the provider interface fits in.
## Payout
A payout is a single transfer of a seller's earnings for one order. It is
represented by the `Payout` data model (table `payout`, id prefix `pout`). Its
`amount` is the order total minus the order's commission lines, and it belongs to
the seller's `PayoutAccount`.
```ts theme={null}
// createPayoutWorkflow computes the seller's share
const amount = MathBN.sub(order.total, totalCommission)
```
The flow is designed to run automatically. Scheduled jobs and event-driven
subscribers move each order from authorized payment to settled transfer with no
manual step.
The payout **workflows** (`createPayoutWorkflow`, `createPayoutAccountWorkflow`,
`createOnboardingWorkflow`, `processPayoutForWebhookWorkflow`) and the provider
**webhook subscriber** ship in `@mercurjs/core`. The **scheduled jobs** that
drive capture and daily payout are wired up in your project (under
`apps/api/src/jobs`), along with the `order.capture_requested` and
`payout.requested` events they emit. The steps below describe that intended
pipeline and its integration points, not jobs bundled in the core plugin.
## 1. Capture check (every 15 min)
A scheduled job scans for orders ready for capture. An order qualifies when its
payment is `authorized`, the seller has an `ACTIVE` payout account, the order
meets the required fulfillment status (default `fulfilled`), and no payout
exists yet. As the capture deadline nears (authorization window minus safety
buffer), it emits `order.capture_requested`. If the authorization already
expired, it emits `order.authorization_expired`.
## 2. Payment capture (event-driven)
A subscriber listens for `order.capture_requested` and runs Medusa's
`capturePaymentWorkflow` to capture the authorized payment. On success, the order
is marked captured. On failure, it's flagged so it isn't retried.
## 3. Daily payout (1 AM UTC)
A daily job scans captured orders that haven't been paid out and emits
`payout.requested` for each one. An order qualifies when its payment is captured,
no payout exists yet, and the seller's account is `ACTIVE`.
## 4. Transfer (event-driven)
A subscriber listens for `payout.requested` and runs `createPayoutWorkflow`,
which loads the order with its seller, payout account, and commission lines,
computes the seller's share, calls the provider to initiate the transfer, and
creates a `Payout` record linked to the seller.
The order id is used as the payout's `idempotency_key`, so a re-emitted
`payout.requested` event never produces a duplicate transfer.
## The provider interface
Every external operation goes through the `IPayoutProvider` contract, and the
module registers **exactly one** provider. Stripe Connect ships out of the box.
Any other processor implements the same four methods.
| Method | Purpose |
| ------------------------- | ------------------------------------------------------- |
| `createPayoutAccount` | Create the connected account with the provider |
| `createOnboarding` | Produce onboarding data (e.g. a Stripe onboarding link) |
| `createPayout` | Initiate a transfer to the seller |
| `getWebhookActionAndData` | Parse a raw webhook into a `PayoutWebhookResult` |
Provider-specific values, such as account ids, onboarding URLs, or transfer
references, are stored in the `data` JSON fields and never interpreted by the
module. The same code path works for any provider.
## Configuration
The pipeline's timing is tunable via the payout module options in
`medusa-config.ts`:
| Option | Default | Description |
| --------------------------- | ------------- | --------------------------------------------------------- |
| `disabled` | `false` | Disable both scheduled jobs |
| `authorizationWindowMs` | 7 days | How long a payment authorization stays valid |
| `sellerActionWindowMs` | 72 hours | Time a seller has to fulfill before the order is rejected |
| `captureSafetyBufferMs` | 24 hours | Margin before authorization expiry to trigger capture |
| `requiredFulfillmentStatus` | `"fulfilled"` | Minimum fulfillment status before an order is eligible |
# Create a payout account
Source: https://docs.mercurjs.com/platform/payout/guides/create-a-payout-account
Create a seller's payout account with createPayoutAccountWorkflow.
In this guide, you'll learn how to create a payout account for a seller from your
own server code. This is useful in an onboarding flow or a custom API route.
Mercur exposes a `createPayoutAccountWorkflow` that persists the `PayoutAccount`,
calls the configured provider to create the connected account, and links the
account to the store. Run it from any place that has access to the Medusa
container.
## Run the workflow
```ts title="src/api/custom/payout-account/route.ts" theme={null}
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
import { createPayoutAccountWorkflow } from "@mercurjs/core/workflows"
export async function POST(req: MedusaRequest, res: MedusaResponse) {
const { result } = await createPayoutAccountWorkflow(req.scope).run({
input: {
seller_id: req.params.id,
},
})
res.status(201).json({ payout_account: result })
}
```
The new account starts in `PENDING` and can't receive payouts until the provider
marks it `ACTIVE`. See [Start onboarding](/platform/payout/guides/start-provider-onboarding).
A seller can have **only one** payout account. The workflow validates this
first and fails if the store already has one.
## Forward provider data
The workflow accepts `data` and `context` payloads that are passed straight to
the provider when it creates the connected account. Use them to hand the
provider anything it needs up front.
```ts theme={null}
await createPayoutAccountWorkflow(req.scope).run({
input: {
seller_id: "sel_123",
context: { idempotency_key: "sel_123" },
data: { business_type: "company" },
},
})
```
If the provider call fails after the record is created, the workflow rolls the
account back so you don't leave a dangling `PayoutAccount` behind.
# Process a provider webhook
Source: https://docs.mercurjs.com/platform/payout/guides/process-a-provider-webhook
Turn a provider webhook into account and payout status updates.
In this guide, you'll learn how a provider webhook becomes account and payout
status changes. Mercur already wires this up. A subscriber listens for
`payout.webhook_received` and drives the update workflow. Understanding the path
lets you emit the event yourself or extend the flow.
## The built-in path
The `payout-webhook` subscriber resolves the raw payload to an action through the
provider, then runs `processPayoutForWebhookWorkflow`:
```ts title="src/subscribers/payout-webhook.ts (shipped)" theme={null}
const processedEvent = await payoutService.getWebhookActionAndData(input)
if (!processedEvent.data) {
return
}
const wfEngine = container.resolve(Modules.WORKFLOW_ENGINE)
await wfEngine.run(processPayoutForWebhookWorkflowId, { input: processedEvent })
```
`getWebhookActionAndData` delegates to the provider, which parses its own payload
and returns a `PayoutWebhookResult`. The result is an `action` plus the affected
`id`.
## Run the workflow directly
To process an already-parsed result yourself, run the workflow with a
`PayoutWebhookResult`:
```ts theme={null}
import { processPayoutForWebhookWorkflow } from "@mercurjs/core/workflows"
await processPayoutForWebhookWorkflow(container).run({
input: {
action: "payout.paid",
data: { id: "pout_123" },
},
})
```
The workflow branches on `action`, updating the account or the payout:
| Action | Effect |
| ------------------------------------------------------------------------- | ---------------------------------------------------------- |
| `account.activated` / `account.restricted` / `account.rejected` | Set account status to `ACTIVE` / `RESTRICTED` / `REJECTED` |
| `payout.processing` / `payout.paid` / `payout.failed` / `payout.canceled` | Set payout status accordingly |
Actions the provider can't map return `not_supported` (or a missing `data.id`),
and the workflow makes no change. It is safe to hand it every event the provider
sends.
## Emit the event yourself
To route a custom provider integration through the same path, emit
`payout.webhook_received` with the raw payload and let the shipped subscriber
take over.
```ts theme={null}
const eventBus = container.resolve(Modules.EVENT_BUS)
await eventBus.emit({
name: "payout.webhook_received",
data: { rawData, headers, data },
})
```
# Start provider onboarding
Source: https://docs.mercurjs.com/platform/payout/guides/start-provider-onboarding
Kick off provider onboarding with createOnboardingWorkflow.
In this guide, you'll learn how to start provider onboarding for a payout
account from server code. Onboarding is what moves an account from `PENDING`
toward `ACTIVE`. For Stripe Connect, it produces the hosted link the seller uses
to submit their details.
## Run the workflow
`createOnboardingWorkflow` asks the provider to produce onboarding data and
stores it as an `Onboarding` record on the account.
```ts title="src/api/custom/onboarding/route.ts" theme={null}
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
import { createOnboardingWorkflow } from "@mercurjs/core/workflows"
export async function POST(req: MedusaRequest, res: MedusaResponse) {
const { result } = await createOnboardingWorkflow(req.scope).run({
input: {
account_id: req.params.id,
context: {
return_url: "https://store.example.com/settings/payouts",
},
},
})
res.status(201).json({ onboarding: result })
}
```
The provider-specific payload (for Stripe Connect, the onboarding URL) is stored
in the record's `data` field for you to return to the seller.
Running the workflow again on an account that already has an onboarding record
**updates** it in place instead of creating a second one. It is safe to call
whenever a seller needs a fresh link.
## Reaching `ACTIVE`
Onboarding kicks off the flow, but the account only becomes `ACTIVE` when the
provider confirms it via webhook. Handle that step in
[Process a webhook](/platform/payout/guides/process-a-provider-webhook), and see
[Account lifecycle](/platform/payout/concepts/account-lifecycle) for the full
state model.
# Payout
Source: https://docs.mercurjs.com/platform/payout/overview
Onboard sellers to a payment provider and settle their earnings automatically.
Use Mercur to control how each seller is onboarded and settled, while the
payment provider handles KYC.
Settlement you control is the Payout domain. Once an order is placed and
fulfilled, it splits each seller's share out to their own connected account, so
funds are settled per seller rather than pooled. The seller's share is the order
total minus commission. Onboarding is provider-driven, with the payment provider
carrying KYC, and the domain ships with a pluggable provider interface and a
**Stripe Connect** implementation out of the box. It also runs a fully automated
capture-and-transfer pipeline driven by scheduled jobs and provider webhooks.
**Provider-agnostic.** The module talks to exactly one registered payout
provider through a single interface. Stripe Connect ships by default; any
other processor is a drop-in implementation of the same `IPayoutProvider`
contract.
## Key features
* **Pluggable provider interface:** one `IPayoutProvider` contract, with Stripe Connect included.
* **Payout accounts and onboarding:** a per-seller account plus a provider onboarding record.
* **Webhook-driven lifecycle:** account status (`PENDING` → `ACTIVE` ↔ `RESTRICTED` / `REJECTED`) tracks the provider.
* **Automated pipeline:** a capture-check job, payment capture, a daily payout job, and transfer, with no manual steps.
* **Commission-aware transfers:** each payout is the order total minus its commission lines.
* **Tunable timing:** authorization window, seller-action window, capture buffer, and required fulfillment status.
## Get started
Learn how the domain fits together.
The payout account, its onboarding record, and provider data.
Statuses and the webhook-driven transitions between them.
Capture check, capture, daily payout, transfer, and the provider interface.
## Examples
Build against the Payout domain in your own code.
Run `createPayoutAccountWorkflow` for a seller.
Kick off provider onboarding with `createOnboardingWorkflow`.
Turn a provider webhook into account and payout status updates.
## Resources
Data models, workflows, service methods, and events for the Payout domain.
The `PayoutAccount`, `Onboarding`, and `Payout` entities.
How the Payout domain links to sellers and orders.
Account, onboarding, payout, and webhook workflows.
Module service methods for working with records directly.
Events that drive the payout pipeline.
# Data models
Source: https://docs.mercurjs.com/platform/payout/reference/data-models
The data models owned by the Payout domain.
The Payout domain is owned by the **Payout module**. This reference lists its
data models and their fields. For the full module overview, see the
[Payout overview](/platform/payout/overview).
## PayoutAccount
Table `payout_account`, id prefix `pacc`. A seller's connection to the payment
provider. This is the record funds are transferred to.
| Field | Type | Notes |
| --------- | ---- | ---------------------------------------- |
| `id` | text | Primary key |
| `status` | enum | `PayoutAccountStatus`, default `pending` |
| `data` | json | Provider-specific account data |
| `context` | json | Nullable; per-request provider hints |
Relations: `onboarding` (one-to-one, nullable), `payouts` (one-to-many).
## Onboarding
Table `onboarding`, id prefix `onb`. Provider setup state for a payout account
(for Stripe Connect, the onboarding link).
| Field | Type | Notes |
| ------------ | ---- | ------------------------------------ |
| `id` | text | Primary key |
| `data` | json | Nullable; provider onboarding data |
| `context` | json | Nullable; per-request provider hints |
| `account_id` | text | Belongs to a `PayoutAccount` |
`Onboarding` is one-to-one with `PayoutAccount`. Re-running the onboarding
workflow updates the existing record rather than creating a second one.
## Payout
Table `payout`, id prefix `pout`. A single transfer of a seller's earnings for
one order.
| Field | Type | Notes |
| --------------- | --------- | --------------------------------- |
| `id` | text | Primary key |
| `display_id` | number | Auto-incrementing, human-readable |
| `currency_code` | text | The payout's currency |
| `amount` | bigNumber | Order total minus commission |
| `data` | json | Nullable; provider transfer data |
| `status` | enum | `PayoutStatus`, default `pending` |
| `account_id` | text | Belongs to a `PayoutAccount` |
## Enums
**`PayoutAccountStatus`:** `pending`, `active`, `restricted`, `rejected`.
**`PayoutStatus`:** `pending`, `processing`, `paid`, `failed`, `canceled`.
# Event reference
Source: https://docs.mercurjs.com/platform/payout/reference/events
Events that drive the payout pipeline, for subscribers and side effects.
The Payout domain is event-driven. Scheduled jobs emit events, and subscribers
react to them to capture payments and transfer funds. Subscribe to these events
to run your own side effects, such as notifications, ledger syncing, or follow-up
workflows, instead of polling.
```ts title="src/subscribers/payout-requested.ts" theme={null}
import type { SubscriberArgs, SubscriberConfig } from "@medusajs/framework"
export default async function payoutRequestedHandler({
event,
container,
}: SubscriberArgs<{ order_id: string }>) {
const orderId = event.data.order_id
// ...notify the seller, write to an external ledger, etc.
}
export const config: SubscriberConfig = {
event: "payout.requested",
}
```
## Pipeline events
| Event | Emitted when | Handled by |
| ----------------------------- | --------------------------------------------------------- | ------------------------------------------------------------ |
| `order.capture_requested` | Capture check finds an order ready to capture | The payment-capture subscriber runs `capturePaymentWorkflow` |
| `order.authorization_expired` | Capture check finds an authorization that already expired | Order is flagged so it isn't retried |
| `payout.requested` | Daily job finds a captured order not yet paid out | The transfer subscriber runs `createPayoutWorkflow` |
## Webhook events
| Event | Emitted when | Handled by |
| ------------------------- | -------------------------- | ---------------------------------------------------------------------- |
| `payout.webhook_received` | A provider webhook arrives | The `payout-webhook` subscriber runs `processPayoutForWebhookWorkflow` |
`order.capture_requested`, `order.authorization_expired`, and
`payout.requested` are defined on the `PayoutEvents` enum in `@mercurjs/types`.
The webhook subscriber resolves `payout.webhook_received` to a provider action
before updating status. See
[Account lifecycle](/platform/payout/concepts/account-lifecycle).
# Links to other modules
Source: https://docs.mercurjs.com/platform/payout/reference/links
How the Payout domain links to sellers and orders across the marketplace.
Modules in Mercur never reference each other directly. They connect through
**module links**. The Payout module links to the Seller and Order modules. Once a
link is defined, you retrieve related records with `query.graph` using the link
alias.
```ts theme={null}
const { data: sellers } = await query.graph({
entity: "seller",
fields: ["id", "name", "payout_account.*", "payouts.*"],
})
```
## Sellers
| Linked module | Relationship |
| -------------------- | ----------------------------------------------------------------- |
| **Seller** (account) | A store has **one** payout account (`seller` ↔ `payout_account`). |
| **Seller** (payouts) | A store has **many** payouts (`seller` ↔ `payout`, list). |
## Orders
| Linked module | Relationship |
| ------------- | --------------------------------------------------------- |
| **Order** | An order has **many** payouts (`order` ↔ `payout`, list). |
A payout is linked to both the order it settles and the seller it pays. The
account link is one-to-one, while the seller-payouts and order-payouts links
are lists.
# Service reference
Source: https://docs.mercurjs.com/platform/payout/reference/service
The Payout module service: methods for working with records directly.
The Payout module exposes a service you can resolve from the Medusa container to
read and write records directly, without going through a workflow. Use it inside
custom services, subscribers, or scheduled jobs.
```ts theme={null}
import { MercurModules } from "@mercurjs/types"
const payoutModuleService = container.resolve(MercurModules.PAYOUT)
const [payouts, count] = await payoutModuleService.listAndCountPayouts({
status: "paid",
})
```
## Generated methods
Each data model gets a standard set of auto-generated methods. For `Payout`:
| Method | Description |
| ---------------------------------------- | ------------------------------- |
| `retrievePayout(id, config?)` | Retrieve a payout by id |
| `listPayouts(filters?, config?)` | List payouts matching filters |
| `listAndCountPayouts(filters?, config?)` | List payouts with a total count |
| `updatePayouts(data)` | Update one or more payouts |
| `deletePayouts(ids)` | Delete one or more payouts |
The same set exists for `PayoutAccount` and `Onboarding` (e.g.
`retrievePayoutAccount`, `listPayoutAccounts`, `updateOnboardings`).
## Provider-integrated methods
These overrides call the configured provider as part of the operation. Prefer
them over the raw generated creates.
| Method | Description |
| -------------------------------- | ------------------------------------------------------------------- |
| `createPayoutAccount(input)` | Create the account and the provider's connected account |
| `createOnboarding(input)` | Create or update onboarding via the provider |
| `createPayouts(input)` | Initiate a transfer through the provider (account must be `ACTIVE`) |
| `getWebhookActionAndData(input)` | Ask the provider to parse a raw webhook payload |
| `getOptions()` | Read the module's configured options (with defaults applied) |
Prefer [workflows](/platform/payout/reference/workflows) for anything that
touches the provider or links records. The generated `updatePayouts` /
`updatePayoutAccounts` write status directly and do **not** call the provider.
# Workflows
Source: https://docs.mercurjs.com/platform/payout/reference/workflows
Payout account, onboarding, payout, and webhook workflows.
This reference lists the workflows for the Payout domain. Import them from
`@mercurjs/core/workflows` and run them against the Medusa container.
## Account & onboarding workflows
| Workflow | Input | Purpose |
| ----------------------------- | --------------------------------- | ----------------------------------------------------------------- |
| `createPayoutAccountWorkflow` | `{ seller_id, data?, context? }` | Create a payout account, call the provider, link it to the store |
| `createOnboardingWorkflow` | `{ account_id, data?, context? }` | Create or update the account's onboarding record via the provider |
## Payout workflows
| Workflow | Input | Purpose |
| --------------------------------- | --------------------- | ----------------------------------------------------------------------- |
| `createPayoutWorkflow` | `{ order_id }` | Compute the seller's share (total − commission) and initiate a transfer |
| `processPayoutForWebhookWorkflow` | `PayoutWebhookResult` | Apply a provider webhook to account or payout status |
`createPayoutWorkflow` reads the order with its seller, payout account, and
commission lines, then uses the order id as the transfer's idempotency key so
a re-run never double-pays.
To work with records directly instead of through a workflow, see the
[Service reference](/platform/payout/reference/service). For the events that
drive these workflows, see the [Event reference](/platform/payout/reference/events).
# Change actions
Source: https://docs.mercurjs.com/platform/product-edit/concepts/change-actions
The typed actions that make up a change and the details they carry.
In this document, you'll learn how the individual operations inside a change are
modeled.
## Product change action
Each operation inside a change is a `ProductChangeAction` (table
`product_change_action`, id prefix `prodchact`). An action belongs to a parent
`ProductChange`, targets a `product_id`, and names the operation in its `action`
field. The operation's payload lives in the `details` JSON, and an `applied`
boolean records whether it has already been written to the product.
```ts theme={null}
const action = {
product_id: "prod_123",
action: "UPDATE",
details: { field: "title", value: "New title" },
}
```
Actions carry an autoincrementing `ordering` so a change with several operations
applies them deterministically.
## Action types
The `action` field is one of the `ProductChangeActionType` values. Each type
reads a different shape out of `details`:
| Action | `details` shape | Applies |
| ------------------ | ------------------------ | ---------------------------------------- |
| `UPDATE` | `{ field, value }` | A single product field update |
| `STATUS_CHANGE` | `{ status }` | A product status change |
| `VARIANT_ADD` | `{ variant }` | Create a variant |
| `VARIANT_UPDATE` | `{ variant_id, fields }` | Update a variant (scalars + image links) |
| `VARIANT_REMOVE` | `{ variant_id }` | Delete a variant |
| `ATTRIBUTE_ADD` | `{ attribute }` | Attach a product attribute |
| `ATTRIBUTE_UPDATE` | `{ update }` | Change an attached attribute |
| `ATTRIBUTE_REMOVE` | `{ attribute_id }` | Detach an attribute |
| `PRODUCT_ADD` | None | Record a product creation in the trail |
| `PRODUCT_DELETE` | None | Delete the product |
| `CHANGE_REQUESTED` | `{ message }` | Record an operator revision request |
`CHANGE_REQUESTED` mutates nothing. It's an audit-only marker for a revision
request. The operator's message rides on both the action's `details.message`
and the parent change's `external_note`. See
[Status & auto-confirm](/platform/product-edit/concepts/status-and-auto-confirm).
## How actions apply
When a change is confirmed, its **not-yet-applied** actions are bucketed by type
and dispatched to the matching Medusa workflows in one pass: product updates,
variant creates/updates/deletes, and the attribute batch. Each action is then
flipped to `applied: true` so a re-run never applies it twice.
Audit-trail changes (publish approvals, revision requests) are stored with
their actions already `applied`, so confirming them is a no-op on the product
itself. They exist purely as history.
# The change pipeline
Source: https://docs.mercurjs.com/platform/product-edit/concepts/change-pipeline
The ProductChange record, its immutability, and the audit trail.
In this document, you'll learn how a product edit is captured and why the
pipeline is built on immutable records.
## Product change
A product change is a single reviewable edit to one product, represented by the
`ProductChange` data model (table `product_change`, id prefix `prodch`). It
references the target product through `product_id`, carries the `status` of the
review, and records who created and resolved it (`created_by`, `confirmed_by`,
`declined_by`, `canceled_by`) with matching timestamps.
```ts theme={null}
const productChangeModuleService = container.resolve(MercurModules.PRODUCT_EDIT)
const change = await productChangeModuleService.retrieveProductChange(id, {
relations: ["actions"],
})
```
A change owns one or more `ProductChangeAction` records (`actions`). The change
is the reviewable unit; the actions are the individual operations it will apply.
See [Change actions](/platform/product-edit/concepts/change-actions).
A product can have **only one active (pending) change at a time**. Staging a
new change while one is still pending is rejected. The vendor resolves or
cancels the open change first.
## Immutability & the audit trail
A change is never rewritten in place. It is created, its actions are appended,
and it is resolved by moving `status` forward and stamping the actor and time.
Because nothing is overwritten, the set of `ProductChange` rows on a product is a
durable history of who changed what and who approved it.
Some events aren't vendor edits at all, such as a publish approval or a revision
request. They still belong in the history. Those are recorded as changes created
already `confirmed`, so the audit trail captures them without waiting on review.
```ts theme={null}
await recordProductAuditChangeWorkflow(container).run({
input: {
actor_id: "user_123",
changes: [
{
product_id: "prod_123",
external_note: "Approved for publish",
actions: [
{ product_id: "prod_123", action: "STATUS_CHANGE", details: { status: "published" } },
],
},
],
},
})
```
Read a product's full history through the read-only `product.changes` link
(see the [Links reference](/platform/product-edit/reference/links)) rather than
querying the module directly. The link keeps the audit trail attached to the
product.
# Status & auto-confirm
Source: https://docs.mercurjs.com/platform/product-edit/concepts/status-and-auto-confirm
The change status lifecycle, revision requests, and auto-confirm.
In this document, you'll learn about the states a change moves through and when
it resolves without operator review.
## Status
A change's state is held in the `status` field of the `ProductChange` model,
typed by the `ProductChangeStatus` enum. A change moves through four statuses:
```
┌───────────┐
│ pending │
└─────┬─────┘
┌───────────┼───────────┐
confirm decline cancel
▼ ▼ ▼
┌───────────┐ ┌──────────┐ ┌──────────┐
│ confirmed │ │ declined │ │ canceled │
└───────────┘ └──────────┘ └──────────┘
```
| Status | Meaning |
| ----------- | ------------------------------------------------------- |
| `pending` | Awaiting operator review. The default for a staged edit |
| `confirmed` | Approved; its actions are applied to the product |
| `declined` | Rejected by the operator; nothing is applied |
| `canceled` | Withdrawn (e.g. by the vendor) before review |
Only a `pending` change can be confirmed, declined, or canceled. The
resolution workflows validate the current status first, so a change is
resolved exactly once.
## Revision requests
When an operator wants a submission reworked rather than approved or rejected,
they **request a revision**. This is recorded as a `CHANGE_REQUESTED` audit
action (a `confirmed` audit change) carrying the operator's message, and it emits
`product.change-requested`. The product stays with the vendor to revise and
resubmit. A revision request is a signal in the audit trail, not a fourth
resolution of the pending change.
## Auto-confirm
Staging a change runs `autoConfirmProductChangeWorkflow`. Whether it confirms
immediately depends on the marketplace's review setting:
* **Review off:** the change is confirmed and applied in the same run, so
low-friction edits don't wait for an operator.
* **Review on:** the change stays `pending` for an operator to resolve.
You can also force auto-confirm for a specific change (for example a
trusted-source import) via the `auto_confirm` flag on the staging workflow,
regardless of the review setting.
Auto-confirm reuses the exact same `confirmProductChangeWorkflow` as a manual
approval, so an auto-confirmed change is applied and audited identically to one
an operator approves by hand.
# Confirm or decline a change
Source: https://docs.mercurjs.com/platform/product-edit/guides/confirm-or-decline-a-change
Resolve a pending product change from server code.
In this guide, you'll learn how to resolve a pending change from your own server
code. Each resolution has a dedicated workflow so the side effects (applying
actions, events, compensation) run consistently.
## Confirm a change
`confirmProductChangeWorkflow` marks the changes `confirmed`, applies their
pending actions to the product, and emits `product-change.confirmed`.
```ts title="src/api/custom/confirm/route.ts" theme={null}
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
import { confirmProductChangeWorkflow } from "@mercurjs/core/workflows"
export async function POST(req: MedusaRequest, res: MedusaResponse) {
await confirmProductChangeWorkflow(req.scope).run({
input: {
ids: [req.params.id],
confirmed_by: req.auth_context?.actor_id,
},
})
res.sendStatus(200)
}
```
Confirmation only applies actions that aren't already `applied`, so re-running
it never writes the same edit twice.
## Decline a change
`rejectProductChangeWorkflow` moves a `pending` change to `declined` without
touching the product, and emits `product-change.declined`.
```ts theme={null}
import { rejectProductChangeWorkflow } from "@mercurjs/core/workflows"
await rejectProductChangeWorkflow(container).run({
input: {
id: "prodch_123",
declined_by: "user_123",
declined_reason: "Images don't meet guidelines",
},
})
```
## Cancel a change
When the change should be withdrawn rather than judged (for example the vendor
retracting their own submission), use `cancelProductChangeWorkflow`:
```ts theme={null}
import { cancelProductChangeWorkflow } from "@mercurjs/core/workflows"
await cancelProductChangeWorkflow(container).run({
input: { id: "prodch_123", canceled_by: "user_123" },
})
```
Confirm, decline, and cancel all require the change to be `pending`. Resolving
an already-resolved change fails validation. A change is resolved exactly once.
## React to resolutions
To run your own side effects when a change resolves, subscribe to the events
these workflows emit rather than polling. See the
[Event reference](/platform/product-edit/reference/events).
# Edit a product
Source: https://docs.mercurjs.com/platform/product-edit/guides/edit-a-product
Stage a product change from server code with the edit workflows.
In this guide, you'll learn how to route a product edit through the change
pipeline from your own server code, such as a custom API route or a bulk tool.
Instead of writing to a product directly, you stage a `ProductChange`. Mercur
exposes high-level edit workflows that diff your update against the current
product and stage only the fields that actually changed.
## Update product fields
`productEditUpdateProductWorkflow` diffs the `update` payload against the product
and stages an `UPDATE` action per changed field.
```ts title="src/api/custom/edit/route.ts" theme={null}
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
import { productEditUpdateProductWorkflow } from "@mercurjs/core/workflows"
export async function POST(req: MedusaRequest, res: MedusaResponse) {
const { result } = await productEditUpdateProductWorkflow(req.scope).run({
input: {
product_id: req.params.id,
created_by: req.auth_context?.actor_id,
update: { title: "Updated title", material: "Cotton" },
},
})
res.status(201).json({ product_change: result })
}
```
If a product already has a `pending` change, staging another one is rejected.
Only one active change per product is allowed. Resolve or cancel the open
change first.
## Stage actions directly
For finer control (variants, attributes, mixed operations), stage the actions
yourself with `stageProductChangeWorkflow`:
```ts theme={null}
import { stageProductChangeWorkflow } from "@mercurjs/core/workflows"
await stageProductChangeWorkflow(container).run({
input: {
product_id: "prod_123",
created_by: "user_123",
actions: [
{ product_id: "prod_123", action: "UPDATE", details: { field: "subtitle", value: "New" } },
{ product_id: "prod_123", action: "VARIANT_REMOVE", details: { variant_id: "variant_123" } },
],
},
})
```
## Auto-confirm
Both workflows run auto-confirm after staging: with review off the change
applies immediately, with review on it stays `pending`. Pass `auto_confirm: true`
to `stageProductChangeWorkflow` to force immediate application regardless of the
review setting.
Dedicated helpers exist for common shapes, such as
`productEditUpdateVariantsWorkflow`, `productEditUpdateAttributesWorkflow`, and
`productEditDeleteProductWorkflow`. Each stages the right action types for you.
See the [Workflows reference](/platform/product-edit/reference/workflows).
# Request a revision
Source: https://docs.mercurjs.com/platform/product-edit/guides/request-a-revision
Send a product submission back to the vendor from server code.
In this guide, you'll learn how to ask a vendor to rework a submission instead of
approving or rejecting it outright.
A revision request doesn't mutate the product. It records a `CHANGE_REQUESTED`
action in the audit trail carrying your message, and emits
`product.change-requested` so the vendor is notified. The product stays with the
vendor to revise and resubmit.
## Run the workflow
`requestProductChangeWorkflow` validates that the product is in `proposed`,
records the audit action, and emits the event.
```ts title="src/api/custom/request-revision/route.ts" theme={null}
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
import { requestProductChangeWorkflow } from "@mercurjs/core/workflows"
export async function POST(req: MedusaRequest, res: MedusaResponse) {
await requestProductChangeWorkflow(req.scope).run({
input: {
product_id: req.params.id,
actor_id: req.auth_context?.actor_id,
message: "Please add a size guide and sharper photos.",
},
})
res.sendStatus(200)
}
```
The request is only valid while the product is `proposed`. The workflow
validates the product status first and fails otherwise.
## Where the message goes
The `message` is stored in two places for durability: the `CHANGE_REQUESTED`
action's `details.message`, and the parent change's `external_note` (the
vendor-facing note). Because the audit change is created already `confirmed`, the
request is a permanent entry in the product's history.
Subscribe to `product.change-requested` to send the vendor a notification. The
payload includes the `id` (product), the `message`, and the `actor_id` of the
operator who asked. See the
[Event reference](/platform/product-edit/reference/events).
# Product Edit
Source: https://docs.mercurjs.com/platform/product-edit/overview
Route every product edit through a reviewable, immutable change pipeline.
Use Mercur to keep a full audit trail of every product change and govern your
master data before it changes.
Master-data governance depends on knowing who changed what, and reviewing it
before it takes effect. The Product Edit domain is that auditable change
pipeline. Every edit becomes an immutable, attributed `ProductChange` record that
enters an approval queue, so no change to a shared product is silently written.
Each record carries typed actions and a status lifecycle, and the trail records
who changed what and who approved it.
**Product Edit ≠ product ownership.** Products are the shared master catalog,
and sellers sell against them through offers. Product Edit governs *changes* to
a product. It never makes a store the owner of one.
## Key features
* **Immutable audit trail:** every edit is an attributed `ProductChange` that is never rewritten, only appended to and resolved.
* **Typed change actions:** each edit is expressed as `ProductChangeAction` rows such as `UPDATE`, `VARIANT_*`, `ATTRIBUTE_*`, `STATUS_CHANGE`, or `PRODUCT_ADD/DELETE`.
* **Approval queue:** a change moves `pending` → `confirmed` / `declined` / `canceled`, each through a dedicated workflow.
* **Deferred application:** actions are applied to the product only on confirmation, then marked `applied`.
* **Auto-confirm:** when review is off, staged changes confirm and apply immediately.
* **Revision requests:** operators send a submission back to the vendor as a recorded `CHANGE_REQUESTED` audit action.
* **One active change per product:** a product can't have two pending changes at once.
## Get started
Learn how the domain fits together:
The `ProductChange` record, immutability, and the audit trail.
The typed actions that make up a change and their `details`.
The status lifecycle, revision requests, and auto-confirm.
## Examples
Build against the Product Edit domain in your own code:
Stage a product change from server code.
Resolve a pending change in code.
Send a submission back to the vendor.
## Resources
Data models, workflows, service methods, and events for the Product Edit domain:
The `ProductChange` and `ProductChangeAction` entities.
How the Product Edit domain links to other modules.
Create, confirm, decline, cancel, and stage workflows.
Module service methods for working with records directly.
Events emitted as changes move through the pipeline.
# Data models
Source: https://docs.mercurjs.com/platform/product-edit/reference/data-models
The data models owned by the Product Edit domain.
The Product Edit domain is owned by the **Product Edit module**
(`MercurModules.PRODUCT_EDIT`). This reference lists its data models and their
fields.
## ProductChange
Table `product_change`, id prefix `prodch`. A single reviewable edit to one
product.
| Field | Type | Notes |
| ------------------------------------------- | --------------- | -------------------------------------------------- |
| `id` | text | Primary key |
| `product_id` | text | The product this change targets (indexed) |
| `status` | enum | `ProductChangeStatus`, default `pending` (indexed) |
| `internal_note` | text | Nullable; operator-only note |
| `external_note` | text | Nullable; vendor-facing note |
| `created_by` | text | Nullable; actor who staged the change |
| `confirmed_by` / `confirmed_at` | text / dateTime | Nullable; set on confirm |
| `declined_by` / `declined_at` | text / dateTime | Nullable; set on decline |
| `declined_reason` | text | Nullable |
| `canceled_by` / `canceled_at` | text / dateTime | Nullable; set on cancel |
| `requires_action_by` / `requires_action_at` | text / dateTime | Nullable |
| `requires_action_reason` | text | Nullable |
| `metadata` | json | Nullable |
Relations: `actions` (one-to-many `ProductChangeAction`, cascade-deleted with the
change).
The `status` enum has four values: `pending`, `confirmed`, `declined`, and
`canceled`. A revision request is recorded as a `CHANGE_REQUESTED` audit action
rather than a distinct status.
## ProductChangeAction
Table `product_change_action`, id prefix `prodchact`. A single typed operation
inside a change.
| Field | Type | Notes |
| ------------------- | ------------- | ---------------------------------------------------------------- |
| `id` | text | Primary key |
| `product_id` | text | The product the action targets (indexed) |
| `product_change_id` | text | Nullable FK to the parent change (`ON DELETE SET NULL`, indexed) |
| `ordering` | autoincrement | Deterministic apply order (indexed) |
| `action` | text | A `ProductChangeActionType` value |
| `details` | json | Default `{}`; the operation payload |
| `internal_note` | text | Nullable |
| `applied` | boolean | Default `false`; set `true` once written to the product |
## Enums
`ProductChangeStatus`: `pending`, `confirmed`, `declined`, `canceled`.
`ProductChangeActionType`: `UPDATE`, `STATUS_CHANGE`, `VARIANT_ADD`,
`VARIANT_UPDATE`, `VARIANT_REMOVE`, `ATTRIBUTE_ADD`, `ATTRIBUTE_UPDATE`,
`ATTRIBUTE_REMOVE`, `PRODUCT_ADD`, `PRODUCT_DELETE`, `CHANGE_REQUESTED`.
Both enums are exported from `@mercurjs/types`.
# Event reference
Source: https://docs.mercurjs.com/platform/product-edit/reference/events
Events emitted by the Product Edit domain, for subscribers and side effects.
The Product Edit domain emits events as changes move through the pipeline.
Subscribe to them to run side effects instead of polling. Use them to notify a
vendor, sync external systems, or kick off follow-up workflows.
```ts title="src/subscribers/product-change-confirmed.ts" theme={null}
import type { SubscriberArgs, SubscriberConfig } from "@medusajs/framework"
export default async function productChangeConfirmedHandler({
event,
container,
}: SubscriberArgs<{ id: string }>) {
const changeId = event.data.id
// ...notify the vendor, sync search, etc.
}
export const config: SubscriberConfig = {
event: "product-change.confirmed",
}
```
## Change events
| Event | Emitted when | Payload |
| -------------------------- | --------------------- | -------- |
| `product-change.created` | A change is staged | `{ id }` |
| `product-change.confirmed` | A change is confirmed | `{ id }` |
| `product-change.declined` | A change is declined | `{ id }` |
| `product-change.canceled` | A change is canceled | `{ id }` |
## Revision events
| Event | Emitted when | Payload |
| -------------------------- | ------------------------------- | --------------------------- |
| `product.change-requested` | An operator requests a revision | `{ id, message, actor_id }` |
`product.change-requested` carries the **product** id (not a change id) along
with the operator's `message` and `actor_id`, because a revision request is
recorded against the product's audit trail rather than resolving a pending
change.
# Links to other modules
Source: https://docs.mercurjs.com/platform/product-edit/reference/links
How the Product Edit domain links to other modules across the marketplace.
Modules in Mercur never reference each other directly. They connect through
**module links**. Once a link is defined, you retrieve related records with
`query.graph` using the link alias.
## Product
The Product Edit domain links to the Product module so a product's change
history hangs off the product itself.
| Linked module | Relationship |
| ------------- | -------------------------------------------------------------------------------------------------- |
| **Product** | A product has many changes (`product_change.product_id`, read-only). Exposed as `product.changes`. |
```ts theme={null}
const { data: products } = await query.graph({
entity: "product",
fields: ["id", "title", "changes.status", "changes.actions.action"],
})
```
The link is **read-only** and has no pivot table. The FK lives directly on the
`product_change` row. It exists so you read the audit trail through
`product.changes`. You can't write a change through the link itself. Stage
changes with the [workflows](/platform/product-edit/reference/workflows).
## Actor references
The `created_by`, `confirmed_by`, `declined_by`, and `canceled_by` fields hold
actor ids (the dashboard user who staged or resolved the change). They are plain
text references for the audit trail, not module links, so they aren't resolved
through `query.graph`.
# Service reference
Source: https://docs.mercurjs.com/platform/product-edit/reference/service
The Product Edit module service methods for working with records directly.
The Product Edit module exposes a service you can resolve from the Medusa
container to read and write records directly, without going through a workflow.
Use it inside custom services, subscribers, or scheduled jobs.
```ts theme={null}
import { MercurModules } from "@mercurjs/types"
const productChangeModuleService = container.resolve(MercurModules.PRODUCT_EDIT)
const [changes, count] = await productChangeModuleService.listAndCountProductChanges({
status: "pending",
})
```
## Generated methods
Each data model gets a standard set of auto-generated methods. For
`ProductChange`:
| Method | Description |
| ----------------------------------------------- | ------------------------------- |
| `createProductChanges(data)` | Create one or more changes |
| `retrieveProductChange(id, config?)` | Retrieve a change by id |
| `listProductChanges(filters?, config?)` | List changes matching filters |
| `listAndCountProductChanges(filters?, config?)` | List changes with a total count |
| `updateProductChanges(data)` | Update one or more changes |
| `deleteProductChanges(ids)` | Delete one or more changes |
The same set exists for `ProductChangeAction`: `createProductChangeActions`,
`listProductChangeActions`, `updateProductChangeActions`, and so on.
Prefer [workflows](/platform/product-edit/reference/workflows) for anything with
side effects (confirming, declining, applying actions). The service writes
records directly and does **not** apply actions to the product, emit events, or
run compensation.
# Workflows
Source: https://docs.mercurjs.com/platform/product-edit/reference/workflows
Product change workflows, service methods, and events.
This reference lists the workflows for the Product Edit domain. Import them from
`@mercurjs/core/workflows` and run them against the Medusa container.
## Lifecycle workflows
| Workflow | Input | Purpose |
| ---------------------------------- | --------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
| `createProductChangeWorkflow` | `{ changes[], additional_data? }` | Create pending changes (rejects if one is already pending) |
| `stageProductChangeWorkflow` | `{ product_id, actions[], created_by?, internal_note?, external_note?, auto_confirm? }` | Create a change with actions, then run auto-confirm |
| `confirmProductChangeWorkflow` | `{ ids[], confirmed_by?, internal_note?, external_note? }` | Mark `confirmed` and apply pending actions |
| `rejectProductChangeWorkflow` | `{ id, declined_by?, declined_reason? }` | Move a `pending` change to `declined` |
| `cancelProductChangeWorkflow` | `{ id, canceled_by? }` | Move a `pending` change to `canceled` |
| `autoConfirmProductChangeWorkflow` | `{ change_id, confirmed_by?, force? }` | Confirm when review is off, or when `force` is set |
## Apply workflows
| Workflow | Input | Purpose |
| -------------------------------------------- | ------------------------------------------- | ----------------------------------------------------------------------------- |
| `applyProductChangeActionsWorkflow` | `{ change_ids[] }` | Bucket not-yet-applied actions and dispatch them to product/variant workflows |
| `applyProductAttributeChangeActionsWorkflow` | `{ product_id, add[], remove[], update[] }` | Apply the attribute batch for a change |
## Edit helpers
High-level workflows that diff your input and stage the right actions for you.
| Workflow | Input | Purpose |
| ------------------------------------- | ------------------------------------- | ------------------------------------------------- |
| `productEditUpdateProductWorkflow` | `{ product_id, update, created_by? }` | Diff and stage `UPDATE` actions per changed field |
| `productEditUpdateVariantsWorkflow` | `{ product_id, ... }` | Stage `VARIANT_*` actions |
| `productEditUpdateAttributesWorkflow` | `{ product_id, ... }` | Stage `ATTRIBUTE_*` actions |
| `productEditDeleteProductWorkflow` | `{ product_id, ... }` | Stage a `PRODUCT_DELETE` action |
## Audit-trail workflows
| Workflow | Input | Purpose |
| ---------------------------------- | ------------------------------------- | -------------------------------------------------------------------- |
| `recordProductAuditChangeWorkflow` | `{ actor_id?, changes[] }` | Record already-`confirmed` audit changes (actions stored `applied`) |
| `requestProductChangeWorkflow` | `{ product_id, message?, actor_id? }` | Record a `CHANGE_REQUESTED` revision request on a `proposed` product |
To work with records directly instead of through a workflow, see the
[Service reference](/platform/product-edit/reference/service). To run side effects
when a change resolves, see the
[Event reference](/platform/product-edit/reference/events).
# Product vs seller reviews
Source: https://docs.mercurjs.com/platform/review/concepts/product-vs-seller-reviews
The reference discriminator and the links that anchor each review.
This page covers how one review model serves two targets and how each review is
anchored to the rest of the marketplace.
## Reference
The `reference` field on the `Review` model decides what a review is about. It's
an enum with two values, `product` and `seller`, and it works together with the
review's link to the target record. There is no polymorphic id column on the
model itself; the target is resolved through a module link.
```ts theme={null}
// A product review
{ reference: "product", reference_id: "prod_123", rating: 5 }
// A seller review
{ reference: "seller", reference_id: "sel_123", rating: 4 }
```
When a review is created, the `reference` value selects which link is written:
`reference: "product"` links the review to a product, and `reference: "seller"`
links it to a seller. Reading a review back, you follow the matching relation
(`review.product` or `review.seller`) to reach its target.
Products are the shared master catalog, not seller-owned. A **product** review
rates the master product; a **seller** review rates the store. They are
independent. A customer can leave both for the same order.
## Order & customer links
Beyond its target, every review is anchored to the order that earned it and the
customer who wrote it. Creating a review writes two more links, one to the
`Order` and one to the `Customer`, so a review always has a verifiable purchase
behind it.
```ts theme={null}
// resolved through the customer link when listing a customer's own reviews
const { data } = await query.graph({
entity: "customer_customer_review_review",
fields: ["review.*"],
filters: { customer_id: "cus_123" },
})
```
## One review per target, per order
Because a review is tied to an order, the create flow enforces that a customer
can leave **at most one review per target per order**. Submitting a second review
for the same `reference` and `reference_id` on the same order is rejected. The
order must also belong to the customer submitting the review.
A single order can still produce several reviews, one per distinct target. For
example, a customer may review the master product *and* the store that
fulfilled it from the same order.
# Ratings & moderation
Source: https://docs.mercurjs.com/platform/review/concepts/ratings-and-moderation
The review status lifecycle, store responses, and aggregate ratings.
This page covers how a review moves from submission to a public rating, and how
per-product and per-seller averages are computed.
## Status
A review's state is held in the `status` field of the `Review` model. A review
moves through three statuses:
```
┌──────────┐ publish ┌────────────┐
│ pending │───────────►│ published │
└────┬─────┘ └────────────┘
│ reject
▼
┌────────────┐
│ rejected │
└────────────┘
```
| Status | Meaning |
| ----------- | ------------------------------------------------------- |
| `pending` | Submitted, awaiting moderation. The default on creation |
| `published` | Approved and visible on the storefront |
| `rejected` | Declined by a moderator |
A review is created as `pending`. Moderation moves it to `published` or
`rejected` by updating the `status` field. See
[Moderate a review](/platform/review/guides/moderate-a-review).
## Store responses
A store can attach a single public response to any of its reviews. The response
lives in the `seller_note` field and is added through a dedicated respond flow,
which refuses to overwrite an existing response.
Responding is separate from moderation. A store adds its `seller_note`, while
the `status` transition (`published` / `rejected`) stays an operator decision.
## Aggregate ratings
The module service computes average ratings on demand rather than storing a
denormalized column. `getAvgRating` returns the average for a single product or
seller, and `getProductsWithRating` / `getSellersWithRating` return records with
their average rating joined in for list views.
```ts theme={null}
const service = container.resolve(MercurModules.REVIEW)
const avg = await service.getAvgRating("seller", "sel_123")
```
Because averages are computed at query time, they always reflect the current
set of reviews. There's no cache to invalidate when a review is added, removed,
or moderated.
# The review model
Source: https://docs.mercurjs.com/platform/review/concepts/the-review-model
The single review entity, its rating, notes, and moderation status.
This page covers the review record and the fields that make up a rating.
## Review
A review is a customer's rating of a single target, either a product or a seller.
It's represented by the `Review` data model (table `review`, id prefix `rev`). It
holds the numeric rating, an optional customer note, an optional store response,
and the moderation status.
```ts theme={null}
const { result } = await createReviewWorkflow(container).run({
input: {
order_id: "order_123",
reference: "product",
reference_id: "prod_123",
rating: 5,
customer_note: "Exactly as described, fast shipping.",
customer_id: "cus_123",
},
})
```
Every review carries the same shape regardless of what it targets:
| Field | Purpose |
| --------------- | ----------------------------------------------------------- |
| `rating` | The numeric score the customer gave |
| `reference` | Whether the review is about a `product` or a `seller` |
| `customer_note` | The customer's optional free-text note |
| `seller_note` | The store's optional public response |
| `status` | The moderation state: `pending`, `published`, or `rejected` |
| `display_id` | A human-readable auto-incrementing number |
There is no separate table for product reviews and seller reviews. A single
`Review` row is discriminated by its `reference` field. See
[Product vs seller reviews](/platform/review/concepts/product-vs-seller-reviews).
## Notes
A review separates the two sides of the conversation into two nullable text
fields. `customer_note` is written by the customer when they submit the review;
`seller_note` is the store's single response, added later through the respond
flow. Both are searchable so operators can find reviews by their content.
A store can respond **once**. The respond flow refuses to overwrite an existing
`seller_note`. To change a response, clear it first.
# Compute aggregate ratings
Source: https://docs.mercurjs.com/platform/review/guides/compute-aggregate-ratings
Compute average ratings for products and sellers from server code.
In this guide, you'll learn how to compute average ratings for products and
sellers from your own server code. This is useful when surfacing a rating on a
storefront page or in a custom API route.
The Review module service computes averages on demand instead of storing a
denormalized column, so the numbers always reflect the current set of reviews.
Resolve the service from the container to use its rating helpers.
## Average for one target
`getAvgRating` returns the average rating for a single product or seller:
```ts title="src/api/custom/route.ts" theme={null}
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
import { MercurModules } from "@mercurjs/types"
export async function GET(req: MedusaRequest, res: MedusaResponse) {
const reviewModuleService = req.scope.resolve(MercurModules.REVIEW)
const rating = await reviewModuleService.getAvgRating("seller", "sel_123")
res.json({ rating })
}
```
The first argument is the target type (`"product"` or `"seller"`), the second is
its id. The method returns `null` when the target has no reviews yet.
## Ratings for a list
For list views, `getProductsWithRating` and `getSellersWithRating` return records
with their average rating joined in, so you don't fan out a call per row:
```ts theme={null}
const reviewModuleService = container.resolve(MercurModules.REVIEW)
const sellers = await reviewModuleService.getSellersWithRating([
"id",
"name",
])
// => [{ id, name, rating }, ...]
```
Pass the fields you want selected from the target table; each returned record
gains a `rating` field with the average.
These helpers average across a target's linked reviews. Combine them with your
own `status` filter if you only want `published` reviews to count toward the
public number.
# Create a review
Source: https://docs.mercurjs.com/platform/review/guides/create-a-review
Create a review programmatically with createReviewWorkflow.
In this guide, you'll learn how to create a review from your own server code. This
is useful in a custom storefront route, an import script, or a seed.
Mercur exposes a `createReviewWorkflow` that validates the submission, creates the
`Review` record, and links it to its target, order, and customer in one step. Run
it from any place that has access to the Medusa container.
## Run the workflow
```ts title="src/api/custom/route.ts" theme={null}
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
import { createReviewWorkflow } from "@mercurjs/core/workflows"
export async function POST(req: MedusaRequest, res: MedusaResponse) {
const { result } = await createReviewWorkflow(req.scope).run({
input: {
order_id: "order_123",
reference: "product",
reference_id: "prod_123",
rating: 5,
customer_note: "Exactly as described.",
customer_id: "cus_123",
},
})
res.status(201).json({ review: result })
}
```
The `reference` field selects the target: pass `"product"` with a product id, or
`"seller"` with a seller id, in `reference_id`.
The workflow validates that the `order_id` belongs to `customer_id` and that
the customer hasn't already reviewed the same target on that order. Either
check failing raises an error and no review is created.
## Statuses on creation
A new review is created as `pending` and stays hidden until it's moderated. See
[Moderate a review](/platform/review/guides/moderate-a-review) to publish or
reject it.
A single order can back several reviews, one per distinct target. Create the
product review and the seller review as two separate calls with different
`reference` / `reference_id` values.
# Moderate a review
Source: https://docs.mercurjs.com/platform/review/guides/moderate-a-review
Publish, reject, and delete reviews from server code.
In this guide, you'll learn how to moderate reviews from your own server code.
Moderation is a change to the review's `status`; removal is a soft delete that
can be undone.
## Publish or reject
Move a `pending` review to `published` or `rejected` with `updateReviewWorkflow`,
setting the `status` field:
```ts title="src/api/custom/moderate/route.ts" theme={null}
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
import { updateReviewWorkflow } from "@mercurjs/core/workflows"
export async function POST(req: MedusaRequest, res: MedusaResponse) {
await updateReviewWorkflow(req.scope).run({
input: {
id: req.params.id,
status: "published",
},
})
res.sendStatus(200)
}
```
`updateReviewWorkflow` also accepts `rating`, `customer_note`, and `seller_note`,
so the same workflow is used for any correction to a review. It captures the
previous values for compensation, so a failure downstream rolls the record back.
## Delete a review
Remove a review with `deleteReviewWorkflow`. This is a **soft delete**. The row
is retained and restored automatically if the workflow is rolled back:
```ts theme={null}
import { deleteReviewWorkflow } from "@mercurjs/core/workflows"
await deleteReviewWorkflow(container).run({ input: "rev_123" })
```
A soft-deleted review stops counting toward aggregate ratings, but its row (and
its links) are kept. Use a hard delete through the service only if you need the
record gone permanently.
## React to moderation
To run your own side effects when a review is published or rejected, wrap
`updateReviewWorkflow` in a route that also runs your follow-up logic, or add a
hook. See the [Events reference](/platform/review/reference/events) for the
current state of review-domain events.
# Respond to a review
Source: https://docs.mercurjs.com/platform/review/guides/respond-to-a-review
Attach a store's public response with respondReviewWorkflow.
In this guide, you'll learn how to attach a store's response to a review from your
own server code. A response is a single public note the store adds to a review it
received.
## Run the workflow
`respondReviewWorkflow` writes the `seller_note` on an existing review:
```ts title="src/api/custom/respond/route.ts" theme={null}
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
import { respondReviewWorkflow } from "@mercurjs/core/workflows"
export async function POST(req: MedusaRequest, res: MedusaResponse) {
await respondReviewWorkflow(req.scope).run({
input: {
id: req.params.id,
seller_note: "Thanks for the feedback, glad it arrived quickly!",
},
})
res.sendStatus(200)
}
```
A store can respond **once**. The workflow throws if the review already has a
`seller_note`, and it throws `NOT_FOUND` if the review id doesn't exist. It
captures the previous value so a rollback clears the response again.
## Clearing a response
The respond flow won't overwrite an existing note. To replace a response, first
clear it with `updateReviewWorkflow`, then respond again:
```ts theme={null}
import {
updateReviewWorkflow,
respondReviewWorkflow,
} from "@mercurjs/core/workflows"
await updateReviewWorkflow(container).run({
input: { id: "rev_123", seller_note: null },
})
await respondReviewWorkflow(container).run({
input: { id: "rev_123", seller_note: "Updated response." },
})
```
Responding never changes the review's `status`. A store can respond to a
`pending` review, but the response only becomes public once the review is
[published](/platform/review/guides/moderate-a-review).
# Review
Source: https://docs.mercurjs.com/platform/review/overview
Collect, moderate, and surface customer ratings for products and sellers.
Use Mercur to let customers rate the products they bought and the stores they
bought from.
The Review domain captures a single rating model that points at either a product
or a seller. It ties each review back to the order that earned it, and it moves
through a moderation lifecycle before it goes public. Stores can respond to their
reviews, and aggregate ratings roll up onto public seller and product pages. All
of it is exposed directly through the Admin, Vendor, and Store APIs.
**One model, two targets.** A single `Review` entity (id prefix `rev`) covers
both product and seller reviews. A `reference` discriminator decides which one
a given review is about. There is no separate product-review or seller-review
table.
## Key features
* **Product & seller reviews:** one rating model, discriminated by a `reference` field.
* **Order-backed:** every review is linked to the order that earned it, with one review per target per order.
* **Moderation lifecycle:** reviews start `pending` and are moderated to `published` or `rejected`.
* **Store responses:** a store can attach a single public response to each of its reviews.
* **Aggregate ratings:** average ratings computed per product and per seller for storefront display.
* **Customer notes:** an optional free-text note alongside the numeric rating.
## Get started
Learn how the domain fits together.
The single review entity, its rating, notes, and status.
The reference discriminator and the links that anchor each review.
The status lifecycle, store responses, and aggregate ratings.
## Examples
Build against the Review domain in your own code.
Run `createReviewWorkflow` from a route or script.
Publish, reject, and delete reviews in code.
Attach a store's response with `respondReviewWorkflow`.
Compute average ratings for products and sellers.
## Resources
Data models, links, workflows, service methods, and events for the Review domain.
The `Review` entity and its fields.
How reviews link to products, sellers, orders, and customers.
Create, update, respond, and delete workflows.
Module service methods, including aggregate-rating helpers.
Running side effects as reviews change.
# Data models
Source: https://docs.mercurjs.com/platform/review/reference/data-models
The data models owned by the Review domain.
The Review domain is owned by the **Review module**. This reference lists its
data model and fields.
## Review
Table `review`, id prefix `rev`. A customer's rating of a single product or
seller.
| Field | Type | Notes |
| --------------- | -------- | -------------------------------------------------------- |
| `id` | text | Primary key |
| `display_id` | serial | Human-readable auto-incrementing number |
| `reference` | enum | `product` or `seller`, the review's target type |
| `rating` | integer | The numeric score |
| `customer_note` | text | Nullable, searchable. The customer's note |
| `seller_note` | text | Nullable, searchable. The store's single response |
| `status` | enum | `pending`, `published`, or `rejected`; default `pending` |
| `created_at` | dateTime | Set on creation |
| `updated_at` | dateTime | Updated on change |
| `deleted_at` | dateTime | Nullable; set by soft delete |
The target record itself is not a column on this model. It's resolved through a
module link chosen by the `reference` value. See the
[Links reference](/platform/review/reference/links).
The `review` table is indexed on `deleted_at` for soft-delete filtering.
Deleting a review through the workflow is a soft delete. The row is retained
and can be restored.
# Event reference
Source: https://docs.mercurjs.com/platform/review/reference/events
Running side effects as reviews are created, moderated, and answered.
The Review workflows handle validation, links, moderation, and compensation. They
do **not** currently emit their own domain events. There is no `review.created`
or `review.published` event to subscribe to today.
To run side effects when a review changes, wrap the review workflows in your own
route or workflow and run the follow-up logic there, or emit your own event and
subscribe to it.
## Emit your own event
Emit an event alongside the workflow, then handle it in a subscriber:
```ts title="src/api/custom/route.ts" theme={null}
import { Modules } from "@medusajs/framework/utils"
import { createReviewWorkflow } from "@mercurjs/core/workflows"
export async function POST(req, res) {
const { result } = await createReviewWorkflow(req.scope).run({
input: req.body,
})
const eventBus = req.scope.resolve(Modules.EVENT_BUS)
await eventBus.emit({ name: "review.created", data: { id: result.id } })
res.status(201).json({ review: result })
}
```
```ts title="src/subscribers/review-created.ts" theme={null}
import type { SubscriberArgs, SubscriberConfig } from "@medusajs/framework"
export default async function reviewCreatedHandler({
event,
container,
}: SubscriberArgs<{ id: string }>) {
const reviewId = event.data.id
// ...notify the store, update a search index, etc.
}
export const config: SubscriberConfig = {
event: "review.created",
}
```
Because the module ships no events of its own, the event name in the example
above is one **you** define. Keep it consistent across the emit site and the
subscriber.
## React without an event
For side effects that must run transactionally with the review change, add a step
to your own workflow that wraps the review workflow, rather than relying on an
event. Events are handled asynchronously and outside the workflow's compensation.
# Links to other modules
Source: https://docs.mercurjs.com/platform/review/reference/links
How the Review domain links to products, sellers, orders, and customers.
Modules in Mercur never reference each other directly. They connect through
**module links**. A review carries no foreign keys to its target on the model
itself. Instead, four links anchor each review to the rest of the marketplace.
Once a link is defined, you retrieve related records with `query.graph` using the
link alias.
```ts theme={null}
const { data: reviews } = await query.graph({
entity: "review",
fields: ["id", "rating", "product.*", "seller.*"],
})
```
## Targets
| Linked module | Relationship |
| ------------- | ---------------------------------------------------------------------------------------------------- |
| **Product** | A product has many reviews (`product_product_review_review`). Written when `reference` is `product`. |
| **Seller** | A seller has many reviews (`seller_seller_review_review`). Written when `reference` is `seller`. |
A review is linked to **either** a product or a seller, never both. The
`reference` field on the review decides which link is created.
## Provenance
| Linked module | Relationship |
| ------------- | --------------------------------------------------------------------------------------- |
| **Order** | Each review is linked to the order that earned it (`order_order_review_review`). |
| **Customer** | Each review is linked to the customer who wrote it (`customer_customer_review_review`). |
These two links are written for every review and back the validation that a
customer can review a target only once per order.
All four links are list links (`isList: true`) on the owning side. A product,
seller, order, or customer has many reviews.
# Service reference
Source: https://docs.mercurjs.com/platform/review/reference/service
The Review module service: CRUD methods and aggregate-rating helpers.
The Review module exposes a service you can resolve from the Medusa container to
read and write records directly, without going through a workflow. Use it inside
custom services, subscribers, scheduled jobs, or route handlers.
```ts theme={null}
import { MercurModules } from "@mercurjs/types"
const reviewModuleService = container.resolve(MercurModules.REVIEW)
const [reviews, count] = await reviewModuleService.listAndCountReviews({
status: "published",
})
```
## Generated methods
The `Review` model gets a standard set of auto-generated methods:
| Method | Description |
| ---------------------------------------- | -------------------------------- |
| `createReviews(data)` | Create one or more reviews |
| `retrieveReview(id, config?)` | Retrieve a review by id |
| `listReviews(filters?, config?)` | List reviews matching filters |
| `listAndCountReviews(filters?, config?)` | List reviews with a total count |
| `updateReviews(data)` | Update one or more reviews |
| `deleteReviews(ids)` | Delete one or more reviews |
| `softDeleteReviews(ids)` | Soft-delete reviews (restorable) |
| `restoreReviews(ids)` | Restore soft-deleted reviews |
## Aggregate-rating methods
The service adds three helpers that compute average ratings on demand:
| Method | Description |
| ------------------------------- | ------------------------------------------------------------------------------- |
| `getAvgRating(type, id)` | Average rating for one `"product"` or `"seller"`; `null` when it has no reviews |
| `getProductsWithRating(fields)` | Product records with an average `rating` joined in |
| `getSellersWithRating(fields)` | Seller records with an average `rating` joined in |
See [Compute aggregate ratings](/platform/review/guides/compute-aggregate-ratings)
for usage.
Prefer [workflows](/platform/review/reference/workflows) for anything with side
effects (creation with its validation and links, responses, moderation). The
service writes records directly and does **not** run the create-flow validation
or manage the target/order/customer links.
# Workflows
Source: https://docs.mercurjs.com/platform/review/reference/workflows
Review workflows for creating, moderating, responding, and deleting.
This reference lists the workflows for the Review domain. Import them from
`@mercurjs/core/workflows` and run them against the Medusa container.
## Review workflows
| Workflow | Input | Purpose |
| ----------------------- | ---------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
| `createReviewWorkflow` | `{ order_id, reference, reference_id, rating, customer_note?, customer_id }` | Validate and create a review, linking it to its target, order, and customer |
| `updateReviewWorkflow` | `{ id, rating?, customer_note?, seller_note?, status? }` | Update a review, including moderating its `status` |
| `respondReviewWorkflow` | `{ id, seller_note }` | Attach a store's single response to a review |
| `deleteReviewWorkflow` | `id` | Soft-delete a review (restorable on rollback) |
Each workflow supports compensation: `createReviewWorkflow` deletes the review it
created on failure, `updateReviewWorkflow` and `respondReviewWorkflow` restore the
previous values, and `deleteReviewWorkflow` restores the soft-deleted row.
`createReviewWorkflow` validates that the order belongs to the customer and
that the target hasn't already been reviewed on that order. `respondReviewWorkflow`
refuses to overwrite an existing response.
To work with records directly instead of through a workflow, see the
[Service reference](/platform/review/reference/service). To run side effects when
a review changes, see the [Event reference](/platform/review/reference/events).
# Lifecycle
Source: https://docs.mercurjs.com/platform/store/concepts/lifecycle
Store statuses, transitions, scheduled closures, and premium.
This page covers the store account lifecycle and the states a store moves through.
## Status
A store's state lives in the `status` field of the `Seller` model, typed by the
`SellerStatus` enum. A store moves through four statuses.
```
┌───────────────────┐
│ pending_approval │
└─────────┬─────────┘
│ approve
▼
┌───────────┐ ┌────────┐
│ suspended │◄───►│ open │
└───────────┘ └───┬────┘
│ terminate
▼
┌────────────┐
│ terminated │
└────────────┘
```
| Status | Meaning |
| ------------------ | --------------------------------------------------------------- |
| `pending_approval` | Registered, waiting for operator review. |
| `open` | Active. Can list offers, take orders, and collect payouts. |
| `suspended` | Temporarily frozen. Offers stay listed but cannot be purchased. |
| `terminated` | Permanently closed. This status is irreversible. |
Only the operator can change a store's status. Each transition runs through a
dedicated workflow: `approveSellerWorkflow`, `suspendSellerWorkflow`,
`unsuspendSellerWorkflow`, and `terminateSellerWorkflow`.
Termination is irreversible. All orders and payouts must be resolved before a
store can be terminated.
## Scheduled closures
A store can schedule a temporary closure with the `closed_from` and `closed_to`
fields without changing its status. During the window, the storefront shows as
unavailable and no new orders are accepted. The account stays `open` and resumes
on its own once `closed_to` passes.
A closure overlays the current status. It is not a status of its own, and it
does not affect the transition rules.
## Premium
The `is_premium` boolean is set only by the operator. Stores cannot designate
themselves as premium. The storefront uses the flag for featured placement,
badges, and curation priority.
# The store entity
Source: https://docs.mercurjs.com/platform/store/concepts/store-entity
The seller record, its business identity, and its satellite data.
This page covers the store record and the data models that make up its business
identity.
## Store
A store is the marketplace vendor. It owns offers, orders, and payouts, and it
holds the profile shown to customers on the storefront. A store is represented by
the `Seller` data model (table `seller`, id prefix `sel`).
```ts theme={null}
const { result } = await createSellersWorkflow(container).run({
input: {
sellers: [
{
name: "Acme Supplies",
email: "team@acme.com",
currency_code: "usd",
},
],
},
})
```
A store settles in exactly one currency, set by `currency_code`. A seller who
needs to trade in several currencies creates a separate store for each one, and
each store operates independently.
## Professional details
A store can carry professional details: a corporate name, a registration number,
and a tax id. These are represented by the `ProfessionalDetails` data model. When
this record is present, the store is a registered business. When it is absent,
the store is an individual seller.
## Addresses and payment details
A store keeps its addresses as `SellerAddress` records, one each for a warehouse,
return, or business address. The bank details used to settle payouts live on a
`PaymentDetails` record. Both are satellite records of the store, and fulfillment
and payout settlement read from them.
`ProfessionalDetails`, `SellerAddress`, and `PaymentDetails` are one-to-one with
the store and are deleted along with it.
# Team & members
Source: https://docs.mercurjs.com/platform/store/concepts/team
Members, roles, invites, and the many-to-many store relationship.
This page covers how a store's team is modeled and how access is structured.
## Member
A member is a dashboard user who can manage one or more stores. A member is
represented by the `Member` data model (table `member`, id prefix `mem`). Their
identity is tied to their `email`, which is unique across the system.
## Seller member
The relationship between stores and members is many-to-many. A store can have
several members, and a member can belong to several stores. Each membership is a
`SellerMember` record, the pivot between `Seller` and `Member`, unique on
`(seller_id, member_id)`.
```ts theme={null}
await addSellerMemberWorkflow(container).run({
input: {
seller_id: "sel_123",
member: { email: "ops@acme.com" },
},
})
```
Every membership carries a `role_id`, resolved against the RBAC roles module, and
an `is_owner` flag.
A store must always keep at least one member with admin access. The last admin
cannot be removed or demoted.
## Member invite
Pending invitations are represented by the `MemberInvite` data model. If an
invited email already belongs to a user, accepting the invite links the new store
association to their existing account instead of creating a new one.
## Store switcher
A user who belongs to several stores uses the store switcher in the Vendor panel
to change the active store. Each store context is fully isolated. Switching stores
shows only that store's offers, orders, and payouts, and it never leaks data
across stores.
# Create a store
Source: https://docs.mercurjs.com/platform/store/guides/create-a-store
Create a seller programmatically with createSellersWorkflow.
In this guide, you'll learn how to create a store from your own server code. This
is useful in a seed script, a custom API route, or an onboarding flow.
Mercur exposes a `createSellersWorkflow` that creates the `Seller` record along
with its defaults. Run it from any place that has access to the Medusa container.
## Run the workflow
```ts title="src/api/custom/route.ts" theme={null}
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
import { createSellersWorkflow } from "@mercurjs/core/workflows"
export async function POST(req: MedusaRequest, res: MedusaResponse) {
const { result } = await createSellersWorkflow(req.scope).run({
input: {
sellers: [
{
name: "Acme Supplies",
email: "team@acme.com",
currency_code: "usd",
},
],
},
})
res.status(201).json({ seller: result[0] })
}
```
An operator can set a new store to `open` right away. A store created through
the public self-registration flow starts in `pending_approval` and waits for
operator review. See [Moderate stores](/platform/store/guides/moderate-a-store).
## Attach custom data
The workflow accepts an `additional_data` payload. It is passed to the workflow's
hooks, so you can persist marketplace-specific data alongside the store without
forking the workflow.
```ts theme={null}
await createSellersWorkflow(req.scope).run({
input: {
sellers: [{ name: "Acme Supplies", email: "team@acme.com", currency_code: "usd" }],
additional_data: { referral_code: "SPRING25" },
},
})
```
# Manage the team
Source: https://docs.mercurjs.com/platform/store/guides/manage-the-team
Invite and add store members from server code.
In this guide, you'll learn how to manage a store's team from your own server
code. You can invite members, add them directly, and update their roles.
## Invite a member
`inviteSellerWorkflow` creates a `MemberInvite` and sends the invitation email.
The invitee sets up access and is linked to the store when they accept.
```ts title="src/api/custom/invite/route.ts" theme={null}
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
import { inviteSellerWorkflow } from "@mercurjs/core/workflows"
export async function POST(req: MedusaRequest, res: MedusaResponse) {
await inviteSellerWorkflow(req.scope).run({
input: {
email: "ops@acme.com",
seller_id: req.params.id,
},
})
res.sendStatus(200)
}
```
## Add a member directly
When you already have the user, for example during a migration, add them to a
store without the invite step. Use `addSellerMemberWorkflow`.
```ts theme={null}
import { addSellerMemberWorkflow } from "@mercurjs/core/workflows"
await addSellerMemberWorkflow(container).run({
input: {
seller_id: "sel_123",
member: { email: "ops@acme.com" },
},
})
```
Every store must keep at least one admin member. A workflow that would remove or
demote the last admin fails instead of leaving a store unadministered.
## Accept an invite
The invitee's acceptance runs through `acceptMemberInviteWorkflow`. It links the
member to the store, and if the email already exists, it reuses their account.
# Moderate stores
Source: https://docs.mercurjs.com/platform/store/guides/moderate-a-store
Approve, suspend, and terminate stores from server code.
In this guide, you'll learn how to drive a store through its lifecycle from your
own server code. Each transition has a dedicated workflow, so the side effects
(events, notifications, and compensation) run consistently.
## Approve a store
Move a `pending_approval` store to `open` with `approveSellerWorkflow`.
```ts title="src/api/custom/approve/route.ts" theme={null}
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
import { approveSellerWorkflow } from "@mercurjs/core/workflows"
export async function POST(req: MedusaRequest, res: MedusaResponse) {
await approveSellerWorkflow(req.scope).run({
input: { seller_id: req.params.id },
})
res.sendStatus(200)
}
```
## Suspend and reinstate
Freeze an `open` store's operations, then reinstate it once the issue is resolved.
```ts theme={null}
import {
suspendSellerWorkflow,
unsuspendSellerWorkflow,
} from "@mercurjs/core/workflows"
await suspendSellerWorkflow(container).run({ input: { seller_id } })
// ...later
await unsuspendSellerWorkflow(container).run({ input: { seller_id } })
```
## Terminate a store
```ts theme={null}
import { terminateSellerWorkflow } from "@mercurjs/core/workflows"
await terminateSellerWorkflow(container).run({ input: { seller_id } })
```
Termination is irreversible. All orders and payouts must be resolved first.
## React to lifecycle changes
To run your own side effects when a store's status changes, subscribe to the
events these workflows emit instead of polling. The
[Event reference](/platform/store/reference/events) lists the event names.
# Store
Source: https://docs.mercurjs.com/platform/store/overview
Manage sellers, their teams, and their lifecycle on the marketplace.
Use Mercur to control who is allowed to sell on your marketplace and what each
person on a store's team can do.
Governing your sellers starts here. The Store domain decides who may sell,
enforces role-based access so every member acts only within their store, and
keeps each store's data isolated from the rest. On top of that control layer, a
store carries its own team, a governed account lifecycle, and the business
details used to settle payouts. Every offer, order, payout, and storefront page
belongs to a store, and all of it is exposed directly through the Admin and
Vendor APIs.
A store is the `Seller` entity. The Admin and Vendor panels call it a "Store".
The API and core modules call the same record a `Seller` (id prefix `sel`). You
never need to know Medusa to work with stores.
## Key features
* **Role-based access control:** many-to-many members, roles resolved per store, and a store switcher for users who belong to several stores.
* **Store isolation:** each store sees and acts on only its own data.
* **Governed lifecycle:** a four-state account model with operator-only transitions.
* **Self-service onboarding:** create stores yourself, or let sellers register publicly.
* **Scheduled closures:** temporary offline windows that don't change account status.
* **Premium placement:** an operator-only flag the storefront uses for featured curation.
* **Single-currency accounts:** each store settles in exactly one currency.
## Get started
Learn how the domain fits together.
The seller entity, its profile, currency, and business identity.
Multi-user stores, roles, invites, and the store switcher.
Statuses, transitions, scheduled closures, and premium.
## Examples
Build against the Store domain in your own code.
Run `createSellersWorkflow` from a route or seed script.
Approve, suspend, and terminate stores in code.
Invite and add members programmatically.
## Resources
Data models, workflows, service methods, and events for the Store domain.
The `Seller`, `Member`, and related entities.
How the Store domain links to other modules.
Seller and member lifecycle workflows.
Module service methods for working with records directly.
Events emitted as stores and members change.
# Data models
Source: https://docs.mercurjs.com/platform/store/reference/data-models
The data models owned by the Store (Seller) domain.
The Store domain is owned by the **Seller module**. This reference lists its data
models and their fields. For the full module overview, see the
[Store overview](/platform/store/overview).
## Seller
Table `seller`, id prefix `sel`. The store account and profile.
| Field | Type | Notes |
| ----------------------------- | -------- | ----------------------------------------------- |
| `id` | text | Primary key |
| `name` | text | Unique, searchable |
| `handle` | text | Unique; auto-generated from `name` when omitted |
| `email` | text | Unique, searchable |
| `phone` | text | Nullable |
| `description` | text | Nullable |
| `logo` / `banner` | text | Nullable |
| `website_url` | text | Nullable |
| `external_id` | text | Nullable; unique when set |
| `currency_code` | text | The store's single operating currency |
| `status` | enum | `SellerStatus`, default `pending_approval` |
| `status_reason` | text | Nullable |
| `approved_at` / `rejected_at` | dateTime | Nullable |
| `is_premium` | boolean | Default `false`; operator-set only |
| `closed_from` / `closed_to` | dateTime | Nullable; scheduled closure window |
| `closure_note` | text | Nullable |
| `metadata` | json | Nullable |
Relations: `professional_details`, `address`, `payment_details` (one-to-one,
deleted with the seller), `members` (many-to-many through `SellerMember`),
`member_invites` (one-to-many, deleted with the seller).
## ProfessionalDetails
Table `professional_details`, id prefix `selprodet`. Presence marks the store as
a registered business.
| Field | Type |
| --------------------- | -------------- |
| `corporate_name` | text, nullable |
| `registration_number` | text, nullable |
| `tax_id` | text, nullable |
## SellerAddress
Table `seller_address`, id prefix `seladdr`. Fields: `name`, `company`,
`first_name`, `last_name`, `address_1`, `address_2`, `city`, `country_code`,
`province`, `postal_code`, `phone`, and `metadata`. All are nullable.
## PaymentDetails
Table `payment_details`, id prefix `selpaydet`. Fields: `country_code`,
`holder_name`, `bank_name`, `iban`, `bic`, `routing_number`, and
`account_number`. All are nullable.
## Member
Table `member`, id prefix `mem`. A dashboard user that can belong to one or more
stores.
| Field | Type | Notes |
| -------------------------- | ------- | ------------------ |
| `email` | text | Unique, searchable |
| `first_name` / `last_name` | text | Nullable |
| `locale` | text | Nullable |
| `is_active` | boolean | Default `true` |
| `metadata` | json | Nullable |
## SellerMember
Table `seller_member`, id prefix `selmem`. Pivot between `Seller` and `Member`,
unique on `(seller_id, member_id)`.
| Field | Type | Notes |
| ---------- | ------- | ------------------------------------------------ |
| `role_id` | text | Nullable; resolved against the RBAC roles module |
| `is_owner` | boolean | Default `false` |
| `metadata` | json | Nullable |
## MemberInvite
Table `member_invite`, id prefix `meminv`. A pending team invitation, carrying
the invited `email`, target `seller_id`, `role_id`, and an expiry.
# Event reference
Source: https://docs.mercurjs.com/platform/store/reference/events
Events emitted by the Store domain, for subscribers and side effects.
The Store domain emits events as stores and members change. Subscribe to them to
run side effects such as sending notifications, syncing external systems, or
kicking off follow-up workflows, instead of polling.
```ts title="src/subscribers/store-approved.ts" theme={null}
import type { SubscriberArgs, SubscriberConfig } from "@medusajs/framework"
export default async function storeApprovedHandler({
event,
container,
}: SubscriberArgs<{ id: string }>) {
const sellerId = event.data.id
// ...send a welcome email, provision resources, etc.
}
export const config: SubscriberConfig = {
event: "seller.approved",
}
```
## Store events
| Event | Emitted when | Payload |
| ------------------- | ------------------------------ | -------- |
| `seller.created` | A store is created | `{ id }` |
| `seller.updated` | A store's profile changes | `{ id }` |
| `seller.approved` | A store is approved (`→ open`) | `{ id }` |
| `seller.suspended` | A store is suspended | `{ id }` |
| `seller.terminated` | A store is terminated | `{ id }` |
## Member events
| Event | Emitted when | Payload |
| ------------------------ | ---------------------------- | -------- |
| `member_invite.created` | A team invite is sent | `{ id }` |
| `member_invite.accepted` | An invite is accepted | `{ id }` |
| `seller_member.created` | A member is added to a store | `{ id }` |
# Links to other modules
Source: https://docs.mercurjs.com/platform/store/reference/links
How the Store (Seller) domain links to other modules across the marketplace.
Modules in Mercur never reference each other directly. They connect through
**module links**. The Seller module is the most linked domain in the marketplace,
and almost every other module attaches to a store. Once a link is defined, you
retrieve related records with `query.graph` using the link alias.
```ts theme={null}
const { data: stores } = await query.graph({
entity: "seller",
fields: ["id", "name", "offers.*", "payout_account.*"],
})
```
## Commerce
| Linked module | Relationship |
| -------------------- | -------------------------------------------------------------------------------------------------------------- |
| **Offer** | A store has many offers (`offer.seller_id`, read-only). Offers are how a store sells against a master product. |
| **Product** | Many-to-many allowlist (`product_seller`) that controls which stores may sell a given master product. |
| **Product category** | Many-to-many link to the categories a store is associated with. |
| **Order** | Each order belongs to one store; a store has many orders. |
## Payouts
| Linked module | Relationship |
| ------------------ | ---------------------------------------------------------------- |
| **Payout** | A store has many payouts. |
| **Payout account** | A store has one payout account (its provider onboarding record). |
## Customers
| Linked module | Relationship |
| ------------------ | ------------------------------------------------------------------------- |
| **Customer** | Many-to-many link to the customers associated with a store. |
| **Customer group** | A customer group is owned by exactly one store; a store owns many groups. |
## Fulfillment & inventory
| Linked module | Relationship |
| -------------------- | ----------------------------------- |
| **Shipping profile** | A store has many shipping profiles. |
| **Shipping option** | A store has many shipping options. |
| **Service zone** | A store has many service zones. |
| **Fulfillment set** | A store has many fulfillment sets. |
| **Stock location** | A store has many stock locations. |
| **Inventory item** | A store has many inventory items. |
## Pricing & promotions
| Linked module | Relationship |
| -------------- | ----------------------------- |
| **Price list** | A store has many price lists. |
| **Promotion** | A store has many promotions. |
| **Campaign** | A store has many campaigns. |
## Access & reviews
| Linked module | Relationship |
| ------------- | -------------------------------------------------------------------------------------------------- |
| **RBAC role** | A member's role on a store (`seller_member.role_id`, read-only) resolves against the roles module. |
| **Review** | A store has many reviews. |
Read-only links (Offer, RBAC role) are resolved from the field on the owning
record and can't be written through the link itself.
# Service reference
Source: https://docs.mercurjs.com/platform/store/reference/service
The Seller module service and its methods for working with records directly.
The Seller module exposes a service you can resolve from the Medusa container to
read and write records directly, without going through a workflow. Use it inside
custom services, subscribers, or scheduled jobs.
```ts theme={null}
import { MercurModules } from "@mercurjs/types"
const sellerModuleService = container.resolve(MercurModules.SELLER)
const [sellers, count] = await sellerModuleService.listAndCountSellers({
status: "open",
})
```
## Generated methods
Each data model gets a standard set of auto-generated methods. For `Seller`:
| Method | Description |
| ---------------------------------------- | ------------------------------ |
| `createSellers(data)` | Create one or more stores |
| `retrieveSeller(id, config?)` | Retrieve a store by id |
| `listSellers(filters?, config?)` | List stores matching filters |
| `listAndCountSellers(filters?, config?)` | List stores with a total count |
| `updateSellers(data)` | Update one or more stores |
| `deleteSellers(ids)` | Delete one or more stores |
The same set exists for every model in the module, such as `Member`, `SellerMember`,
`MemberInvite`, `ProfessionalDetails`, `SellerAddress`, and `PaymentDetails`
(e.g. `createMembers`, `listMemberInvites`, `updatePaymentDetails`).
## Team methods
| Method | Description |
| ---------------------------------------- | ------------------------------- |
| `addSellerMember(data)` | Attach a member to a store |
| `removeSellerMember(sellerId, memberId)` | Detach a member from a store |
| `createMemberInvites(data)` | Create pending team invitations |
| `updateMember(data)` | Update a member record |
Prefer [workflows](/platform/store/reference/workflows) for anything with side
effects (status changes, invites, payouts). The service writes records
directly and does **not** emit events or run compensation.
# Workflows
Source: https://docs.mercurjs.com/platform/store/reference/workflows
Seller and member workflows, service methods, and events.
This reference lists the workflows, service methods, and events for the Store
domain. Import workflows from `@mercurjs/core/workflows` and run them against the
Medusa container.
## Seller workflows
| Workflow | Input | Purpose |
| ------------------------- | --------------------------------- | ----------------------------- |
| `createSellersWorkflow` | `{ sellers[], additional_data? }` | Create stores with defaults |
| `updateSellersWorkflow` | `{ selector, update }` | Update store fields |
| `deleteSellersWorkflow` | `{ ids[] }` | Delete stores |
| `approveSellerWorkflow` | `{ seller_id, additional_data? }` | `pending_approval` → `open` |
| `suspendSellerWorkflow` | `{ seller_id }` | `open` → `suspended` |
| `unsuspendSellerWorkflow` | `{ seller_id }` | `suspended` → `open` |
| `terminateSellerWorkflow` | `{ seller_id }` | → `terminated` (irreversible) |
## Member workflows
| Workflow | Input | Purpose |
| ---------------------------- | -------------------------- | --------------------------------- |
| `inviteSellerWorkflow` | `{ email, seller_id }` | Create + send a member invite |
| `acceptMemberInviteWorkflow` | `{ token }` | Accept an invite, link the member |
| `addSellerMemberWorkflow` | `{ seller_id, member }` | Add a member directly |
| `removeSellerMemberWorkflow` | `{ seller_id, member_id }` | Revoke access |
| `updateMemberRoleWorkflow` | `{ id, role_id }` | Change a member's role |
To work with records directly instead of through a workflow, see the
[Service reference](/platform/store/reference/service). To run side effects when
a store changes, see the [Event reference](/platform/store/reference/events).
# Admin API
Source: https://docs.mercurjs.com/references/api/admin
Operator routes under /admin/* for sellers, commissions, payouts, master catalog, and marketplace-wide orders.
The Admin API is the marketplace operator's surface.
It extends Medusa's standard admin routes with the marketplace domains below. Everything else, such as regions, currencies, users, or API keys, comes from Medusa core unchanged. Authentication and shared conventions are covered in [API conventions](/rc/references/api/conventions).
## Sellers
| Method | Path | Purpose |
| --------------- | ----------------------------------------- | ------------------------------------ |
| `GET` `POST` | `/admin/sellers` | List sellers / create a seller |
| `GET` `POST` | `/admin/sellers/:id` | Retrieve / update a seller |
| `POST` | `/admin/sellers/:id/approve` | Approve a pending seller |
| `POST` | `/admin/sellers/:id/suspend` | Suspend a seller |
| `POST` | `/admin/sellers/:id/unsuspend` | Lift a suspension |
| `POST` | `/admin/sellers/:id/terminate` | Terminate a seller |
| `POST` | `/admin/sellers/:id/unterminate` | Reverse a termination |
| `POST` | `/admin/sellers/:id/address` | Upsert the seller address |
| `POST` | `/admin/sellers/:id/payment-details` | Upsert payment details |
| `POST` `DELETE` | `/admin/sellers/:id/professional-details` | Upsert / remove professional details |
| `GET` | `/admin/sellers/:id/products` | List the seller's products |
### Members and invites
| Method | Path | Purpose |
| ------------ | ------------------------------------------------------ | ------------------------------- |
| `GET` | `/admin/members` | List members across all sellers |
| `GET` `POST` | `/admin/sellers/:id/members` | List / add a member |
| `DELETE` | `/admin/sellers/:id/members/:member_id` | Remove a member |
| `POST` | `/admin/sellers/:id/members/invite` | Invite a member |
| `GET` | `/admin/sellers/:id/members/invites` | List pending invites |
| `DELETE` | `/admin/sellers/:id/members/invites/:invite_id` | Revoke an invite |
| `POST` | `/admin/sellers/:id/members/invites/:invite_id/resend` | Resend an invite |
## Commission rates
| Method | Path | Purpose |
| --------------------- | ----------------------------------- | ------------------------------------------------------------- |
| `GET` `POST` | `/admin/commission-rates` | List rates (supports the `scope_type` filter) / create a rate |
| `GET` `POST` `DELETE` | `/admin/commission-rates/:id` | Retrieve / update / delete a rate |
| `POST` | `/admin/commission-rates/:id/rules` | Batch add and remove rules on a rate |
## Payouts
| Method | Path | Purpose |
| ------ | -------------------- | ----------------------------------- |
| `GET` | `/admin/payouts` | List payouts across the marketplace |
| `GET` | `/admin/payouts/:id` | Retrieve a payout |
## Products (master catalog)
Products are shared catalog records. See [master products](/platform/catalog/concepts/master-products).
| Method | Path | Purpose |
| --------------------- | ------------------------------------------ | --------------------------------------------------------------- |
| `GET` `POST` | `/admin/products` | List / create a product (`seller_ids` sets selling eligibility) |
| `GET` `POST` `DELETE` | `/admin/products/:id` | Retrieve / update / delete a product |
| `GET` | `/admin/products/:id/preview` | Preview the product with pending changes applied |
| `POST` | `/admin/products/:id/confirm` | Confirm the pending change |
| `POST` | `/admin/products/:id/reject` | Reject the pending change |
| `POST` | `/admin/products/:id/request-changes` | Send a change back to the seller for action |
| `POST` | `/admin/products/:id/sellers` | Set the selling-eligibility allowlist |
| `POST` | `/admin/products/:id/attributes/batch` | Batch add / update / remove product attributes |
| `GET` `POST` | `/admin/products/:id/variants` | List / create variants |
| `GET` `POST` `DELETE` | `/admin/products/:id/variants/:variant_id` | Retrieve / update / delete a variant |
### Product changes
| Method | Path | Purpose |
| ------ | ------------------------------------ | --------------------------------- |
| `POST` | `/admin/product-changes/:id/confirm` | Confirm a change request directly |
| `POST` | `/admin/product-changes/:id/cancel` | Cancel a change request |
## Offers
| Method | Path | Purpose |
| -------------- | --------------------- | ---------------------------------------- |
| `GET` | `/admin/offers` | List offers (supports `group_by_seller`) |
| `GET` `DELETE` | `/admin/offers/:id` | Retrieve / delete an offer |
| `POST` | `/admin/offers/batch` | Batch create offers |
## Product attributes
| Method | Path | Purpose |
| --------------------- | ------------------------------------------------ | --------------------------------------- |
| `GET` `POST` | `/admin/product-attributes` | List / create attributes |
| `GET` `POST` `DELETE` | `/admin/product-attributes/:id` | Retrieve / update / delete an attribute |
| `POST` | `/admin/product-attributes/:id/values` | Create a value |
| `POST` `DELETE` | `/admin/product-attributes/:id/values/:value_id` | Update / delete a value |
## Categories and collections
| Method | Path | Purpose |
| --------------------- | ---------------------------------------- | --------------------------------------------- |
| `GET` `POST` | `/admin/product-categories` | List / create categories (with media + icon) |
| `GET` `POST` `DELETE` | `/admin/product-categories/:id` | Retrieve / update / delete a category |
| `POST` | `/admin/product-categories/:id/products` | Batch link products |
| `POST` | `/admin/product-categories/:id/sellers` | Batch link sellers |
| `GET` `POST` | `/admin/collections` | List / create collections (with media + icon) |
| `GET` `POST` `DELETE` | `/admin/collections/:id` | Retrieve / update / delete a collection |
## Orders and order groups
| Method | Path | Purpose |
| ------ | ------------------------------------ | --------------------------------------------- |
| `GET` | `/admin/orders` | List all orders across sellers |
| `GET` | `/admin/orders/:id/commission-lines` | Commission lines computed for an order |
| `GET` | `/admin/orders/:id/order-group` | The order group an order belongs to |
| `GET` | `/admin/order-groups` | List order groups |
| `GET` | `/admin/order-groups/:id` | Retrieve an order group with its child orders |
### Order edits, claims, exchanges, returns
Mercur extends the Medusa RMA routes so they respect seller boundaries:
| Method | Path | Purpose |
| --------------- | ------------------------------------- | --------------------------------- |
| `POST` | `/admin/order-edits/:id/items` | Add items to an order edit |
| `POST` | `/admin/order-edits/:id/confirm` | Confirm an order edit |
| `POST` | `/admin/claims/:id/outbound/items` | Add outbound items to a claim |
| `POST` `DELETE` | `/admin/claims/:id/request` | Request / cancel a claim |
| `POST` | `/admin/exchanges/:id/outbound/items` | Add outbound items to an exchange |
| `POST` `DELETE` | `/admin/exchanges/:id/request` | Request / cancel an exchange |
| `POST` | `/admin/returns/:id/receive/confirm` | Confirm receipt of a return |
## Next steps
# Batch Commission Rules
Source: https://docs.mercurjs.com/references/api/admin/commission-rates/batch-commission-rules
POST /admin/commission-rates/{id}/rules
Create, update, and delete a commission rate's rules in one request.
Applies a batch of rule operations against a commission rate.
## Path parameters
The commission rate's ID.
## Body parameters
Rules to create.
The rule target type: `product`, `product_type`, `product_collection`, `product_category`, or `seller`.ID of the referenced entity.
Rules to update.
The rule's ID.The rule target type: `product`, `product_type`, `product_collection`, `product_category`, or `seller`.ID of the referenced entity.IDs of rules to delete.
## Response
The created rules.The updated rules.IDs of the deleted rules.
```bash cURL theme={null}
curl -X POST 'http://localhost:9000/admin/commission-rates/comrate_01HXYZ8Q2M4N6P8R0T2V4W6X8Y/rules' \
-H 'Authorization: Bearer ' \
-H 'Content-Type: application/json' \
-d '{"create": [{"reference": "seller", "reference_id": "sel_01HXYZ"}], "delete": ["comrule_01HABC"]}'
```
```ts JS Client theme={null}
const { created, updated, deleted } =
await client.admin.commissionRates.$id.rules.mutate({
$id: "comrate_01HXYZ8Q2M4N6P8R0T2V4W6X8Y",
create: [{ reference: "seller", reference_id: "sel_01HXYZ" }],
delete: ["comrule_01HABC"],
})
```
```json 200 theme={null}
{
"created": [
{
"id": "comrule_01HXYZ9A1B2C3D4E5F6G7H8J9K",
"reference": "seller",
"reference_id": "sel_01HXYZ"
}
],
"updated": [],
"deleted": ["comrule_01HABC"]
}
```
# Create Commission Rate
Source: https://docs.mercurjs.com/references/api/admin/commission-rates/create-commission-rate
POST /admin/commission-rates
Create a commission rate.
Creates a commission rate, optionally with scoping rules and per-currency values.
## Body parameters
The rate's display name.Unique code identifying the rate.The rate type: `fixed` or `percentage`.The commission value, a percentage or a fixed amount.Currency of a fixed rate's value.Whether commission is calculated on tax-inclusive amounts.Whether shipping is included in the commission base.Whether the rate is active.Whether this is the marketplace default rate.
Rules scoping which sellers or products the rate applies to.
The rule target type: `product`, `product_type`, `product_collection`, `product_category`, or `seller`.ID of the referenced entity.
Per-currency fixed amounts.
The value's currency code.The fixed commission amount for that currency.
## Response
The commission rate's ID.The rate's display name.The rate's unique code.The rate type: `fixed` or `percentage`.The commission value.Whether the rate is active.Whether this is the marketplace default rate.Rules scoping the rate.Per-currency fixed amounts.
```bash cURL theme={null}
curl -X POST 'http://localhost:9000/admin/commission-rates' \
-H 'Authorization: Bearer ' \
-H 'Content-Type: application/json' \
-d '{"name": "Electronics", "code": "electronics", "type": "percentage", "value": 12, "rules": [{"reference": "product_category", "reference_id": "pcat_01HXYZ"}]}'
```
```ts JS Client theme={null}
const { commission_rate } = await client.admin.commissionRates.mutate({
name: "Electronics",
code: "electronics",
type: "percentage",
value: 12,
rules: [{ reference: "product_category", reference_id: "pcat_01HXYZ" }],
})
```
```json 201 theme={null}
{
"commission_rate": {
"id": "comrate_01HXYZ8Q2M4N6P8R0T2V4W6X8Y",
"name": "Electronics",
"code": "electronics",
"type": "percentage",
"value": 12,
"currency_code": null,
"include_tax": false,
"include_shipping": false,
"is_enabled": true,
"is_default": false,
"rules": [
{
"id": "comrule_01HXYZ9A1B2C3D4E5F6G7H8J9K",
"reference": "product_category",
"reference_id": "pcat_01HXYZ"
}
],
"values": []
}
}
```
# Delete Commission Rate
Source: https://docs.mercurjs.com/references/api/admin/commission-rates/delete-commission-rate
DELETE /admin/commission-rates/{id}
Delete a commission rate.
Deletes a commission rate and its rules.
## Path parameters
The commission rate's ID.
## Response
The deleted commission rate's ID.The object type: `commission_rate`.Whether the rate was deleted.
```bash cURL theme={null}
curl -X DELETE 'http://localhost:9000/admin/commission-rates/comrate_01HXYZ8Q2M4N6P8R0T2V4W6X8Y' \
-H 'Authorization: Bearer '
```
```ts JS Client theme={null}
const result = await client.admin.commissionRates.$id.delete({
$id: "comrate_01HXYZ8Q2M4N6P8R0T2V4W6X8Y",
})
```
```json 200 theme={null}
{
"id": "comrate_01HXYZ8Q2M4N6P8R0T2V4W6X8Y",
"object": "commission_rate",
"deleted": true
}
```
# List Commission Rates
Source: https://docs.mercurjs.com/references/api/admin/commission-rates/list-commission-rates
GET /admin/commission-rates
Retrieve a paginated list of commission rates.
Returns commission rates with their rules and currency-specific values.
`scope_type` is a virtual filter derived from each rate's rules. It is not a
stored column. Use it to filter rates by the scope their rules target.
## Query parameters
Maximum number of records to return.Number of records to skip.Field to sort by, prefix with `-` for descending order.Comma-separated fields to include in the response.Search term matched against commission rates.Filter by commission rate ID(s).Filter by commission rate code(s).Filter by rate type: `fixed` or `percentage`.Filter by rule scope: `store`, `product_type`, `category`, `store_product_type`, or `store_category`.Filter by enabled state.Filter by whether the rate is the marketplace default.Filter by creation date using operators like `$gte` and `$lte`.Filter by update date using operators like `$gte` and `$lte`.
## Response
The commission rate's ID.The rate's display name.The rate's unique code.The rate type: `fixed` or `percentage`.The commission value, a percentage or a fixed amount.Currency of a fixed rate's value.Whether commission is calculated on tax-inclusive amounts.Whether shipping is included in the commission base.Whether the rate is active.Whether this is the marketplace default rate.Rules scoping the rate, each with `id`, `reference`, and `reference_id`.Per-currency fixed amounts, each with `id`, `currency_code`, and `amount`.Creation timestamp.Last update timestamp.Total number of matching commission rates.Number of records skipped.Maximum number of records returned.
```bash cURL theme={null}
curl 'http://localhost:9000/admin/commission-rates?scope_type=store&limit=20' \
-H 'Authorization: Bearer '
```
```ts JS Client theme={null}
const { commission_rates, count } = await client.admin.commissionRates.query({
scope_type: "store",
limit: 20,
})
```
```json 200 theme={null}
{
"commission_rates": [
{
"id": "comrate_01HXYZ8Q2M4N6P8R0T2V4W6X8Y",
"name": "Default",
"code": "default",
"type": "percentage",
"value": 10,
"currency_code": null,
"include_tax": false,
"include_shipping": false,
"is_enabled": true,
"is_default": true,
"rules": [],
"values": []
}
],
"count": 1,
"offset": 0,
"limit": 20
}
```
# Retrieve Commission Rate
Source: https://docs.mercurjs.com/references/api/admin/commission-rates/retrieve-commission-rate
GET /admin/commission-rates/{id}
Retrieve a commission rate by ID.
Returns a single commission rate with its rules and per-currency values.
## Path parameters
The commission rate's ID.
## Query parameters
Comma-separated fields to include in the response.
## Response
The commission rate's ID.The rate's display name.The rate's unique code.The rate type: `fixed` or `percentage`.The commission value.Currency of a fixed rate's value.Whether commission is calculated on tax-inclusive amounts.Whether shipping is included in the commission base.Whether the rate is active.Whether this is the marketplace default rate.Rules scoping the rate, each with `id`, `reference`, and `reference_id`.Per-currency fixed amounts, each with `id`, `currency_code`, and `amount`.Creation timestamp.Last update timestamp.
```bash cURL theme={null}
curl 'http://localhost:9000/admin/commission-rates/comrate_01HXYZ8Q2M4N6P8R0T2V4W6X8Y' \
-H 'Authorization: Bearer '
```
```ts JS Client theme={null}
const { commission_rate } = await client.admin.commissionRates.$id.query({
$id: "comrate_01HXYZ8Q2M4N6P8R0T2V4W6X8Y",
})
```
```json 200 theme={null}
{
"commission_rate": {
"id": "comrate_01HXYZ8Q2M4N6P8R0T2V4W6X8Y",
"name": "Default",
"code": "default",
"type": "percentage",
"value": 10,
"currency_code": null,
"include_tax": false,
"include_shipping": false,
"is_enabled": true,
"is_default": true,
"rules": [],
"values": []
}
}
```
# Update Commission Rate
Source: https://docs.mercurjs.com/references/api/admin/commission-rates/update-commission-rate
POST /admin/commission-rates/{id}
Update a commission rate.
Updates a commission rate's properties.
Rules are managed separately through the
[batch rules endpoint](/rc/references/api/admin/commission-rates/batch-commission-rules).
## Path parameters
The commission rate's ID.
## Body parameters
The rate's display name.Unique code identifying the rate.The rate type: `fixed` or `percentage`.The commission value, a percentage or a fixed amount.Currency of a fixed rate's value.Whether commission is calculated on tax-inclusive amounts.Whether shipping is included in the commission base.Whether the rate is active.
Per-currency fixed amounts.
The value's currency code.The fixed commission amount for that currency.
## Response
The commission rate's ID.The rate's display name.The rate's unique code.The rate type: `fixed` or `percentage`.The commission value.Whether the rate is active.Rules scoping the rate.Per-currency fixed amounts.
```bash cURL theme={null}
curl -X POST 'http://localhost:9000/admin/commission-rates/comrate_01HXYZ8Q2M4N6P8R0T2V4W6X8Y' \
-H 'Authorization: Bearer ' \
-H 'Content-Type: application/json' \
-d '{"value": 15, "is_enabled": true}'
```
```ts JS Client theme={null}
const { commission_rate } = await client.admin.commissionRates.$id.mutate({
$id: "comrate_01HXYZ8Q2M4N6P8R0T2V4W6X8Y",
value: 15,
is_enabled: true,
})
```
```json 200 theme={null}
{
"commission_rate": {
"id": "comrate_01HXYZ8Q2M4N6P8R0T2V4W6X8Y",
"name": "Default",
"code": "default",
"type": "percentage",
"value": 15,
"is_enabled": true,
"is_default": true,
"rules": [],
"values": []
}
}
```
# List Members
Source: https://docs.mercurjs.com/references/api/admin/members/list-members
GET /admin/members
Retrieve a paginated list of members across all sellers.
Returns member records platform-wide, useful for finding a member to add to a seller's team.
## Query parameters
Maximum number of records to return.Number of records to skip.Field to sort by, prefix with `-` for descending order.Comma-separated list of fields to include, prefix with `+`/`-` to add or remove from defaults.Search term matched against member fields.Filter by member email.
## Response
The member's ID.The member's email address.Whether the member's account is active.Creation timestamp.Total number of matching members.Number of records skipped.Number of records returned.
```bash cURL theme={null}
curl 'http://localhost:9000/admin/members?email=owner@acme.co' \
-H 'Authorization: Bearer '
```
```ts JS Client theme={null}
const { members, count } = await client.admin.members.query({
email: "owner@acme.co",
})
```
```json 200 theme={null}
{
"members": [
{
"id": "mem_01HXYZMEMAA",
"email": "owner@acme.co",
"is_active": true,
"created_at": "2026-06-01T10:00:00.000Z"
}
],
"count": 1,
"offset": 0,
"limit": 10
}
```
# Batch Create Offers
Source: https://docs.mercurjs.com/references/api/admin/offers/batch-create-offers
POST /admin/offers/batch
Create up to 100 offers for a seller in one request.
Creates multiple offers on behalf of a seller. The authenticated admin user is recorded as the creator.
## Body parameters
ID of the seller the offers belong to.
The offers to create, between 1 and 100.
The offer's SKU.ID of the product variant to offer.ID of the seller's shipping profile.
Offer prices, at least one.
The price amount.The price currency.Minimum quantity the price applies to.Maximum quantity the price applies to.Price rules as attribute-value pairs.
Inventory items backing the offer, at least one.
The inventory item's title.The inventory item's SKU.Quantity required per unit sold.
Initial stock levels.
The stock location's ID.Quantity in stock at the location.The offer's EAN.The offer's UPC.Custom key-value data.
## Response
The offer's ID.ID of the seller who owns the offer.ID of the offered product variant.ID of the offer's shipping profile.The offer's SKU.The offer's prices.The offer's linked inventory items.
```bash cURL theme={null}
curl -X POST 'http://localhost:9000/admin/offers/batch' \
-H 'Authorization: Bearer ' \
-H 'Content-Type: application/json' \
-d '{
"seller_id": "sel_01HXYZ",
"offers": [
{
"sku": "SHIRT-M-BLUE",
"variant_id": "variant_01HXYZ",
"shipping_profile_id": "sp_01HXYZ",
"prices": [{ "amount": 2500, "currency_code": "usd" }],
"inventory_items": [
{
"sku": "SHIRT-M-BLUE",
"required_quantity": 1,
"stock_levels": [{ "location_id": "sloc_01HXYZ", "stocked_quantity": 100 }]
}
]
}
]
}'
```
```ts JS Client theme={null}
const { offers } = await client.admin.offers.batch.mutate({
seller_id: "sel_01HXYZ",
offers: [
{
sku: "SHIRT-M-BLUE",
variant_id: "variant_01HXYZ",
shipping_profile_id: "sp_01HXYZ",
prices: [{ amount: 2500, currency_code: "usd" }],
inventory_items: [
{
sku: "SHIRT-M-BLUE",
required_quantity: 1,
stock_levels: [{ location_id: "sloc_01HXYZ", stocked_quantity: 100 }],
},
],
},
],
})
```
```json 201 theme={null}
{
"offers": [
{
"id": "offer_01HXYZ8Q2M4N6P8R0T2V4W6X8Y",
"seller_id": "sel_01HXYZ",
"variant_id": "variant_01HXYZ",
"shipping_profile_id": "sp_01HXYZ",
"sku": "SHIRT-M-BLUE",
"prices": [
{ "id": "price_01HXYZ", "amount": 2500, "currency_code": "usd" }
],
"inventory_items": [
{ "id": "iitem_01HXYZ", "inventory_item_id": "iitem_01HXYZ", "required_quantity": 1, "sku": "SHIRT-M-BLUE" }
]
}
]
}
```
# Delete Offer
Source: https://docs.mercurjs.com/references/api/admin/offers/delete-offer
DELETE /admin/offers/{id}
Delete an offer.
Deletes an offer by ID.
## Path parameters
The offer's ID.
## Response
The deleted offer's ID.The object type: `offer`.Whether the offer was deleted.
```bash cURL theme={null}
curl -X DELETE 'http://localhost:9000/admin/offers/offer_01HXYZ8Q2M4N6P8R0T2V4W6X8Y' \
-H 'Authorization: Bearer '
```
```ts JS Client theme={null}
const result = await client.admin.offers.$id.delete({
$id: "offer_01HXYZ8Q2M4N6P8R0T2V4W6X8Y",
})
```
```json 200 theme={null}
{
"id": "offer_01HXYZ8Q2M4N6P8R0T2V4W6X8Y",
"object": "offer",
"deleted": true
}
```
# List Offers
Source: https://docs.mercurjs.com/references/api/admin/offers/list-offers
GET /admin/offers
Retrieve a paginated list of offers across all sellers.
Returns offers with their seller, variant, shipping profile, prices, and inventory items.
## Query parameters
Maximum number of records to return.Number of records to skip.Field to sort by, prefix with `-` for descending order.Comma-separated fields to include in the response.Search term matched against offers.Filter by offer ID(s).Filter by seller ID(s).Filter by product variant ID(s).Filter by shipping profile ID(s).Filter by SKU(s).Filter by EAN(s).Filter by UPC(s).Group results by seller.Filter by the offered product's status.Filter by product category ID(s).Filter by product collection ID(s).Filter by product type ID(s).Filter by product tag ID(s).Filter by creation date using operators like `$gte` and `$lte`.Filter by update date using operators like `$gte` and `$lte`.
## Response
The offer's ID.ID of the seller who owns the offer.ID of the offered product variant.ID of the offered product.ID of the offer's shipping profile.The offer's SKU.The offer's EAN.The offer's UPC.ID of the actor who created the offer.Number of variants on the offered product.Custom key-value data.The seller, with `id`, `name`, and `handle`.The variant, with `id`, `title`, and `sku`.The shipping profile, with `id` and `name`.Offer prices, each with `id`, `amount`, `currency_code`, `min_quantity`, `max_quantity`, and `price_rules`.Linked inventory items, each with `id`, `inventory_item_id`, `required_quantity`, and `sku`.Creation timestamp.Last update timestamp.Total number of matching offers.Number of records skipped.Maximum number of records returned.
```bash cURL theme={null}
curl 'http://localhost:9000/admin/offers?seller_id=sel_01HXYZ&limit=20' \
-H 'Authorization: Bearer '
```
```ts JS Client theme={null}
const { offers, count } = await client.admin.offers.query({
seller_id: "sel_01HXYZ",
limit: 20,
})
```
```json 200 theme={null}
{
"offers": [
{
"id": "offer_01HXYZ8Q2M4N6P8R0T2V4W6X8Y",
"seller_id": "sel_01HXYZ",
"variant_id": "variant_01HXYZ",
"product_id": "prod_01HXYZ",
"shipping_profile_id": "sp_01HXYZ",
"sku": "SHIRT-M-BLUE",
"ean": null,
"upc": null,
"created_by": "user_01HXYZ",
"variant_count": 3,
"metadata": null,
"seller": { "id": "sel_01HXYZ", "name": "Acme Store", "handle": "acme-store" },
"product_variant": { "id": "variant_01HXYZ", "title": "M / Blue", "sku": "SHIRT-M-BLUE" },
"shipping_profile": { "id": "sp_01HXYZ", "name": "Default" },
"prices": [
{ "id": "price_01HXYZ", "amount": 2500, "currency_code": "usd", "min_quantity": null, "max_quantity": null }
],
"inventory_items": [
{ "id": "iitem_01HXYZ", "inventory_item_id": "iitem_01HXYZ", "required_quantity": 1, "sku": "SHIRT-M-BLUE" }
]
}
],
"count": 1,
"offset": 0,
"limit": 20
}
```
# Retrieve Offer
Source: https://docs.mercurjs.com/references/api/admin/offers/retrieve-offer
GET /admin/offers/{id}
Retrieve an offer by ID.
Returns a single offer with its seller, variant, shipping profile, prices, and inventory items.
## Path parameters
The offer's ID.
## Query parameters
Comma-separated fields to include in the response.
## Response
The offer's ID.ID of the seller who owns the offer.ID of the offered product variant.ID of the offered product.ID of the offer's shipping profile.The offer's SKU.The offer's EAN.The offer's UPC.ID of the actor who created the offer.Number of variants on the offered product.Custom key-value data.The seller, with `id`, `name`, and `handle`.The variant, with `id`, `title`, and `sku`.The shipping profile, with `id` and `name`.Offer prices, each with `id`, `amount`, `currency_code`, `min_quantity`, `max_quantity`, and `price_rules`.Linked inventory items, each with `id`, `inventory_item_id`, `required_quantity`, and `sku`.Creation timestamp.Last update timestamp.
```bash cURL theme={null}
curl 'http://localhost:9000/admin/offers/offer_01HXYZ8Q2M4N6P8R0T2V4W6X8Y' \
-H 'Authorization: Bearer '
```
```ts JS Client theme={null}
const { offer } = await client.admin.offers.$id.query({
$id: "offer_01HXYZ8Q2M4N6P8R0T2V4W6X8Y",
})
```
```json 200 theme={null}
{
"offer": {
"id": "offer_01HXYZ8Q2M4N6P8R0T2V4W6X8Y",
"seller_id": "sel_01HXYZ",
"variant_id": "variant_01HXYZ",
"product_id": "prod_01HXYZ",
"shipping_profile_id": "sp_01HXYZ",
"sku": "SHIRT-M-BLUE",
"ean": null,
"upc": null,
"created_by": "user_01HXYZ",
"variant_count": 3,
"metadata": null,
"seller": { "id": "sel_01HXYZ", "name": "Acme Store", "handle": "acme-store" },
"product_variant": { "id": "variant_01HXYZ", "title": "M / Blue", "sku": "SHIRT-M-BLUE" },
"shipping_profile": { "id": "sp_01HXYZ", "name": "Default" },
"prices": [
{ "id": "price_01HXYZ", "amount": 2500, "currency_code": "usd", "min_quantity": null, "max_quantity": null }
],
"inventory_items": [
{ "id": "iitem_01HXYZ", "inventory_item_id": "iitem_01HXYZ", "required_quantity": 1, "sku": "SHIRT-M-BLUE" }
]
}
}
```
# List Order Groups
Source: https://docs.mercurjs.com/references/api/admin/order-groups/list-order-groups
GET /admin/order-groups
Retrieve a paginated list of order groups.
Returns order groups, the customer-facing wrappers around per-seller orders created from a multi-seller cart.
Filtering by `seller_id` narrows results to order groups that contain at
least one order belonging to that seller.
## Query parameters
Maximum number of records to return.Number of records to skip.Field to sort by, prefix with `-` for descending order.Comma-separated fields to include in the response.Search term matched against order groups.Filter by order group ID(s).Filter by customer ID(s).Filter to groups containing orders from the given seller(s).Filter by order group status.Filter by sales channel ID(s).Filter by creation date using operators like `$gte` and `$lte`.Filter by update date using operators like `$gte` and `$lte`.
## Response
The order group's ID.ID of the customer who placed the order.Number of sellers in the group.The group's total amount.Creation timestamp.Last update timestamp.Total number of matching order groups.Number of records skipped.Maximum number of records returned.
```bash cURL theme={null}
curl 'http://localhost:9000/admin/order-groups?customer_id=cus_01HXYZ&limit=20' \
-H 'Authorization: Bearer '
```
```ts JS Client theme={null}
const { order_groups, count } = await client.admin.orderGroups.query({
customer_id: "cus_01HXYZ",
limit: 20,
})
```
```json 200 theme={null}
{
"order_groups": [
{
"id": "og_01HXYZ8Q2M4N6P8R0T2V4W6X8Y",
"customer_id": "cus_01HXYZ",
"seller_count": 2,
"total": 7400,
"created_at": "2026-06-01T10:00:00.000Z",
"updated_at": "2026-06-01T10:00:00.000Z"
}
],
"count": 1,
"offset": 0,
"limit": 20
}
```
# Retrieve Order Group
Source: https://docs.mercurjs.com/references/api/admin/order-groups/retrieve-order-group
GET /admin/order-groups/{id}
Retrieve an order group by ID.
Returns a single order group.
## Path parameters
The order group's ID.
## Query parameters
Comma-separated fields to include in the response.
## Response
The order group's ID.ID of the customer who placed the order.Number of sellers in the group.The group's total amount.Creation timestamp.Last update timestamp.
```bash cURL theme={null}
curl 'http://localhost:9000/admin/order-groups/og_01HXYZ8Q2M4N6P8R0T2V4W6X8Y' \
-H 'Authorization: Bearer '
```
```ts JS Client theme={null}
const { order_group } = await client.admin.orderGroups.$id.query({
$id: "og_01HXYZ8Q2M4N6P8R0T2V4W6X8Y",
})
```
```json 200 theme={null}
{
"order_group": {
"id": "og_01HXYZ8Q2M4N6P8R0T2V4W6X8Y",
"customer_id": "cus_01HXYZ",
"seller_count": 2,
"total": 7400,
"created_at": "2026-06-01T10:00:00.000Z",
"updated_at": "2026-06-01T10:00:00.000Z"
}
}
```
# Get Order Commission Lines
Source: https://docs.mercurjs.com/references/api/admin/orders/get-order-commission-lines
GET /admin/orders/{id}/commission-lines
Retrieve the commission lines calculated for an order.
Returns the commission lines applied to an order's items and shipping methods.
## Path parameters
The order's ID.
## Response
The commission line's ID.ID of the order item the line applies to.ID of the shipping method the line applies to.ID of the commission rate that produced the line.Code of the applied commission rate.The applied commission rate value.The calculated commission amount.Optional description of the line.Creation timestamp.Last update timestamp.Number of commission lines on the order.
```bash cURL theme={null}
curl 'http://localhost:9000/admin/orders/order_01HXYZ8Q2M4N6P8R0T2V4W6X8Y/commission-lines' \
-H 'Authorization: Bearer '
```
```ts JS Client theme={null}
const { commission_lines, count } =
await client.admin.orders.$id.commissionLines.query({
$id: "order_01HXYZ8Q2M4N6P8R0T2V4W6X8Y",
})
```
```json 200 theme={null}
{
"commission_lines": [
{
"id": "comline_01HXYZ8Q2M4N6P8R0T2V4W6X8Y",
"item_id": "ordli_01HXYZ",
"shipping_method_id": null,
"commission_rate_id": "comrate_01HXYZ",
"code": "default",
"rate": 10,
"amount": 490,
"description": null,
"created_at": "2026-06-01T10:00:00.000Z",
"updated_at": "2026-06-01T10:00:00.000Z"
}
],
"count": 1
}
```
# Get Order's Order Group
Source: https://docs.mercurjs.com/references/api/admin/orders/get-order-order-group
GET /admin/orders/{id}/order-group
Retrieve the order group a specific order belongs to.
Looks up the order group containing the given order and returns its details. Responds with `404` if the order is not part of any group.
## Path parameters
The order's ID.
## Query parameters
Comma-separated fields to include in the response.
## Response
The order group's ID.ID of the customer who placed the order.Number of sellers in the group.The group's total amount.Creation timestamp.Last update timestamp.
```bash cURL theme={null}
curl 'http://localhost:9000/admin/orders/order_01HXYZ8Q2M4N6P8R0T2V4W6X8Y/order-group' \
-H 'Authorization: Bearer '
```
```ts JS Client theme={null}
const { order_group } = await client.admin.orders.$id.orderGroup.query({
$id: "order_01HXYZ8Q2M4N6P8R0T2V4W6X8Y",
})
```
```json 200 theme={null}
{
"order_group": {
"id": "og_01HXYZ8Q2M4N6P8R0T2V4W6X8Y",
"customer_id": "cus_01HXYZ",
"seller_count": 2,
"total": 7400,
"created_at": "2026-06-01T10:00:00.000Z",
"updated_at": "2026-06-01T10:00:00.000Z"
}
}
```
# List Orders
Source: https://docs.mercurjs.com/references/api/admin/orders/list-orders
GET /admin/orders
Retrieve a paginated list of orders across all sellers.
Returns orders platform-wide. Mercur extends the standard Medusa endpoint with `seller_id` and `name` filters.
## Query parameters
Maximum number of records to return.Number of records to skip.Field to sort by, prefix with `-` for descending order.Comma-separated fields to include in the response.Search term matched against orders.Filter by order ID(s).Filter by order status.Filter to orders belonging to the given seller(s).Filter by order name(s).Filter by sales channel IDs.Filter by region ID(s).Filter by customer ID(s).Filter by order total using operators like `$gte` and `$lte`.Filter by creation date using operators like `$gte` and `$lte`.Filter by update date using operators like `$gte` and `$lte`.
## Response
The order's ID.Human-readable order number.The order's status.The order's currency.The order's total amount.ID of the customer who placed the order.Creation timestamp.Last update timestamp.Total number of matching orders.Number of records skipped.Maximum number of records returned.
```bash cURL theme={null}
curl 'http://localhost:9000/admin/orders?seller_id=sel_01HXYZ&limit=20' \
-H 'Authorization: Bearer '
```
```ts JS Client theme={null}
const { orders, count } = await client.admin.orders.query({
seller_id: "sel_01HXYZ",
limit: 20,
})
```
```json 200 theme={null}
{
"orders": [
{
"id": "order_01HXYZ8Q2M4N6P8R0T2V4W6X8Y",
"display_id": 128,
"status": "pending",
"currency_code": "usd",
"total": 4900,
"customer_id": "cus_01HXYZ",
"created_at": "2026-06-01T10:00:00.000Z",
"updated_at": "2026-06-01T10:00:00.000Z"
}
],
"count": 1,
"offset": 0,
"limit": 20
}
```
# List Payouts
Source: https://docs.mercurjs.com/references/api/admin/payouts/list-payouts
GET /admin/payouts
Retrieve a paginated list of payouts across all sellers.
Returns payouts with their payout account and seller details.
## Query parameters
Maximum number of records to return.Number of records to skip.Field to sort by, prefix with `-` for descending order.Comma-separated fields to include in the response.Filter by payout ID(s).Filter by payout status.Filter by payout account ID(s).Filter by creation date using operators like `$gte` and `$lte`.Filter by update date using operators like `$gte` and `$lte`.
## Response
The payout's ID.Human-readable payout number.The payout amount.The payout currency.The payout status.Provider-specific payout data.The payout account, with `id` and `status`.The seller, with `id`, `name`, and `handle`.Creation timestamp.Last update timestamp.Total number of matching payouts.Number of records skipped.Maximum number of records returned.
```bash cURL theme={null}
curl 'http://localhost:9000/admin/payouts?status=completed&limit=20' \
-H 'Authorization: Bearer '
```
```ts JS Client theme={null}
const { payouts, count } = await client.admin.payouts.query({
status: "completed",
limit: 20,
})
```
```json 200 theme={null}
{
"payouts": [
{
"id": "pout_01HXYZ8Q2M4N6P8R0T2V4W6X8Y",
"display_id": 42,
"amount": 1250,
"currency_code": "usd",
"status": "completed",
"data": null,
"account": { "id": "pacc_01HXYZ", "status": "active" },
"seller": { "id": "sel_01HXYZ", "name": "Acme Store", "handle": "acme-store" }
}
],
"count": 1,
"offset": 0,
"limit": 20
}
```
# Retrieve Payout
Source: https://docs.mercurjs.com/references/api/admin/payouts/retrieve-payout
GET /admin/payouts/{id}
Retrieve a payout by ID.
Returns a single payout with its payout account and seller details.
## Path parameters
The payout's ID.
## Query parameters
Comma-separated fields to include in the response.
## Response
The payout's ID.Human-readable payout number.The payout amount.The payout currency.The payout status.Provider-specific payout data.The payout account, with `id` and `status`.The seller, with `id`, `name`, and `handle`.Creation timestamp.Last update timestamp.
```bash cURL theme={null}
curl 'http://localhost:9000/admin/payouts/pout_01HXYZ8Q2M4N6P8R0T2V4W6X8Y' \
-H 'Authorization: Bearer '
```
```ts JS Client theme={null}
const { payout } = await client.admin.payouts.$id.query({
$id: "pout_01HXYZ8Q2M4N6P8R0T2V4W6X8Y",
})
```
```json 200 theme={null}
{
"payout": {
"id": "pout_01HXYZ8Q2M4N6P8R0T2V4W6X8Y",
"display_id": 42,
"amount": 1250,
"currency_code": "usd",
"status": "completed",
"data": null,
"account": { "id": "pacc_01HXYZ", "status": "active" },
"seller": { "id": "sel_01HXYZ", "name": "Acme Store", "handle": "acme-store" }
}
}
```
# Upsert Attribute Values
Source: https://docs.mercurjs.com/references/api/admin/product-attributes/create-attribute-value
POST /admin/product-attributes/{id}/values
Create or update values on a product attribute.
Upserts the given values on the attribute. Items with an `id` update an existing value, items without one create a new value.
## Path parameters
The attribute's ID.
## Body parameters
Values to create or update, one of two shapes per item.
The value's ID.Value name.Unique URL-safe handle.Sort rank; must be non-negative.Whether the value is active.Custom key-value data.Value name.Unique URL-safe handle.Sort rank; must be non-negative.Whether the value is active.Custom key-value data.
## Response
The attribute's ID.Attribute name.The attribute's type.All values after the upsert, with `id`, `name`, `handle`, `rank`, and `is_active`.Last update timestamp.
```bash cURL theme={null}
curl -X POST 'http://localhost:9000/admin/product-attributes/pattr_01HXYZABCDEF/values' \
-H 'Authorization: Bearer ' \
-H 'Content-Type: application/json' \
-d '{"values": [{ "name": "Wool" }, { "id": "pattrval_01HXYZABCDEF", "rank": 1 }]}'
```
```ts JS Client theme={null}
const { product_attribute } = await client.admin.productAttributes.$id.values.mutate({
$id: "pattr_01HXYZABCDEF",
values: [{ name: "Wool" }, { id: "pattrval_01HXYZABCDEF", rank: 1 }],
})
```
```json 200 theme={null}
{
"product_attribute": {
"id": "pattr_01HXYZABCDEF",
"name": "Material",
"type": "single_select",
"values": [
{ "id": "pattrval_01HXYZABCDEG", "name": "Wool", "rank": 0 },
{ "id": "pattrval_01HXYZABCDEF", "name": "Linen", "rank": 1 }
]
}
}
```
# Create Product Attribute
Source: https://docs.mercurjs.com/references/api/admin/product-attributes/create-product-attribute
POST /admin/product-attributes
Create a catalog product attribute.
Creates a shared attribute that products across the catalog can use.
`is_variant_axis` is only valid for `multi_select` attributes. Axis attributes provide the value sets that variants are generated from.
## Body parameters
Attribute name.One of `single_select`, `multi_select`, `unit`, `toggle`, `text`.Unique URL-safe handle, generated from the name if omitted.Attribute description.Whether a value is required on products.Whether the attribute can be used as a storefront filter.Whether the attribute drives variant generation; only valid for `multi_select`.Sort rank of the attribute; must be non-negative.Whether the attribute is active.IDs of categories to link the attribute to; omit for a global attribute.
Initial attribute values for select types.
Value name.Unique URL-safe handle.Sort rank; must be non-negative.Whether the value is active.Custom key-value data.Custom key-value data.Custom data passed to workflow hooks.
## Response
The attribute's ID.Attribute name.Unique URL-safe handle.One of `single_select`, `multi_select`, `unit`, `toggle`, `text`.Whether a value is required on products.Whether the attribute can be used as a storefront filter.Whether the attribute drives variant generation.Whether the attribute is active.The attribute's values.Linked categories with `id`, `name`, and `handle`.Creation timestamp.Last update timestamp.
```bash cURL theme={null}
curl -X POST 'http://localhost:9000/admin/product-attributes' \
-H 'Authorization: Bearer ' \
-H 'Content-Type: application/json' \
-d '{
"name": "Material",
"type": "single_select",
"is_filterable": true,
"values": [{ "name": "Linen" }, { "name": "Cotton" }]
}'
```
```ts JS Client theme={null}
const { product_attribute } = await client.admin.productAttributes.mutate({
name: "Material",
type: "single_select",
is_filterable: true,
values: [{ name: "Linen" }, { name: "Cotton" }],
})
```
```json 200 theme={null}
{
"product_attribute": {
"id": "pattr_01HXYZABCDEF",
"name": "Material",
"handle": "material",
"type": "single_select",
"is_required": false,
"is_filterable": true,
"is_variant_axis": false,
"is_active": true,
"values": [
{ "id": "pattrval_01HXYZABCDEF", "name": "Linen", "rank": 0 },
{ "id": "pattrval_01HXYZABCDEG", "name": "Cotton", "rank": 1 }
]
}
}
```
# Delete Attribute Value
Source: https://docs.mercurjs.com/references/api/admin/product-attributes/delete-attribute-value
DELETE /admin/product-attributes/{id}/values/{value_id}
Delete a value from a product attribute.
Deletes the value and returns the parent attribute.
## Path parameters
The attribute's ID.The value's ID.
## Response
The attribute's ID.Attribute name.The attribute's type.Remaining values after deletion.Last update timestamp.
```bash cURL theme={null}
curl -X DELETE 'http://localhost:9000/admin/product-attributes/pattr_01HXYZABCDEF/values/pattrval_01HXYZABCDEF' \
-H 'Authorization: Bearer '
```
```ts JS Client theme={null}
const { product_attribute } =
await client.admin.productAttributes.$id.values.$value_id.delete({
$id: "pattr_01HXYZABCDEF",
$value_id: "pattrval_01HXYZABCDEF",
})
```
```json 200 theme={null}
{
"product_attribute": {
"id": "pattr_01HXYZABCDEF",
"name": "Material",
"type": "single_select",
"values": []
}
}
```
# Delete Product Attribute
Source: https://docs.mercurjs.com/references/api/admin/product-attributes/delete-product-attribute
DELETE /admin/product-attributes/{id}
Delete a catalog product attribute.
Deletes the attribute and its values.
## Path parameters
The attribute's ID.
## Response
The deleted attribute's ID.Always `product_attribute`.Always `true`.
```bash cURL theme={null}
curl -X DELETE 'http://localhost:9000/admin/product-attributes/pattr_01HXYZABCDEF' \
-H 'Authorization: Bearer '
```
```ts JS Client theme={null}
const result = await client.admin.productAttributes.$id.delete({
$id: "pattr_01HXYZABCDEF",
})
```
```json 200 theme={null}
{
"id": "pattr_01HXYZABCDEF",
"object": "product_attribute",
"deleted": true
}
```
# List Product Attributes
Source: https://docs.mercurjs.com/references/api/admin/product-attributes/list-product-attributes
GET /admin/product-attributes
Retrieve a paginated list of catalog product attributes.
Returns global (non-product-scoped) attributes, with optional filtering by type, flags, and category.
## Query parameters
Maximum number of records to return.Number of records to skip.Field to sort by, prefix with `-` for descending order.Comma-separated list of fields to include, prefix with `+`/`-` to add or remove from defaults.Search term matched against attribute fields.Filter by attribute ID(s).Filter by attribute handle(s).Filter by type: `single_select`, `multi_select`, `unit`, `toggle`, or `text`.Filter by the required flag.Filter by the variant-axis flag.Filter by the filterable flag.Filter by the active flag.Filter to attributes linked to the given category(ies) or global to all categories.Filter by creation date with operators like `$gte`, `$lte`.Filter by update date with operators like `$gte`, `$lte`.
## Response
The attribute's ID.Attribute name.Unique URL-safe handle.Attribute description.One of `single_select`, `multi_select`, `unit`, `toggle`, `text`.Whether a value is required on products.Whether the attribute can be used as a storefront filter.Whether the attribute drives variant generation.Whether the attribute is active.ID of the actor who created the attribute.Always `null` for catalog attributes.Sort rank of the attribute.The attribute's values.Linked categories with `id`, `name`, and `handle`.Custom key-value data.Creation timestamp.Last update timestamp.Total number of matching attributes.Number of records skipped.Number of records returned.
```bash cURL theme={null}
curl 'http://localhost:9000/admin/product-attributes?type=single_select&limit=20' \
-H 'Authorization: Bearer '
```
```ts JS Client theme={null}
const { product_attributes, count } = await client.admin.productAttributes.query({
type: "single_select",
limit: 20,
})
```
```json 200 theme={null}
{
"product_attributes": [
{
"id": "pattr_01HXYZABCDEF",
"name": "Material",
"handle": "material",
"type": "single_select",
"is_required": false,
"is_filterable": true,
"is_variant_axis": false,
"is_active": true,
"values": [
{ "id": "pattrval_01HXYZABCDEF", "name": "Linen", "rank": 0 }
]
}
],
"count": 1,
"offset": 0,
"limit": 20
}
```
# Retrieve Product Attribute
Source: https://docs.mercurjs.com/references/api/admin/product-attributes/retrieve-product-attribute
GET /admin/product-attributes/{id}
Retrieve a catalog product attribute by ID.
Returns a single attribute with its values and linked categories.
## Path parameters
The attribute's ID.
## Query parameters
Comma-separated list of fields to include, prefix with `+`/`-` to add or remove from defaults.
## Response
The attribute's ID.Attribute name.Unique URL-safe handle.Attribute description.One of `single_select`, `multi_select`, `unit`, `toggle`, `text`.Whether a value is required on products.Whether the attribute can be used as a storefront filter.Whether the attribute drives variant generation.Whether the attribute is active.ID of the actor who created the attribute.Owning product's ID for product-scoped attributes, otherwise `null`.Sort rank of the attribute.
The attribute's values.
The value's ID.Value name.Unique URL-safe handle.Sort rank.Whether the value is active.Linked categories with `id`, `name`, and `handle`.Custom key-value data.Creation timestamp.Last update timestamp.
```bash cURL theme={null}
curl 'http://localhost:9000/admin/product-attributes/pattr_01HXYZABCDEF' \
-H 'Authorization: Bearer '
```
```ts JS Client theme={null}
const { product_attribute } = await client.admin.productAttributes.$id.query({
$id: "pattr_01HXYZABCDEF",
})
```
```json 200 theme={null}
{
"product_attribute": {
"id": "pattr_01HXYZABCDEF",
"name": "Material",
"handle": "material",
"type": "single_select",
"is_required": false,
"is_filterable": true,
"is_variant_axis": false,
"is_active": true,
"values": [
{ "id": "pattrval_01HXYZABCDEF", "name": "Linen", "rank": 0 }
]
}
}
```
# Update Attribute Value
Source: https://docs.mercurjs.com/references/api/admin/product-attributes/update-attribute-value
POST /admin/product-attributes/{id}/values/{value_id}
Update a single value on a product attribute.
Updates the value and returns the parent attribute.
## Path parameters
The attribute's ID.The value's ID.
## Body parameters
Value name.Unique URL-safe handle.Sort rank; must be non-negative.Whether the value is active.Custom key-value data.Custom data passed to workflow hooks.
## Response
The attribute's ID.Attribute name.The attribute's type.All values, including the updated one.Last update timestamp.
```bash cURL theme={null}
curl -X POST 'http://localhost:9000/admin/product-attributes/pattr_01HXYZABCDEF/values/pattrval_01HXYZABCDEF' \
-H 'Authorization: Bearer ' \
-H 'Content-Type: application/json' \
-d '{"name": "Belgian Linen"}'
```
```ts JS Client theme={null}
const { product_attribute } =
await client.admin.productAttributes.$id.values.$value_id.mutate({
$id: "pattr_01HXYZABCDEF",
$value_id: "pattrval_01HXYZABCDEF",
name: "Belgian Linen",
})
```
```json 200 theme={null}
{
"product_attribute": {
"id": "pattr_01HXYZABCDEF",
"name": "Material",
"type": "single_select",
"values": [
{ "id": "pattrval_01HXYZABCDEF", "name": "Belgian Linen", "rank": 0 }
]
}
}
```
# Update Product Attribute
Source: https://docs.mercurjs.com/references/api/admin/product-attributes/update-product-attribute
POST /admin/product-attributes/{id}
Update a catalog product attribute.
Updates the attribute's details; only the provided fields are changed.
`type` is immutable. Sending a `type` different from the attribute's current type returns an error.
## Path parameters
The attribute's ID.
## Body parameters
Attribute name.Unique URL-safe handle.Attribute description.Must match the current type: `single_select`, `multi_select`, `unit`, `toggle`, or `text`.Whether a value is required on products.Whether the attribute can be used as a storefront filter.Whether the attribute drives variant generation; only valid for `multi_select`.Sort rank of the attribute; must be non-negative.Whether the attribute is active.IDs of categories to link the attribute to; replaces the existing links.Custom key-value data.Custom data passed to workflow hooks.
## Response
The attribute's ID.Attribute name.Unique URL-safe handle.The attribute's type.The attribute's values.Linked categories with `id`, `name`, and `handle`.Last update timestamp.
```bash cURL theme={null}
curl -X POST 'http://localhost:9000/admin/product-attributes/pattr_01HXYZABCDEF' \
-H 'Authorization: Bearer ' \
-H 'Content-Type: application/json' \
-d '{"name": "Fabric", "is_filterable": true}'
```
```ts JS Client theme={null}
const { product_attribute } = await client.admin.productAttributes.$id.mutate({
$id: "pattr_01HXYZABCDEF",
name: "Fabric",
is_filterable: true,
})
```
```json 200 theme={null}
{
"product_attribute": {
"id": "pattr_01HXYZABCDEF",
"name": "Fabric",
"handle": "material",
"type": "single_select",
"is_filterable": true,
"updated_at": "2026-06-02T09:00:00.000Z"
}
}
```
# Cancel Product Change
Source: https://docs.mercurjs.com/references/api/admin/product-changes/cancel-product-change
POST /admin/product-changes/{id}/cancel
Cancel a pending product change without applying it.
Cancels the pending change set and returns the updated change record.
## Path parameters
The product change's ID.
## Body parameters
Note recorded with the cancellation, visible to operators only.
## Response
The product change's ID.ID of the product the change applied to.Change status after cancellation.ID of the actor who canceled the change.When the change was canceled.The change's individual actions with `action`, `ordering`, `details`, and `applied`.Creation timestamp.Last update timestamp.
```bash cURL theme={null}
curl -X POST 'http://localhost:9000/admin/product-changes/prodch_01HXYZABCDEF/cancel' \
-H 'Authorization: Bearer ' \
-H 'Content-Type: application/json' \
-d '{}'
```
```ts JS Client theme={null}
const { product_change } = await client.admin.productChanges.$id.cancel.mutate({
$id: "prodch_01HXYZABCDEF",
})
```
```json 200 theme={null}
{
"product_change": {
"id": "prodch_01HXYZABCDEF",
"product_id": "prod_01HXYZABCDEF",
"status": "canceled",
"canceled_by": "user_01HXYZABCDEF",
"canceled_at": "2026-06-02T09:00:00.000Z",
"actions": [
{
"id": "prodchact_01HXYZABCDEF",
"action": "update_product",
"ordering": 0,
"details": { "title": "Linen Shirt v2" },
"applied": false
}
]
}
}
```
# Confirm Product Change
Source: https://docs.mercurjs.com/references/api/admin/product-changes/confirm-product-change
POST /admin/product-changes/{id}/confirm
Approve a pending product change and apply it to the product.
Applies the pending change set to the product and marks the change as confirmed.
## Path parameters
The product change's ID.
## Body parameters
Note recorded on the confirmation, visible to operators only.
## Response
The confirmed product change's ID.Always `product_change`.Always `true`. The change is no longer pending.
```bash cURL theme={null}
curl -X POST 'http://localhost:9000/admin/product-changes/prodch_01HXYZABCDEF/confirm' \
-H 'Authorization: Bearer ' \
-H 'Content-Type: application/json' \
-d '{"internal_note": "Approved after review"}'
```
```ts JS Client theme={null}
const result = await client.admin.productChanges.$id.confirm.mutate({
$id: "prodch_01HXYZABCDEF",
internal_note: "Approved after review",
})
```
```json 200 theme={null}
{
"id": "prodch_01HXYZABCDEF",
"object": "product_change",
"deleted": true
}
```
# Batch Product Attributes
Source: https://docs.mercurjs.com/references/api/admin/products/batch-product-attributes
POST /admin/products/{id}/attributes/batch
Add, remove, and update attributes on a product in one request.
Applies attribute changes to the product in the order `remove` → `add` → `update`.
## Path parameters
The product's ID.
## Body parameters
Attributes to attach, one of two shapes per item.
ID of an existing catalog attribute.IDs of attribute values to select.Scalar value for `text`, `unit`, or `toggle` attributes.Attribute title.One of `single_select`, `multi_select`, `unit`, `toggle`, `text`; required for non-axis inline attributes unless `value` is a boolean.Value names to create and select.Scalar value for non-select types.Whether the attribute drives variant generation; only allowed on `multi_select` attributes.Whether the attribute can be used as a storefront filter.Whether a value is required on products.Attribute description.Custom key-value data.IDs of attributes to detach from the product.
Changes to attributes already on the product.
The attribute's ID.New attribute title.Value names (or `{ value }` objects) to add to the selection.Value IDs to remove from the selection.New scalar value for non-select types.
## Response
The product's ID.Product title.Current product status.Attribute values selected on the product, each with its parent `attribute`.Product-scoped (inline) attributes with their `values`.
```bash cURL theme={null}
curl -X POST 'http://localhost:9000/admin/products/prod_01HXYZABCDEF/attributes/batch' \
-H 'Authorization: Bearer ' \
-H 'Content-Type: application/json' \
-d '{
"add": [{ "id": "pattr_01HXYZABCDEF", "value_ids": ["pattrval_01HXYZABCDEF"] }],
"remove": ["pattr_01HXYZABCDEG"]
}'
```
```ts JS Client theme={null}
const { product } = await client.admin.products.$id.attributes.batch.mutate({
$id: "prod_01HXYZABCDEF",
add: [{ id: "pattr_01HXYZABCDEF", value_ids: ["pattrval_01HXYZABCDEF"] }],
remove: ["pattr_01HXYZABCDEG"],
})
```
```json 200 theme={null}
{
"product": {
"id": "prod_01HXYZABCDEF",
"title": "Linen Shirt",
"status": "published",
"product_attribute_values": [
{
"id": "pattrval_01HXYZABCDEF",
"name": "Linen",
"attribute": { "id": "pattr_01HXYZABCDEF", "name": "Material", "type": "single_select" }
}
],
"scoped_attributes": []
}
}
```
# Confirm Product
Source: https://docs.mercurjs.com/references/api/admin/products/confirm-product
POST /admin/products/{id}/confirm
Approve a proposed product for publishing.
Approves the product's publish request and returns the updated product.
## Path parameters
The product's ID.
## Query parameters
Comma-separated list of fields to include, prefix with `+`/`-` to add or remove from defaults.
## Body parameters
Note recorded on the approval, visible to operators only.
## Response
The product's ID.Product title.Product status after confirmation.Last update timestamp.
```bash cURL theme={null}
curl -X POST 'http://localhost:9000/admin/products/prod_01HXYZABCDEF/confirm' \
-H 'Authorization: Bearer ' \
-H 'Content-Type: application/json' \
-d '{"internal_note": "Looks good"}'
```
```ts JS Client theme={null}
const { product } = await client.admin.products.$id.confirm.mutate({
$id: "prod_01HXYZABCDEF",
internal_note: "Looks good",
})
```
```json 200 theme={null}
{
"product": {
"id": "prod_01HXYZABCDEF",
"title": "Linen Shirt",
"status": "published",
"updated_at": "2026-06-02T09:00:00.000Z"
}
}
```
# Create Product
Source: https://docs.mercurjs.com/references/api/admin/products/create-product
POST /admin/products
Create a master catalog product.
Creates a shared master product that sellers can be made eligible to sell.
Pass `seller_ids` to set which sellers may sell the product. The `attributes` array accepts a unified input: reference an existing catalog attribute by `id`, or create an inline product-scoped attribute by `title`.
## Body parameters
Product title.Product subtitle.Product description.Unique URL-safe handle, generated from the title if omitted.One of `draft`, `proposed`, `published`, `rejected`.Whether the product is a gift card.Whether discounts can apply to the product.URL of the product thumbnail.
Product images.
Image URL.ID of the product in an external system.ID of the product type.ID of the collection to assign the product to.IDs of sellers eligible to sell the product.
Categories to assign the product to.
Category ID.
Tags to assign to the product.
Tag ID.
Product options used to build variants.
Option title, for example `Size`.Possible option values.
Unified attribute input, one of two shapes per item.
ID of an existing catalog attribute.IDs of attribute values to select.Scalar value for `text`, `unit`, or `toggle` attributes.Attribute title.One of `single_select`, `multi_select`, `unit`, `toggle`, `text`.Value names to create and select.Scalar value for non-select types.Whether the attribute drives variant generation; only valid for `multi_select`.Whether the attribute can be used as a storefront filter.Whether a value is required on products.Attribute description.Custom key-value data.
Product variants.
Variant title.Variant SKU.Variant EAN.Variant UPC.Variant ISBN.Variant ASIN.Variant GTIN.Variant barcode.Harmonized System code.Manufacturer identification code.Sort rank of the variant.Variant weight.Variant length.Variant height.Variant width.Country of origin.Variant material.Map of option title to option value, for example `{"Size": "M"}`.Custom key-value data.Product weight.Product length.Product height.Product width.Harmonized System code.Manufacturer identification code.Country of origin.Product material.Custom key-value data.Custom data passed to workflow hooks.
## Response
The product's ID.Product title.Unique URL-safe handle.One of `draft`, `proposed`, `published`, `rejected`.Created variants.Attribute values selected on the product.Product-scoped (inline) attributes with their values.Computed attribute groups with selected `values` and `all_values`.Creation timestamp.Last update timestamp.
```bash cURL theme={null}
curl -X POST 'http://localhost:9000/admin/products' \
-H 'Authorization: Bearer ' \
-H 'Content-Type: application/json' \
-d '{
"title": "Linen Shirt",
"seller_ids": ["sel_01HXYZABCDEF"],
"options": [{ "title": "Size", "values": ["S", "M"] }],
"variants": [
{ "title": "S", "sku": "LS-S", "options": { "Size": "S" } },
{ "title": "M", "sku": "LS-M", "options": { "Size": "M" } }
],
"attributes": [{ "title": "Material", "type": "single_select", "values": ["Linen"] }]
}'
```
```ts JS Client theme={null}
const { product } = await client.admin.products.mutate({
title: "Linen Shirt",
seller_ids: ["sel_01HXYZABCDEF"],
options: [{ title: "Size", values: ["S", "M"] }],
variants: [
{ title: "S", sku: "LS-S", options: { Size: "S" } },
{ title: "M", sku: "LS-M", options: { Size: "M" } },
],
attributes: [{ title: "Material", type: "single_select", values: ["Linen"] }],
})
```
```json 200 theme={null}
{
"product": {
"id": "prod_01HXYZABCDEF",
"title": "Linen Shirt",
"handle": "linen-shirt",
"status": "draft",
"variants": [
{ "id": "variant_01HXYZABCDEF", "title": "S", "sku": "LS-S" },
{ "id": "variant_01HXYZABCDEG", "title": "M", "sku": "LS-M" }
]
}
}
```
# Create Product Variant
Source: https://docs.mercurjs.com/references/api/admin/products/create-product-variant
POST /admin/products/{id}/variants
Add a variant to a product.
Creates a new variant on the product and returns the updated product.
## Path parameters
The product's ID.
## Body parameters
Variant title.Variant SKU.Variant EAN.Variant UPC.Variant ISBN.Variant ASIN.Variant GTIN.Variant barcode.Harmonized System code.Manufacturer identification code.Sort rank of the variant.Variant weight.Variant length.Variant height.Variant width.Country of origin.Variant material.Map of option title to option value, for example `{"Size": "L"}`.Custom key-value data.Custom data passed to workflow hooks.
## Response
The product's ID.Product title.All variants, including the created one.Last update timestamp.
```bash cURL theme={null}
curl -X POST 'http://localhost:9000/admin/products/prod_01HXYZABCDEF/variants' \
-H 'Authorization: Bearer ' \
-H 'Content-Type: application/json' \
-d '{"title": "L", "sku": "LS-L", "options": {"Size": "L"}}'
```
```ts JS Client theme={null}
const { product } = await client.admin.products.$id.variants.mutate({
$id: "prod_01HXYZABCDEF",
title: "L",
sku: "LS-L",
options: { Size: "L" },
})
```
```json 200 theme={null}
{
"product": {
"id": "prod_01HXYZABCDEF",
"title": "Linen Shirt",
"variants": [
{ "id": "variant_01HXYZABCDEF", "title": "M", "sku": "LS-M" },
{ "id": "variant_01HXYZABCDEG", "title": "L", "sku": "LS-L" }
]
}
}
```
# Delete Product
Source: https://docs.mercurjs.com/references/api/admin/products/delete-product
DELETE /admin/products/{id}
Delete a master catalog product.
Soft-deletes the product and its variants.
## Path parameters
The product's ID.
## Response
The deleted product's ID.Always `product`.Always `true`.
```bash cURL theme={null}
curl -X DELETE 'http://localhost:9000/admin/products/prod_01HXYZABCDEF' \
-H 'Authorization: Bearer '
```
```ts JS Client theme={null}
const result = await client.admin.products.$id.delete({
$id: "prod_01HXYZABCDEF",
})
```
```json 200 theme={null}
{
"id": "prod_01HXYZABCDEF",
"object": "product",
"deleted": true
}
```
# Delete Product Variant
Source: https://docs.mercurjs.com/references/api/admin/products/delete-product-variant
DELETE /admin/products/{id}/variants/{variant_id}
Delete a variant from a product.
Deletes the variant and returns the parent product.
## Path parameters
The product's ID.The variant's ID.
## Response
The deleted variant's ID.Always `variant`.Always `true`.The parent product after deletion.
```bash cURL theme={null}
curl -X DELETE 'http://localhost:9000/admin/products/prod_01HXYZABCDEF/variants/variant_01HXYZABCDEF' \
-H 'Authorization: Bearer '
```
```ts JS Client theme={null}
const result = await client.admin.products.$id.variants.$variant_id.delete({
$id: "prod_01HXYZABCDEF",
$variant_id: "variant_01HXYZABCDEF",
})
```
```json 200 theme={null}
{
"id": "variant_01HXYZABCDEF",
"object": "variant",
"deleted": true,
"parent": {
"id": "prod_01HXYZABCDEF",
"title": "Linen Shirt",
"variants": []
}
}
```
# List Product Variants
Source: https://docs.mercurjs.com/references/api/admin/products/list-product-variants
GET /admin/products/{id}/variants
Retrieve a paginated list of a product's variants.
Returns the variants of a single product, with optional filtering by SKU and identifiers.
## Path parameters
The product's ID.
## Query parameters
Maximum number of records to return.Number of records to skip.Field to sort by, prefix with `-` for descending order.Comma-separated list of fields to include, prefix with `+`/`-` to add or remove from defaults.Search term matched against variant fields.Filter by variant ID(s).Filter by SKU.Filter by EAN.Filter by UPC.Filter by barcode.
## Response
The variant's ID.Variant title.Variant SKU.Variant EAN.Variant UPC.Variant barcode.Harmonized System code.Manufacturer identification code.Sort rank of the variant.Variant weight.Variant length.Variant height.Variant width.Country of origin.Variant material.ID of the parent product.Whether inventory is tracked for the variant.Whether the variant can be ordered when out of stock.The variant's option values.Custom key-value data.Creation timestamp.Last update timestamp.Total number of matching variants.Number of records skipped.Number of records returned.
```bash cURL theme={null}
curl 'http://localhost:9000/admin/products/prod_01HXYZABCDEF/variants?limit=20' \
-H 'Authorization: Bearer '
```
```ts JS Client theme={null}
const { variants, count } = await client.admin.products.$id.variants.query({
$id: "prod_01HXYZABCDEF",
limit: 20,
})
```
```json 200 theme={null}
{
"variants": [
{
"id": "variant_01HXYZABCDEF",
"title": "M",
"sku": "LS-M",
"product_id": "prod_01HXYZABCDEF",
"manage_inventory": false,
"allow_backorder": false,
"variant_rank": 0
}
],
"count": 1,
"offset": 0,
"limit": 20
}
```
# List Products
Source: https://docs.mercurjs.com/references/api/admin/products/list-products
GET /admin/products
Retrieve a paginated list of master catalog products.
Returns all products across the marketplace, with optional filtering by status, seller, category, and more.
## Query parameters
Maximum number of records to return.Number of records to skip.Field to sort by, prefix with `-` for descending order.Comma-separated list of fields to include, prefix with `+`/`-` to add or remove from defaults.Search term matched against product fields.Filter by product ID(s).Filter by product title.Filter by product handle.Filter by the seller(s) eligible to sell the product.Filter by status: `draft`, `proposed`, `published`, or `rejected`.Filter by collection ID(s).Filter by product type ID(s).Filter by category ID(s).Filter by tag ID(s).Filter by variant SKU.Filter by variant EAN.Filter by variant UPC.Filter by variant barcode.Filter to products that have (or don't have) seller offers.Filter by creation date with operators like `$gte`, `$lte`.Filter by update date with operators like `$gte`, `$lte`.Filter by deletion date with operators like `$gte`, `$lte`.
## Response
The product's ID.Product title.Product subtitle.One of `draft`, `proposed`, `published`, `rejected`.Product description.Unique URL-safe handle.Whether the product is a gift card.Whether discounts can apply to the product.URL of the product thumbnail.ID of the product in an external system.ID of the product's collection.ID of the product's type.The collection with `id`, `title`, and `handle`.Categories with `id`, `name`, and `handle`.Variants with `id`, `title`, `sku`, `manage_inventory`, `allow_backorder`, and `variant_rank`.Attribute values selected on the product, each with its parent `attribute`.Product-scoped (inline) attributes with their `values`.Computed attribute groups, each with the selected `values` and the attribute's full `all_values`.Product weight.Product length.Product height.Product width.Harmonized System code.Manufacturer identification code.Country of origin.Product material.Custom key-value data.Creation timestamp.Last update timestamp.Total number of matching products.Number of records skipped.Number of records returned.
```bash cURL theme={null}
curl 'http://localhost:9000/admin/products?status[]=published&limit=20' \
-H 'Authorization: Bearer '
```
```ts JS Client theme={null}
const { products, count } = await client.admin.products.query({
status: ["published"],
limit: 20,
})
```
```json 200 theme={null}
{
"products": [
{
"id": "prod_01HXYZABCDEF",
"title": "Linen Shirt",
"handle": "linen-shirt",
"status": "published",
"thumbnail": "https://cdn.example.com/linen-shirt.png",
"variants": [
{ "id": "variant_01HXYZABCDEF", "title": "M", "sku": "LS-M", "variant_rank": 0 }
],
"created_at": "2026-06-01T10:00:00.000Z",
"updated_at": "2026-06-01T10:00:00.000Z"
}
],
"count": 1,
"offset": 0,
"limit": 20
}
```
# Preview Product Changes
Source: https://docs.mercurjs.com/references/api/admin/products/preview-product
GET /admin/products/{id}/preview
Retrieve the pending change set for a product.
Returns the product's pending change request with its individual change actions, or `null` if no change is pending.
The response contains the pending `product_change` record and its `actions` (the raw edits awaiting review), not a merged product payload.
## Path parameters
The product's ID.
## Response
The product change's ID.ID of the product the change applies to.Always `pending` for this endpoint.Note visible to operators only.Note visible to the vendor.ID of the actor who created the change.
Individual change actions, in order.
The action's ID.The action type.Application order of the action.The proposed field changes.Whether the action has been applied.Creation timestamp.Last update timestamp.
```bash cURL theme={null}
curl 'http://localhost:9000/admin/products/prod_01HXYZABCDEF/preview' \
-H 'Authorization: Bearer '
```
```ts JS Client theme={null}
const { product_change } = await client.admin.products.$id.preview.query({
$id: "prod_01HXYZABCDEF",
})
```
```json 200 theme={null}
{
"product_change": {
"id": "prodch_01HXYZABCDEF",
"product_id": "prod_01HXYZABCDEF",
"status": "pending",
"internal_note": null,
"external_note": null,
"actions": [
{
"id": "prodchact_01HXYZABCDEF",
"action": "update_product",
"ordering": 0,
"details": { "title": "Linen Shirt v2" },
"applied": false
}
],
"created_at": "2026-06-01T10:00:00.000Z",
"updated_at": "2026-06-01T10:00:00.000Z"
}
}
```
# Reject Product
Source: https://docs.mercurjs.com/references/api/admin/products/reject-product
POST /admin/products/{id}/reject
Reject a proposed product.
Rejects the product's publish request and returns the updated product.
## Path parameters
The product's ID.
## Query parameters
Comma-separated list of fields to include, prefix with `+`/`-` to add or remove from defaults.
## Body parameters
Rejection reason shared with the vendor.
## Response
The product's ID.Product title.Product status after rejection.Last update timestamp.
```bash cURL theme={null}
curl -X POST 'http://localhost:9000/admin/products/prod_01HXYZABCDEF/reject' \
-H 'Authorization: Bearer ' \
-H 'Content-Type: application/json' \
-d '{"message": "Images are too low resolution"}'
```
```ts JS Client theme={null}
const { product } = await client.admin.products.$id.reject.mutate({
$id: "prod_01HXYZABCDEF",
message: "Images are too low resolution",
})
```
```json 200 theme={null}
{
"product": {
"id": "prod_01HXYZABCDEF",
"title": "Linen Shirt",
"status": "rejected",
"updated_at": "2026-06-02T09:00:00.000Z"
}
}
```
# Request Product Changes
Source: https://docs.mercurjs.com/references/api/admin/products/request-product-changes
POST /admin/products/{id}/request-changes
Ask the vendor to revise a product before it can be published.
Flags the product as requiring action and records a message for the vendor.
## Path parameters
The product's ID.
## Query parameters
Comma-separated list of fields to include, prefix with `+`/`-` to add or remove from defaults.
## Body parameters
Message describing the requested changes, shared with the vendor.
## Response
The product's ID.Product title.Current product status.Last update timestamp.
```bash cURL theme={null}
curl -X POST 'http://localhost:9000/admin/products/prod_01HXYZABCDEF/request-changes' \
-H 'Authorization: Bearer ' \
-H 'Content-Type: application/json' \
-d '{"message": "Please add size measurements to the description"}'
```
```ts JS Client theme={null}
const { product } = await client.admin.products.$id.requestChanges.mutate({
$id: "prod_01HXYZABCDEF",
message: "Please add size measurements to the description",
})
```
```json 200 theme={null}
{
"product": {
"id": "prod_01HXYZABCDEF",
"title": "Linen Shirt",
"status": "proposed",
"updated_at": "2026-06-02T09:00:00.000Z"
}
}
```
# Retrieve Product
Source: https://docs.mercurjs.com/references/api/admin/products/retrieve-product
GET /admin/products/{id}
Retrieve a master catalog product by ID.
Returns a single product with its variants, attribute values, and product-scoped attributes.
## Path parameters
The product's ID.
## Query parameters
Comma-separated list of fields to include, prefix with `+`/`-` to add or remove from defaults.Scope offer data (when requested via `fields`) to a single seller.
## Response
The product's ID.Product title.Product subtitle.One of `draft`, `proposed`, `published`, `rejected`.Product description.Unique URL-safe handle.Whether the product is a gift card.Whether discounts can apply to the product.URL of the product thumbnail.The collection with `id`, `title`, and `handle`.Categories with `id`, `name`, and `handle`.Variants with `id`, `title`, `sku`, `manage_inventory`, `allow_backorder`, and `variant_rank`.
Attribute values selected on the product.
The value's ID.Value name.Sort rank.The parent attribute with `id`, `name`, `handle`, `type`, `is_variant_axis`, `is_required`, `rank`, and its full `values` list.
Product-scoped (inline) attributes.
The attribute's ID.Attribute name.Attribute handle.One of `single_select`, `multi_select`, `unit`, `toggle`, `text`.Whether the attribute drives variant generation.Values with `id`, `name`, and `rank`.Computed attribute groups, each with the selected `values` and the attribute's full `all_values`.Custom key-value data.Creation timestamp.Last update timestamp.
```bash cURL theme={null}
curl 'http://localhost:9000/admin/products/prod_01HXYZABCDEF' \
-H 'Authorization: Bearer '
```
```ts JS Client theme={null}
const { product } = await client.admin.products.$id.query({
$id: "prod_01HXYZABCDEF",
})
```
```json 200 theme={null}
{
"product": {
"id": "prod_01HXYZABCDEF",
"title": "Linen Shirt",
"handle": "linen-shirt",
"status": "published",
"variants": [
{ "id": "variant_01HXYZABCDEF", "title": "M", "sku": "LS-M", "variant_rank": 0 }
],
"product_attribute_values": [
{
"id": "pattrval_01HXYZABCDEF",
"name": "Linen",
"attribute": { "id": "pattr_01HXYZABCDEF", "name": "Material", "type": "single_select" }
}
],
"scoped_attributes": []
}
}
```
# Retrieve Product Variant
Source: https://docs.mercurjs.com/references/api/admin/products/retrieve-product-variant
GET /admin/products/{id}/variants/{variant_id}
Retrieve a single variant of a product.
Returns one variant scoped to the given product.
## Path parameters
The product's ID.The variant's ID.
## Query parameters
Comma-separated list of fields to include, prefix with `+`/`-` to add or remove from defaults.
## Response
The variant's ID.Variant title.Variant SKU.Variant EAN.Variant UPC.Variant barcode.Sort rank of the variant.Variant weight.Variant length.Variant height.Variant width.Country of origin.Variant material.ID of the parent product.Whether inventory is tracked for the variant.Whether the variant can be ordered when out of stock.The variant's option values.Custom key-value data.Creation timestamp.Last update timestamp.
```bash cURL theme={null}
curl 'http://localhost:9000/admin/products/prod_01HXYZABCDEF/variants/variant_01HXYZABCDEF' \
-H 'Authorization: Bearer '
```
```ts JS Client theme={null}
const { variant } = await client.admin.products.$id.variants.$variant_id.query({
$id: "prod_01HXYZABCDEF",
$variant_id: "variant_01HXYZABCDEF",
})
```
```json 200 theme={null}
{
"variant": {
"id": "variant_01HXYZABCDEF",
"title": "M",
"sku": "LS-M",
"product_id": "prod_01HXYZABCDEF",
"manage_inventory": false,
"allow_backorder": false,
"variant_rank": 0
}
}
```
# Set Product Sellers
Source: https://docs.mercurjs.com/references/api/admin/products/set-product-sellers
POST /admin/products/{id}/sellers
Manage which sellers are eligible to sell a product.
Links or unlinks sellers from the product to control selling eligibility.
## Path parameters
The product's ID.
## Body parameters
IDs of sellers to link to the product.IDs of sellers to unlink from the product.
## Response
The product's ID.Always `product`.
```bash cURL theme={null}
curl -X POST 'http://localhost:9000/admin/products/prod_01HXYZABCDEF/sellers' \
-H 'Authorization: Bearer ' \
-H 'Content-Type: application/json' \
-d '{"add": ["sel_01HXYZABCDEF"], "remove": ["sel_01HXYZABCDEG"]}'
```
```ts JS Client theme={null}
const result = await client.admin.products.$id.sellers.mutate({
$id: "prod_01HXYZABCDEF",
add: ["sel_01HXYZABCDEF"],
remove: ["sel_01HXYZABCDEG"],
})
```
```json 200 theme={null}
{
"id": "prod_01HXYZABCDEF",
"object": "product"
}
```
# Update Product
Source: https://docs.mercurjs.com/references/api/admin/products/update-product
POST /admin/products/{id}
Update a master catalog product.
Updates the product's details; only the provided fields are changed.
## Path parameters
The product's ID.
## Body parameters
Product title.Product subtitle.Product description.Unique URL-safe handle.One of `draft`, `proposed`, `published`, `rejected`.Whether the product is a gift card.Whether discounts can apply to the product.URL of the product thumbnail.
Product images; replaces the existing set.
Existing image ID to keep.Image URL.ID of the product in an external system.ID of the product type.ID of the collection to assign the product to.
Categories to assign; replaces the existing set.
Category ID.
Tags to assign; replaces the existing set.
Tag ID.
Product options; replaces the existing set.
Option title.Possible option values.
Variants to upsert; include `id` to update an existing variant.
Existing variant ID.Variant title.Variant SKU.Variant EAN.Variant UPC.Variant ISBN.Variant ASIN.Variant GTIN.Variant barcode.Harmonized System code.Manufacturer identification code.URL of the variant thumbnail.Sort rank of the variant.Variant weight.Variant length.Variant height.Variant width.Country of origin.Variant material.Prices with `id`, `currency_code`, `amount`, `min_quantity`, `max_quantity`, and `rules`.Map of option title to option value.Custom key-value data.Product weight.Product length.Product height.Product width.Harmonized System code.Manufacturer identification code.Country of origin.Product material.Custom key-value data.Custom data passed to workflow hooks.
## Response
The product's ID.Product title.One of `draft`, `proposed`, `published`, `rejected`.Product variants.Attribute values selected on the product.Product-scoped (inline) attributes.Last update timestamp.
```bash cURL theme={null}
curl -X POST 'http://localhost:9000/admin/products/prod_01HXYZABCDEF' \
-H 'Authorization: Bearer ' \
-H 'Content-Type: application/json' \
-d '{"title": "Linen Shirt v2", "status": "published"}'
```
```ts JS Client theme={null}
const { product } = await client.admin.products.$id.mutate({
$id: "prod_01HXYZABCDEF",
title: "Linen Shirt v2",
status: "published",
})
```
```json 200 theme={null}
{
"product": {
"id": "prod_01HXYZABCDEF",
"title": "Linen Shirt v2",
"handle": "linen-shirt",
"status": "published",
"updated_at": "2026-06-02T09:00:00.000Z"
}
}
```
# Update Product Variant
Source: https://docs.mercurjs.com/references/api/admin/products/update-product-variant
POST /admin/products/{id}/variants/{variant_id}
Update a variant of a product.
Updates the variant and returns the parent product.
## Path parameters
The product's ID.The variant's ID.
## Body parameters
Variant title.Variant SKU.Variant EAN.Variant UPC.Variant ISBN.Variant ASIN.Variant GTIN.Variant barcode.Harmonized System code.Manufacturer identification code.URL of the variant thumbnail.Sort rank of the variant.Variant weight.Variant length.Variant height.Variant width.Country of origin.Variant material.
Variant prices.
Existing price ID to update.Price currency code.Price amount.Minimum quantity for the price to apply.Maximum quantity for the price to apply.Map of rule attribute to value.Map of option title to option value.Custom key-value data.Custom data passed to workflow hooks.
## Response
The product's ID.Product title.All variants, including the updated one.Last update timestamp.
```bash cURL theme={null}
curl -X POST 'http://localhost:9000/admin/products/prod_01HXYZABCDEF/variants/variant_01HXYZABCDEF' \
-H 'Authorization: Bearer ' \
-H 'Content-Type: application/json' \
-d '{"sku": "LS-M-2026"}'
```
```ts JS Client theme={null}
const { product } = await client.admin.products.$id.variants.$variant_id.mutate({
$id: "prod_01HXYZABCDEF",
$variant_id: "variant_01HXYZABCDEF",
sku: "LS-M-2026",
})
```
```json 200 theme={null}
{
"product": {
"id": "prod_01HXYZABCDEF",
"title": "Linen Shirt",
"variants": [
{ "id": "variant_01HXYZABCDEF", "title": "M", "sku": "LS-M-2026" }
]
}
}
```
# Add Seller Member
Source: https://docs.mercurjs.com/references/api/admin/sellers/add-seller-member
POST /admin/sellers/{id}/members
Add an existing member to a seller's team.
Links an existing member to the seller with the given role.
To add someone who does not have a member record yet, use [Invite Seller Member](/rc/references/api/admin/sellers/invite-seller-member) instead.
## Path parameters
The seller's ID.
## Body parameters
ID of the existing member to add.ID of the role to assign.
## Response
The seller member's ID.ID of the seller.ID of the linked member.Whether this member owns the seller account.Creation timestamp.
```bash cURL theme={null}
curl -X POST 'http://localhost:9000/admin/sellers/sel_01HXYZABCDEF/members' \
-H 'Authorization: Bearer ' \
-H 'Content-Type: application/json' \
-d '{"member_id": "mem_01HXYZMEMBB", "role_id": "role_01HXYZROLEAA"}'
```
```ts JS Client theme={null}
const { seller_member } = await client.admin.sellers.$id.members.mutate({
$id: "sel_01HXYZABCDEF",
member_id: "mem_01HXYZMEMBB",
role_id: "role_01HXYZROLEAA",
})
```
```json 201 theme={null}
{
"seller_member": {
"id": "selmem_01HXYZSMBB",
"seller_id": "sel_01HXYZABCDEF",
"member_id": "mem_01HXYZMEMBB",
"is_owner": false,
"created_at": "2026-06-15T09:00:00.000Z"
}
}
```
# Approve Seller
Source: https://docs.mercurjs.com/references/api/admin/sellers/approve-seller
POST /admin/sellers/{id}/approve
Approve a pending seller.
Moves the seller from `pending_approval` to `open` and sets `approved_at`.
## Path parameters
The seller's ID.
## Query parameters
Comma-separated list of fields to include, prefix with `+`/`-` to add or remove from defaults.
## Response
The seller's ID.The seller's status, `open` after approval.When the seller was approved.Last update timestamp.
```bash cURL theme={null}
curl -X POST 'http://localhost:9000/admin/sellers/sel_01HXYZABCDEF/approve' \
-H 'Authorization: Bearer '
```
```ts JS Client theme={null}
const { seller } = await client.admin.sellers.$id.approve.mutate({
$id: "sel_01HXYZABCDEF",
})
```
```json 200 theme={null}
{
"seller": {
"id": "sel_01HXYZABCDEF",
"name": "Acme",
"handle": "acme",
"status": "open",
"approved_at": "2026-06-02T09:30:00.000Z",
"updated_at": "2026-06-02T09:30:00.000Z"
}
}
```
# Create Seller
Source: https://docs.mercurjs.com/references/api/admin/sellers/create-seller
POST /admin/sellers
Create a new seller account.
Creates a seller and its initial owner member.
The `member.email` must belong to an existing or invited user; the seller is created with status `pending_approval` unless a `status` is provided.
## Query parameters
Comma-separated list of fields to include, prefix with `+`/`-` to add or remove from defaults.
## Body parameters
Display name of the seller.Contact email of the seller.The seller's currency code.
The initial owner member of the seller.
Email of the owner member.Unique URL-safe handle, generated from the name if omitted.Contact phone number.Seller description.URL of the seller's logo.URL of the seller's banner image.The seller's website URL.ID of the seller in an external system.One of `open`, `pending_approval`, `suspended`, `terminated`.Reason recorded with the status.Whether the seller is marked as premium.Start of a temporary store closure.End of a temporary store closure.Note shown while the store is closed.Custom key-value data.Extra data passed to workflow hooks.
## Response
The seller's ID.Display name of the seller.Unique URL-safe handle.Contact email of the seller.The seller's currency code.One of `open`, `pending_approval`, `suspended`, `terminated`.Whether the seller is marked as premium.The seller's members.Creation timestamp.Last update timestamp.
```bash cURL theme={null}
curl -X POST 'http://localhost:9000/admin/sellers' \
-H 'Authorization: Bearer ' \
-H 'Content-Type: application/json' \
-d '{
"name": "Acme",
"email": "hello@acme.co",
"currency_code": "usd",
"member": { "email": "owner@acme.co" }
}'
```
```ts JS Client theme={null}
const { seller } = await client.admin.sellers.mutate({
name: "Acme",
email: "hello@acme.co",
currency_code: "usd",
member: { email: "owner@acme.co" },
})
```
```json 201 theme={null}
{
"seller": {
"id": "sel_01HXYZABCDEF",
"name": "Acme",
"handle": "acme",
"email": "hello@acme.co",
"currency_code": "usd",
"status": "pending_approval",
"is_premium": false,
"created_at": "2026-06-01T10:00:00.000Z",
"updated_at": "2026-06-01T10:00:00.000Z"
}
}
```
# Delete Member Invite
Source: https://docs.mercurjs.com/references/api/admin/sellers/delete-member-invite
DELETE /admin/sellers/{id}/members/invites/{invite_id}
Delete a seller's member invite.
Deletes a pending member invite.
## Path parameters
The seller's ID.The invite's ID.
## Response
ID of the deleted invite.Always `member_invite`.Whether the invite was deleted.
```bash cURL theme={null}
curl -X DELETE 'http://localhost:9000/admin/sellers/sel_01HXYZABCDEF/members/invites/meminv_01HXYZINVAA' \
-H 'Authorization: Bearer '
```
```ts JS Client theme={null}
const result = await client.admin.sellers.$id.members.invites.$inviteId.delete({
$id: "sel_01HXYZABCDEF",
$inviteId: "meminv_01HXYZINVAA",
})
```
```json 200 theme={null}
{
"id": "meminv_01HXYZINVAA",
"object": "member_invite",
"deleted": true
}
```
# Delete Professional Details
Source: https://docs.mercurjs.com/references/api/admin/sellers/delete-professional-details
DELETE /admin/sellers/{id}/professional-details
Delete a seller's professional details.
Removes the seller's professional details and returns the updated seller.
## Path parameters
The seller's ID.
## Query parameters
Comma-separated list of fields to include, prefix with `+`/`-` to add or remove from defaults.
## Response
The seller's ID.The seller's professional details, `null` after deletion.Last update timestamp.
```bash cURL theme={null}
curl -X DELETE 'http://localhost:9000/admin/sellers/sel_01HXYZABCDEF/professional-details' \
-H 'Authorization: Bearer '
```
```ts JS Client theme={null}
const { seller } = await client.admin.sellers.$id.professionalDetails.delete({
$id: "sel_01HXYZABCDEF",
})
```
```json 200 theme={null}
{
"seller": {
"id": "sel_01HXYZABCDEF",
"name": "Acme",
"status": "open",
"professional_details": null,
"updated_at": "2026-06-12T12:00:00.000Z"
}
}
```
# Invite Seller Member
Source: https://docs.mercurjs.com/references/api/admin/sellers/invite-seller-member
POST /admin/sellers/{id}/members/invite
Invite a new member to a seller's team by email.
Creates a member invite for the given email and role.
## Path parameters
The seller's ID.
## Body parameters
Email address to invite.ID of the role assigned once the invite is accepted.
## Response
The invite's ID.Invited email address.Role assigned on acceptance.Whether the invite was accepted.Token used to accept the invite.When the invite expires.Creation timestamp.
```bash cURL theme={null}
curl -X POST 'http://localhost:9000/admin/sellers/sel_01HXYZABCDEF/members/invite' \
-H 'Authorization: Bearer ' \
-H 'Content-Type: application/json' \
-d '{"email": "new.member@acme.co", "role_id": "role_01HXYZROLEAA"}'
```
```ts JS Client theme={null}
const { member_invite } = await client.admin.sellers.$id.members.invite.mutate({
$id: "sel_01HXYZABCDEF",
email: "new.member@acme.co",
role_id: "role_01HXYZROLEAA",
})
```
```json 201 theme={null}
{
"member_invite": {
"id": "meminv_01HXYZINVAA",
"email": "new.member@acme.co",
"role_id": "role_01HXYZROLEAA",
"accepted": false,
"token": "inv_token_abc123",
"expires_at": "2026-06-22T09:00:00.000Z",
"created_at": "2026-06-15T09:00:00.000Z"
}
}
```
# List Member Invites
Source: https://docs.mercurjs.com/references/api/admin/sellers/list-member-invites
GET /admin/sellers/{id}/members/invites
Retrieve a paginated list of a seller's member invites.
Returns all member invites created for the seller.
## Path parameters
The seller's ID.
## Query parameters
Maximum number of records to return.Number of records to skip.Field to sort by, prefix with `-` for descending order.Comma-separated list of fields to include, prefix with `+`/`-` to add or remove from defaults.
## Response
The invite's ID.Invited email address.Role assigned on acceptance.Whether the invite was accepted.Token used to accept the invite.When the invite expires.Creation timestamp.Last update timestamp.Total number of invites.Number of records skipped.Number of records returned.
```bash cURL theme={null}
curl 'http://localhost:9000/admin/sellers/sel_01HXYZABCDEF/members/invites' \
-H 'Authorization: Bearer '
```
```ts JS Client theme={null}
const { member_invites } = await client.admin.sellers.$id.members.invites.query({
$id: "sel_01HXYZABCDEF",
})
```
```json 200 theme={null}
{
"member_invites": [
{
"id": "meminv_01HXYZINVAA",
"email": "new.member@acme.co",
"role_id": "role_01HXYZROLEAA",
"accepted": false,
"token": "inv_token_abc123",
"expires_at": "2026-06-22T09:00:00.000Z",
"created_at": "2026-06-15T09:00:00.000Z",
"updated_at": "2026-06-15T09:00:00.000Z"
}
],
"count": 1,
"offset": 0,
"limit": 50
}
```
# List Seller Members
Source: https://docs.mercurjs.com/references/api/admin/sellers/list-seller-members
GET /admin/sellers/{id}/members
Retrieve a paginated list of a seller's members.
Returns the members of the seller's team, including their user record and role.
## Path parameters
The seller's ID.
## Query parameters
Maximum number of records to return.Number of records to skip.Field to sort by, prefix with `-` for descending order.Comma-separated list of fields to include, prefix with `+`/`-` to add or remove from defaults.
## Response
The seller member's ID.Whether this member owns the seller account.The underlying member record, including its email.The role assigned to the member.Creation timestamp.Total number of seller members.Number of records skipped.Number of records returned.
```bash cURL theme={null}
curl 'http://localhost:9000/admin/sellers/sel_01HXYZABCDEF/members' \
-H 'Authorization: Bearer '
```
```ts JS Client theme={null}
const { seller_members } = await client.admin.sellers.$id.members.query({
$id: "sel_01HXYZABCDEF",
})
```
```json 200 theme={null}
{
"seller_members": [
{
"id": "selmem_01HXYZSMAA",
"is_owner": true,
"member": {
"id": "mem_01HXYZMEMAA",
"email": "owner@acme.co"
},
"rbac_role": {
"id": "role_01HXYZROLEAA",
"name": "Owner"
},
"created_at": "2026-06-01T10:00:00.000Z"
}
],
"count": 1,
"offset": 0,
"limit": 50
}
```
# List Seller Products
Source: https://docs.mercurjs.com/references/api/admin/sellers/list-seller-products
GET /admin/sellers/{id}/products
Retrieve a paginated list of a seller's products.
Returns the products owned by the seller.
## Path parameters
The seller's ID.
## Query parameters
Maximum number of records to return.Number of records to skip.Field to sort by, prefix with `-` for descending order.Comma-separated list of fields to include, prefix with `+`/`-` to add or remove from defaults.Search term matched against product fields.Filter by product ID(s).Filter by product status.Filter by collection ID(s).Filter by sales channel ID(s).Filter by product type ID(s).Filter by product tag ID(s).Filter by creation date with operators like `$gte`, `$lte`.Filter by update date with operators like `$gte`, `$lte`.
## Response
The product's ID.Product title.Unique URL-safe handle.Product status, e.g. `draft` or `published`.URL of the product's thumbnail.The product's collection.Sales channels the product is available in.The product's variants (IDs only by default).Creation timestamp.Last update timestamp.Total number of the seller's products.Number of records skipped.Number of records returned.
```bash cURL theme={null}
curl 'http://localhost:9000/admin/sellers/sel_01HXYZABCDEF/products?limit=20' \
-H 'Authorization: Bearer '
```
```ts JS Client theme={null}
const { products, count } = await client.admin.sellers.$id.products.query({
$id: "sel_01HXYZABCDEF",
limit: 20,
})
```
```json 200 theme={null}
{
"products": [
{
"id": "prod_01HXYZPRODAA",
"title": "Canvas Tote Bag",
"handle": "canvas-tote-bag",
"status": "published",
"thumbnail": "https://cdn.example.com/tote.jpg",
"collection": null,
"sales_channels": [],
"variants": [{ "id": "variant_01HXYZVARAA" }],
"created_at": "2026-06-03T10:00:00.000Z",
"updated_at": "2026-06-03T10:00:00.000Z"
}
],
"count": 1,
"offset": 0,
"limit": 20
}
```
# List Sellers
Source: https://docs.mercurjs.com/references/api/admin/sellers/list-sellers
GET /admin/sellers
Retrieve a paginated list of sellers.
Returns all sellers on the marketplace, with optional filtering by status, handle, email, and more.
## Query parameters
Maximum number of records to return.Number of records to skip.Field to sort by, prefix with `-` for descending order.Comma-separated list of fields to include, prefix with `+`/`-` to add or remove from defaults.Search term matched against seller fields.Filter by seller ID(s).Filter by seller name(s).Filter by seller handle.Filter by seller email.Filter by status: `open`, `pending_approval`, `suspended`, or `terminated`.Filter by premium flag.Filter by creation date with operators like `$gte`, `$lte`.Filter by update date with operators like `$gte`, `$lte`.
## Response
The seller's ID.Display name of the seller.Unique URL-safe handle.Contact email of the seller.Contact phone number.Seller description.URL of the seller's logo.URL of the seller's banner image.The seller's website URL.ID of the seller in an external system.The seller's currency code.One of `open`, `pending_approval`, `suspended`, `terminated`.Reason recorded with the latest status change.When the seller was approved.When the seller was rejected.Whether the seller is marked as premium.Start of a temporary store closure.End of a temporary store closure.Note shown while the store is closed.The seller's address.The seller's bank payment details.The seller's legal and tax details.The seller's payout account with status and onboarding data.Custom key-value data.Creation timestamp.Last update timestamp.Total number of matching sellers.Number of records skipped.Number of records returned.
```bash cURL theme={null}
curl 'http://localhost:9000/admin/sellers?status=pending_approval&limit=20' \
-H 'Authorization: Bearer '
```
```ts JS Client theme={null}
const { sellers, count } = await client.admin.sellers.query({
status: "pending_approval",
limit: 20,
})
```
```json 200 theme={null}
{
"sellers": [
{
"id": "sel_01HXYZABCDEF",
"name": "Acme",
"handle": "acme",
"email": "hello@acme.co",
"currency_code": "usd",
"status": "pending_approval",
"is_premium": false,
"created_at": "2026-06-01T10:00:00.000Z",
"updated_at": "2026-06-01T10:00:00.000Z"
}
],
"count": 1,
"offset": 0,
"limit": 20
}
```
# Remove Seller Member
Source: https://docs.mercurjs.com/references/api/admin/sellers/remove-seller-member
DELETE /admin/sellers/{id}/members/{member_id}
Remove a member from a seller's team.
Unlinks the seller member from the seller.
## Path parameters
The seller's ID.The seller member's ID.
## Response
ID of the removed seller member.Always `seller_member`.Whether the member was removed.
```bash cURL theme={null}
curl -X DELETE 'http://localhost:9000/admin/sellers/sel_01HXYZABCDEF/members/selmem_01HXYZSMBB' \
-H 'Authorization: Bearer '
```
```ts JS Client theme={null}
const result = await client.admin.sellers.$id.members.$memberId.delete({
$id: "sel_01HXYZABCDEF",
$memberId: "selmem_01HXYZSMBB",
})
```
```json 200 theme={null}
{
"id": "selmem_01HXYZSMBB",
"object": "seller_member",
"deleted": true
}
```
# Resend Member Invite
Source: https://docs.mercurjs.com/references/api/admin/sellers/resend-member-invite
POST /admin/sellers/{id}/members/invites/{invite_id}/resend
Resend a seller's member invite.
Regenerates the invite token, extends its expiry, and re-sends the invite email.
## Path parameters
The seller's ID.The invite's ID.
## Response
The invite's ID.Invited email address.Role assigned on acceptance.Whether the invite was accepted.The regenerated invite token.The new expiry date.
```bash cURL theme={null}
curl -X POST 'http://localhost:9000/admin/sellers/sel_01HXYZABCDEF/members/invites/meminv_01HXYZINVAA/resend' \
-H 'Authorization: Bearer '
```
```ts JS Client theme={null}
const { member_invite } =
await client.admin.sellers.$id.members.invites.$inviteId.resend.mutate({
$id: "sel_01HXYZABCDEF",
$inviteId: "meminv_01HXYZINVAA",
})
```
```json 200 theme={null}
{
"member_invite": {
"id": "meminv_01HXYZINVAA",
"email": "new.member@acme.co",
"role_id": "role_01HXYZROLEAA",
"accepted": false,
"token": "inv_token_def456",
"expires_at": "2026-06-29T09:00:00.000Z"
}
}
```
# Retrieve Seller
Source: https://docs.mercurjs.com/references/api/admin/sellers/retrieve-seller
GET /admin/sellers/{id}
Retrieve a seller by ID.
Returns a single seller, including address, payment details, professional details, payout account, and members.
## Path parameters
The seller's ID.
## Query parameters
Comma-separated list of fields to include, prefix with `+`/`-` to add or remove from defaults.
## Response
The seller's ID.Display name of the seller.Unique URL-safe handle.Contact email of the seller.Contact phone number.Seller description.URL of the seller's logo.URL of the seller's banner image.The seller's website URL.The seller's currency code.One of `open`, `pending_approval`, `suspended`, `terminated`.Reason recorded with the latest status change.When the seller was approved.When the seller was rejected.Whether the seller is marked as premium.The seller's address.The seller's bank payment details.The seller's legal and tax details.The seller's payout account with status and onboarding data.The seller's members.Custom key-value data.Creation timestamp.Last update timestamp.
```bash cURL theme={null}
curl 'http://localhost:9000/admin/sellers/sel_01HXYZABCDEF' \
-H 'Authorization: Bearer '
```
```ts JS Client theme={null}
const { seller } = await client.admin.sellers.$id.query({
$id: "sel_01HXYZABCDEF",
})
```
```json 200 theme={null}
{
"seller": {
"id": "sel_01HXYZABCDEF",
"name": "Acme",
"handle": "acme",
"email": "hello@acme.co",
"currency_code": "usd",
"status": "open",
"approved_at": "2026-06-02T09:30:00.000Z",
"is_premium": false,
"address": null,
"payment_details": null,
"professional_details": null,
"members": [],
"created_at": "2026-06-01T10:00:00.000Z",
"updated_at": "2026-06-02T09:30:00.000Z"
}
}
```
# Suspend Seller
Source: https://docs.mercurjs.com/references/api/admin/sellers/suspend-seller
POST /admin/sellers/{id}/suspend
Suspend a seller.
Sets the seller's status to `suspended`, optionally recording a reason.
## Path parameters
The seller's ID.
## Query parameters
Comma-separated list of fields to include, prefix with `+`/`-` to add or remove from defaults.
## Body parameters
Reason for the suspension, stored in `status_reason`.
## Response
The seller's ID.The seller's status, `suspended` after this call.The recorded suspension reason.Last update timestamp.
```bash cURL theme={null}
curl -X POST 'http://localhost:9000/admin/sellers/sel_01HXYZABCDEF/suspend' \
-H 'Authorization: Bearer ' \
-H 'Content-Type: application/json' \
-d '{"reason": "Policy violation"}'
```
```ts JS Client theme={null}
const { seller } = await client.admin.sellers.$id.suspend.mutate({
$id: "sel_01HXYZABCDEF",
reason: "Policy violation",
})
```
```json 200 theme={null}
{
"seller": {
"id": "sel_01HXYZABCDEF",
"name": "Acme",
"status": "suspended",
"status_reason": "Policy violation",
"updated_at": "2026-06-05T11:00:00.000Z"
}
}
```
# Terminate Seller
Source: https://docs.mercurjs.com/references/api/admin/sellers/terminate-seller
POST /admin/sellers/{id}/terminate
Terminate a seller account.
Sets the seller's status to `terminated`, optionally recording a reason.
## Path parameters
The seller's ID.
## Query parameters
Comma-separated list of fields to include, prefix with `+`/`-` to add or remove from defaults.
## Body parameters
Reason for the termination, stored in `status_reason`.
## Response
The seller's ID.The seller's status, `terminated` after this call.The recorded termination reason.Last update timestamp.
```bash cURL theme={null}
curl -X POST 'http://localhost:9000/admin/sellers/sel_01HXYZABCDEF/terminate' \
-H 'Authorization: Bearer ' \
-H 'Content-Type: application/json' \
-d '{"reason": "Account closed by operator"}'
```
```ts JS Client theme={null}
const { seller } = await client.admin.sellers.$id.terminate.mutate({
$id: "sel_01HXYZABCDEF",
reason: "Account closed by operator",
})
```
```json 200 theme={null}
{
"seller": {
"id": "sel_01HXYZABCDEF",
"name": "Acme",
"status": "terminated",
"status_reason": "Account closed by operator",
"updated_at": "2026-06-10T16:45:00.000Z"
}
}
```
# Unsuspend Seller
Source: https://docs.mercurjs.com/references/api/admin/sellers/unsuspend-seller
POST /admin/sellers/{id}/unsuspend
Lift a seller's suspension.
Restores a suspended seller to `open`.
## Path parameters
The seller's ID.
## Query parameters
Comma-separated list of fields to include, prefix with `+`/`-` to add or remove from defaults.
## Response
The seller's ID.The seller's status, `open` after this call.Last update timestamp.
```bash cURL theme={null}
curl -X POST 'http://localhost:9000/admin/sellers/sel_01HXYZABCDEF/unsuspend' \
-H 'Authorization: Bearer '
```
```ts JS Client theme={null}
const { seller } = await client.admin.sellers.$id.unsuspend.mutate({
$id: "sel_01HXYZABCDEF",
})
```
```json 200 theme={null}
{
"seller": {
"id": "sel_01HXYZABCDEF",
"name": "Acme",
"status": "open",
"status_reason": null,
"updated_at": "2026-06-06T08:15:00.000Z"
}
}
```
# Unterminate Seller
Source: https://docs.mercurjs.com/references/api/admin/sellers/unterminate-seller
POST /admin/sellers/{id}/unterminate
Reverse a seller termination.
Restores a terminated seller to `open`.
## Path parameters
The seller's ID.
## Query parameters
Comma-separated list of fields to include, prefix with `+`/`-` to add or remove from defaults.
## Response
The seller's ID.The seller's status, `open` after this call.Last update timestamp.
```bash cURL theme={null}
curl -X POST 'http://localhost:9000/admin/sellers/sel_01HXYZABCDEF/unterminate' \
-H 'Authorization: Bearer '
```
```ts JS Client theme={null}
const { seller } = await client.admin.sellers.$id.unterminate.mutate({
$id: "sel_01HXYZABCDEF",
})
```
```json 200 theme={null}
{
"seller": {
"id": "sel_01HXYZABCDEF",
"name": "Acme",
"status": "open",
"status_reason": null,
"updated_at": "2026-06-11T09:00:00.000Z"
}
}
```
# Update Seller
Source: https://docs.mercurjs.com/references/api/admin/sellers/update-seller
POST /admin/sellers/{id}
Update a seller's details.
Updates the seller and returns the updated record.
## Path parameters
The seller's ID.
## Query parameters
Comma-separated list of fields to include, prefix with `+`/`-` to add or remove from defaults.
## Body parameters
Display name of the seller.Unique URL-safe handle.Contact email of the seller.Contact phone number.Seller description.URL of the seller's logo.URL of the seller's banner image.The seller's website URL.ID of the seller in an external system.One of `open`, `pending_approval`, `suspended`, `terminated`.Reason recorded with the status.Whether the seller is marked as premium.Start of a temporary store closure.End of a temporary store closure.Note shown while the store is closed.Custom key-value data.Extra data passed to workflow hooks.
## Response
The seller's ID.Display name of the seller.Unique URL-safe handle.Contact email of the seller.One of `open`, `pending_approval`, `suspended`, `terminated`.Whether the seller is marked as premium.Last update timestamp.
```bash cURL theme={null}
curl -X POST 'http://localhost:9000/admin/sellers/sel_01HXYZABCDEF' \
-H 'Authorization: Bearer ' \
-H 'Content-Type: application/json' \
-d '{"description": "Handmade goods.", "is_premium": true}'
```
```ts JS Client theme={null}
const { seller } = await client.admin.sellers.$id.mutate({
$id: "sel_01HXYZABCDEF",
description: "Handmade goods.",
is_premium: true,
})
```
```json 200 theme={null}
{
"seller": {
"id": "sel_01HXYZABCDEF",
"name": "Acme",
"handle": "acme",
"email": "hello@acme.co",
"description": "Handmade goods.",
"status": "open",
"is_premium": true,
"updated_at": "2026-06-03T14:20:00.000Z"
}
}
```
# Upsert Payment Details
Source: https://docs.mercurjs.com/references/api/admin/sellers/upsert-payment-details
POST /admin/sellers/{id}/payment-details
Create or update a seller's bank payment details.
Creates the seller's payment details if none exist, otherwise updates them.
## Path parameters
The seller's ID.
## Query parameters
Comma-separated list of fields to include, prefix with `+`/`-` to add or remove from defaults.
## Body parameters
Two-letter ISO country code of the bank account.Name of the account holder.Name of the bank.IBAN of the account.BIC / SWIFT code.Routing number for US accounts.Account number.Extra data passed to workflow hooks.
## Response
The seller's ID.Two-letter ISO country code of the bank account.Name of the account holder.Name of the bank.IBAN of the account.BIC / SWIFT code.Last update timestamp.
```bash cURL theme={null}
curl -X POST 'http://localhost:9000/admin/sellers/sel_01HXYZABCDEF/payment-details' \
-H 'Authorization: Bearer ' \
-H 'Content-Type: application/json' \
-d '{
"country_code": "de",
"holder_name": "Acme GmbH",
"iban": "DE89370400440532013000",
"bic": "COBADEFFXXX"
}'
```
```ts JS Client theme={null}
const { seller } = await client.admin.sellers.$id.paymentDetails.mutate({
$id: "sel_01HXYZABCDEF",
country_code: "de",
holder_name: "Acme GmbH",
iban: "DE89370400440532013000",
bic: "COBADEFFXXX",
})
```
```json 200 theme={null}
{
"seller": {
"id": "sel_01HXYZABCDEF",
"name": "Acme",
"status": "open",
"payment_details": {
"country_code": "de",
"holder_name": "Acme GmbH",
"bank_name": null,
"iban": "DE89370400440532013000",
"bic": "COBADEFFXXX"
},
"updated_at": "2026-06-12T11:00:00.000Z"
}
}
```
# Upsert Professional Details
Source: https://docs.mercurjs.com/references/api/admin/sellers/upsert-professional-details
POST /admin/sellers/{id}/professional-details
Create or update a seller's legal and tax details.
Creates the seller's professional details if none exist, otherwise updates them.
## Path parameters
The seller's ID.
## Query parameters
Comma-separated list of fields to include, prefix with `+`/`-` to add or remove from defaults.
## Body parameters
Registered corporate name.Company registration number.Tax identification number.Extra data passed to workflow hooks.
## Response
The seller's ID.Registered corporate name.Company registration number.Tax identification number.Last update timestamp.
```bash cURL theme={null}
curl -X POST 'http://localhost:9000/admin/sellers/sel_01HXYZABCDEF/professional-details' \
-H 'Authorization: Bearer ' \
-H 'Content-Type: application/json' \
-d '{
"corporate_name": "Acme GmbH",
"registration_number": "HRB 123456",
"tax_id": "DE123456789"
}'
```
```ts JS Client theme={null}
const { seller } = await client.admin.sellers.$id.professionalDetails.mutate({
$id: "sel_01HXYZABCDEF",
corporate_name: "Acme GmbH",
registration_number: "HRB 123456",
tax_id: "DE123456789",
})
```
```json 200 theme={null}
{
"seller": {
"id": "sel_01HXYZABCDEF",
"name": "Acme",
"status": "open",
"professional_details": {
"corporate_name": "Acme GmbH",
"registration_number": "HRB 123456",
"tax_id": "DE123456789"
},
"updated_at": "2026-06-12T11:30:00.000Z"
}
}
```
# Upsert Seller Address
Source: https://docs.mercurjs.com/references/api/admin/sellers/upsert-seller-address
POST /admin/sellers/{id}/address
Create or update a seller's address.
Creates the seller's address if none exists, otherwise updates it.
## Path parameters
The seller's ID.
## Query parameters
Comma-separated list of fields to include, prefix with `+`/`-` to add or remove from defaults.
## Body parameters
Label for the address.Company name.First name of the contact person.Last name of the contact person.First address line.Second address line.City.Two-letter ISO country code.Province or state.Postal code.Phone number for the address.Custom key-value data.Extra data passed to workflow hooks.
## Response
The seller's ID.First address line.City.Two-letter ISO country code.Postal code.Last update timestamp.
```bash cURL theme={null}
curl -X POST 'http://localhost:9000/admin/sellers/sel_01HXYZABCDEF/address' \
-H 'Authorization: Bearer ' \
-H 'Content-Type: application/json' \
-d '{
"address_1": "123 Market St",
"city": "San Francisco",
"country_code": "us",
"postal_code": "94103"
}'
```
```ts JS Client theme={null}
const { seller } = await client.admin.sellers.$id.address.mutate({
$id: "sel_01HXYZABCDEF",
address_1: "123 Market St",
city: "San Francisco",
country_code: "us",
postal_code: "94103",
})
```
```json 200 theme={null}
{
"seller": {
"id": "sel_01HXYZABCDEF",
"name": "Acme",
"status": "open",
"address": {
"address_1": "123 Market St",
"city": "San Francisco",
"country_code": "us",
"postal_code": "94103"
},
"updated_at": "2026-06-12T10:30:00.000Z"
}
}
```
# API conventions
Source: https://docs.mercurjs.com/references/api/conventions
Authentication, seller scoping, pagination, field selection, and webhooks shared by every Mercur API surface.
Every Mercur API surface follows the same rules for authentication, pagination, and field selection.
Mercur exposes three HTTP surfaces on the Medusa server. They share the conventions below, so once you learn one surface the others behave the same way.
| Surface | Base path | Audience | Authentication |
| --------------------------------------- | ----------- | -------------------- | ---------------------------------------- |
| [Admin API](/rc/references/api/admin) | `/admin/*` | Marketplace operator | Medusa admin user (session or bearer) |
| [Vendor API](/rc/references/api/vendor) | `/vendor/*` | Sellers | Member auth + seller scoping (below) |
| [Store API](/rc/references/api/store) | `/store/*` | Storefront | Mostly public; customer auth where noted |
All requests and responses are JSON. Call these APIs with the typed `@mercurjs/client`. Every route below maps 1:1 to a client call.
## Authentication
### Admin
Admin routes use standard Medusa admin authentication. Log in via `/auth/user/emailpass`, then send the session cookie or an `Authorization: Bearer ` header.
### Vendor
Vendor routes authenticate the **member** actor (`/auth/member/emailpass` to obtain a token) and are additionally scoped to a single seller:
1. The request must carry an `x-seller-id` header, or a seller selected in the session via `POST /vendor/sellers/select`.
2. Middleware verifies the authenticated member belongs to that seller and populates the request's seller context. Every query and mutation on the surface is then filtered to that seller automatically.
3. The member's RBAC roles are resolved for the selected seller.
A handful of vendor routes are public by design: seller registration (`POST /vendor/sellers`), invite acceptance (`POST /vendor/members/invites/accept`), `GET /vendor/stores`, and `GET /vendor/feature-flags`.
### Store
Store routes are public. Customer authentication (session or bearer) is **required** for `/store/order-groups` and **optional** for `/store/offers` and `/store/products`, where it enriches the pricing context when present. Public seller routes only return sellers whose status is `open` and who are not inside a scheduled closure window.
## Pagination
List endpoints use offset pagination with two query parameters:
```
GET /admin/sellers?limit=20&offset=40
```
| Parameter | Description | Default |
| --------- | ----------------------------------- | -------------------------------------------- |
| `limit` | Maximum number of records to return | 50 for most routes (some use 10, 20, or 200) |
| `offset` | Number of records to skip | 0 |
Every list response carries the paging envelope alongside the records:
```json theme={null}
{
"sellers": [ ... ],
"count": 133,
"offset": 40,
"limit": 20
}
```
`count` is the total number of records matching the filters, so `offset + limit < count` means more pages exist.
## Field selection
List and detail endpoints accept a `fields` query parameter to control which fields and relations are returned:
```
GET /vendor/products?fields=id,title,+variants.sku,-description
```
* `+field` adds a field or relation **on top of** the route's defaults.
* `-field` removes one from the defaults.
* A bare `field` (no prefix) **replaces** the default set entirely.
Mixing one unprefixed field into an otherwise-prefixed list switches the
whole parameter to replace mode and silently drops the route defaults.
Prefix every entry with `+` or `-` when you mean to merge.
## Filtering and ordering
List endpoints accept entity-specific filter parameters (documented per route group), a free-text `q` search parameter where the entity has searchable fields, and `order` for sorting (`order=-created_at` for descending).
## Errors
Errors follow Medusa's format:
```json theme={null}
{
"type": "invalid_data",
"message": "Seller with handle already exists"
}
```
Common types: `invalid_data` (400), `unauthorized` (401), `not_allowed` (403), `not_found` (404). The typed client throws these as `ClientError`.
## Webhooks
| Method | Path | Purpose |
| ------ | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| `POST` | `/hooks/payout` | Payout provider webhook receiver. The registered provider (e.g. Stripe Connect) verifies the signature and resolves the event into a payout action. |
## Next steps
# Store API
Source: https://docs.mercurjs.com/references/api/store
Storefront routes under /store/* for discovery, multi-vendor carts, checkout, and order groups.
The Store API is what a storefront talks to.
It extends Medusa's store routes with marketplace discovery (sellers and offers) and replaces the cart completion flow with the multi-vendor order-group split. Most routes are public, and customer authentication requirements are noted per group. See [API conventions](/rc/references/api/conventions) for pagination and field selection.
## Products and discovery
| Method | Path | Purpose |
| ------ | --------------------------------- | -------------------------------------------------------------------- |
| `GET` | `/store/products` | List published products (filtered to sellers visible to the shopper) |
| `GET` | `/store/products/:id` | Retrieve a product |
| `GET` | `/store/product-categories[/:id]` | Category tree with media and icons |
| `GET` | `/store/product-attributes[/:id]` | Filterable attributes for building facet UIs |
## Sellers
| Method | Path | Purpose |
| ------ | -------------------- | ----------------------------------------------------------------------------- |
| `GET` | `/store/sellers` | List public seller storefronts (only `open` sellers outside a closure window) |
| `GET` | `/store/sellers/:id` | Retrieve a seller storefront |
## Offers
| Method | Path | Purpose |
| ------ | ------------------- | --------------------------------------------- |
| `GET` | `/store/offers` | List offers, with per-offer calculated prices |
| `GET` | `/store/offers/:id` | Retrieve an offer |
## Carts and checkout
Mercur overrides the cart routes so a single cart can span multiple sellers:
| Method | Path | Purpose |
| ------ | ----------------------------------- | --------------------------------------------------------------------------- |
| `POST` | `/store/carts/:id/line-items` | Add a line item (offer-aware, multi-seller) |
| `POST` | `/store/carts/:id/shipping-methods` | Add per-seller shipping methods |
| `GET` | `/store/shipping-options` | Shipping options available for the cart, grouped by seller |
| `POST` | `/store/carts/:id/promotions` | Apply promotions |
| `POST` | `/store/carts/:id/complete` | Complete the cart, splitting it into per-seller orders under an order group |
## Order groups
Customer authentication **required**.
| Method | Path | Purpose |
| ------ | ------------------------- | ----------------------------------------------- |
| `GET` | `/store/order-groups` | The customer's order groups |
| `GET` | `/store/order-groups/:id` | An order group with its per-seller child orders |
## Next steps
# Add Line Item
Source: https://docs.mercurjs.com/references/api/store/carts/add-line-item
POST /store/carts/{id}/line-items
Add a seller offer to the cart as a line item.
Adds an item to the cart by offer. Mercur resolves the offer's variant and stamps the `offer_id` on the line item so the cart can later split into per-seller orders.
Unlike vanilla Medusa, this route takes an `offer_id` instead of a `variant_id`; an unknown offer returns a `404`.
## Path parameters
The cart's ID.
## Body parameters
ID of the offer to add.
Quantity to add; must be a positive integer.
Custom unit price overriding the offer's calculated price.
Compare-at unit price shown as the original price.
Custom key-value data stored on the line item; `offer_id` is merged in automatically.
Data passed to the add-to-cart workflow's hooks.
## Response
The cart's ID.The cart's currency.The cart's region.The customer's email.Line items, each carrying `offer_id` in its `metadata`.Selected shipping methods.Applied promotions.The shipping address.The billing address.The cart's payment collection with its sessions.Cart total including tax and shipping.Cart subtotal.Total tax amount.Total discount amount.Total shipping amount.
```bash cURL theme={null}
curl -X POST 'http://localhost:9000/store/carts/cart_01JB2KB1CD/line-items' \
-H 'Content-Type: application/json' \
-H 'x-publishable-api-key: pk_01JB2K3XYZ' \
-d '{"offer_id": "offer_01JB2K6G5H", "quantity": 1}'
```
```ts JS Client theme={null}
const { cart } = await client.store.carts.$id.lineItems.mutate({
$id: "cart_01JB2KB1CD",
offer_id: "offer_01JB2K6G5H",
quantity: 1,
})
```
```json 200 theme={null}
{
"cart": {
"id": "cart_01JB2KB1CD",
"currency_code": "eur",
"region_id": "reg_01JB2K4S1T",
"email": null,
"items": [
{
"id": "cali_01JB2KB3EF",
"title": "M",
"product_title": "Linen Shirt",
"quantity": 1,
"unit_price": 4500,
"metadata": { "offer_id": "offer_01JB2K6G5H" }
}
],
"shipping_methods": [],
"promotions": [],
"shipping_address": null,
"billing_address": null,
"payment_collection": null,
"total": 4500,
"subtotal": 4500,
"tax_total": 0,
"discount_total": 0,
"shipping_total": 0
}
}
```
# Add Shipping Method
Source: https://docs.mercurjs.com/references/api/store/carts/add-shipping-methods
POST /store/carts/{id}/shipping-methods
Add a seller shipping option to the cart.
Adds the chosen shipping option to the cart through Mercur's seller-aware workflow, so each seller's items can carry their own shipping method.
## Path parameters
The cart's ID.
## Query parameters
Comma-separated fields and relations to include on the returned cart; prefix with `+`/`-` to add to or remove from the defaults.
## Body parameters
ID of the shipping option to add.
Provider-specific data forwarded to the fulfillment provider.
Data passed to the workflow's hooks.
## Response
The cart's ID.The cart's currency.Line items in the cart.Shipping methods with `shipping_option_id`, `amount`, and tax lines.Total shipping amount.Cart total including tax and shipping.
```bash cURL theme={null}
curl -X POST 'http://localhost:9000/store/carts/cart_01JB2KB1CD/shipping-methods' \
-H 'Content-Type: application/json' \
-H 'x-publishable-api-key: pk_01JB2K3XYZ' \
-d '{"option_id": "so_01JB2KC4GH"}'
```
```ts JS Client theme={null}
const { cart } = await client.store.carts.$id.shippingMethods.mutate({
$id: "cart_01JB2KB1CD",
option_id: "so_01JB2KC4GH",
})
```
```json 200 theme={null}
{
"cart": {
"id": "cart_01JB2KB1CD",
"currency_code": "eur",
"items": [{ "id": "cali_01JB2KB3EF", "quantity": 1, "unit_price": 4500 }],
"shipping_methods": [
{
"shipping_option_id": "so_01JB2KC4GH",
"amount": 900,
"is_tax_inclusive": false,
"tax_lines": []
}
],
"shipping_total": 900,
"total": 5400
}
}
```
# Apply Promotions
Source: https://docs.mercurjs.com/references/api/store/carts/apply-promotions
POST /store/carts/{id}/promotions
Apply promotion codes to the cart.
Applies promotion codes through Mercur's seller-aware workflow so seller-scoped promotions only adjust that seller's items; sending an empty array replaces (clears) the applied codes.
## Path parameters
The cart's ID.
## Query parameters
Comma-separated fields and relations to include on the returned cart; prefix with `+`/`-` to add to or remove from the defaults.
## Body parameters
Promotion codes to add; an empty array removes all applied codes.
## Response
The cart's ID.Applied promotions with `code` and `application_method`.Line items with their promotion `adjustments`.Total discount amount.Cart total after discounts.
```bash cURL theme={null}
curl -X POST 'http://localhost:9000/store/carts/cart_01JB2KB1CD/promotions' \
-H 'Content-Type: application/json' \
-H 'x-publishable-api-key: pk_01JB2K3XYZ' \
-d '{"promo_codes": ["SUMMER10"]}'
```
```ts JS Client theme={null}
const { cart } = await client.store.carts.$id.promotions.mutate({
$id: "cart_01JB2KB1CD",
promo_codes: ["SUMMER10"],
})
```
```json 200 theme={null}
{
"cart": {
"id": "cart_01JB2KB1CD",
"promotions": [
{
"id": "promo_01JB2KD5JK",
"code": "SUMMER10",
"is_automatic": false,
"application_method": { "type": "percentage", "value": 10, "currency_code": "eur" }
}
],
"items": [
{
"id": "cali_01JB2KB3EF",
"quantity": 1,
"unit_price": 4500,
"adjustments": [
{ "id": "caadj_01JB2KD6LM", "code": "SUMMER10", "promotion_id": "promo_01JB2KD5JK", "amount": 450 }
]
}
],
"discount_total": 450,
"total": 4950
}
}
```
# Complete Cart
Source: https://docs.mercurjs.com/references/api/store/carts/complete-cart
POST /store/carts/{id}/complete
Complete the cart and split it into per-seller orders under an order group.
Completes the cart with Mercur's split-order workflow: items are grouped by seller, one order is created per seller, and all orders are attached to a single order group returned to the shopper.
On payment errors that need customer action (authorization failed or requires more), the route still returns `200` with `type: "cart"`, the refreshed cart, and an `error` object; any other failure is thrown as an error response.
## Path parameters
The cart's ID.
## Query parameters
Comma-separated fields to include on the returned order group; prefix with `+`/`-` to add to or remove from the defaults.
## Response
`order_group` on success, `cart` when payment requires further action.
Present when `type` is `order_group`.
The order group's ID.ID of the purchasing customer.ID of the completed cart.Number of per-seller orders created.Combined total across all child orders.Creation timestamp.Last update timestamp.
Present when `type` is `cart`; the refreshed cart with items, totals, and payment collection.
Present when `type` is `cart`.
The payment error message.The error name.One of `payment_authorization_error` or `payment_requires_more_error`.
```bash cURL theme={null}
curl -X POST 'http://localhost:9000/store/carts/cart_01JB2KB1CD/complete' \
-H 'x-publishable-api-key: pk_01JB2K3XYZ'
```
```ts JS Client theme={null}
const result = await client.store.carts.$id.complete.mutate({ $id: "cart_01JB2KB1CD" })
if (result.type === "order_group") {
console.log(result.order_group.id)
}
```
```json 200 (success) theme={null}
{
"type": "order_group",
"order_group": {
"id": "og_01JB2KE7NP",
"customer_id": "cus_01JB2KE8QR",
"cart_id": "cart_01JB2KB1CD",
"seller_count": 2,
"total": 9900,
"created_at": "2026-05-01T10:05:00.000Z",
"updated_at": "2026-05-01T10:05:00.000Z"
}
}
```
```json 200 (payment error) theme={null}
{
"type": "cart",
"cart": { "id": "cart_01JB2KB1CD", "total": 9900 },
"error": {
"message": "Payment authorization failed",
"name": "Error",
"type": "payment_authorization_error"
}
}
```
# List Offers
Source: https://docs.mercurjs.com/references/api/store/offers/list-offers
GET /store/offers
Retrieve seller offers with per-offer calculated prices.
Returns a paginated list of offers from visible sellers whose products are published.
Customer authentication is optional. When a customer token is provided, the customer's group memberships enter the pricing context used for `calculated_price`. Prices are computed per offer (each offer scopes the variant's price set by an `offer_id` rule) only when `calculated_price` is in `fields` and a pricing context (`region_id`, `country_code`, or `cart_id`) resolves. Requesting `inventory_quantity` or `in_stock` requires a single sales channel: either configured on the publishable key or passed as `sales_channel_id`.
## Query parameters
Maximum number of offers to return.
Number of offers to skip before collecting results.
Field to sort by, prefixed with `-` for descending order.
Comma-separated fields and relations to include; add `calculated_price`, `inventory_quantity`, or `in_stock` to receive the computed fields.
Region used to build the pricing context for `calculated_price`.
Country used for the tax portion of the pricing context.
Province used for the tax portion of the pricing context.
Cart whose region and customer resolve the pricing context.
Free-text search term applied to offer fields.
Filter by one or more offer IDs.
Filter by one or more product IDs.
Filter by one or more variant IDs.
Filter by one or more seller IDs.
Filter by one or more offer SKUs.
Filter by creation date using operators like `$gt`, `$gte`, `$lt`, `$lte`.
Filter by update date using operators like `$gt`, `$gte`, `$lt`, `$lte`.
## Response
The offer's ID.ID of the selling seller.ID of the offered product variant.ID of the offered product.ID of the offer's shipping profile.The offer's SKU.The offer's EAN.The offer's UPC.The seller's `id`, `name`, and `handle`.The variant's `id`, `title`, `sku`, and `price_set`.The shipping profile's `id` and `name`.Raw offer prices with `amount`, `currency_code`, `min_quantity`, and `max_quantity`.Inventory items backing the offer, with location stock levels.
Per-offer calculated price; includes `calculated_amount_with_tax` / `calculated_amount_without_tax` (and original-amount variants) when a tax context resolves. Only present when requested.
Available quantity in the sales channel's stock locations; only present when requested.Whether the offer has available inventory; only present when requested.Custom key-value data.Creation timestamp.Last update timestamp.Total number of matching offers.Number of skipped offers.Maximum number of returned offers.
```bash cURL theme={null}
curl 'http://localhost:9000/store/offers?product_id=prod_01JB2K5M8N®ion_id=reg_01JB2K4S1T&fields=%2Bcalculated_price' \
-H 'x-publishable-api-key: pk_01JB2K3XYZ'
```
```ts JS Client theme={null}
const { offers, count } = await client.store.offers.query({
product_id: "prod_01JB2K5M8N",
region_id: "reg_01JB2K4S1T",
fields: "+calculated_price",
})
```
```json 200 theme={null}
{
"offers": [
{
"id": "offer_01JB2K6G5H",
"seller_id": "sel_01JB2K8A1B",
"variant_id": "variant_01JB2K6E4F",
"product_id": "prod_01JB2K5M8N",
"shipping_profile_id": "sp_01JB2K9C2D",
"sku": "NT-LINEN-M",
"ean": null,
"upc": null,
"seller": { "id": "sel_01JB2K8A1B", "name": "Nordic Textiles", "handle": "nordic-textiles" },
"product_variant": { "id": "variant_01JB2K6E4F", "title": "M", "sku": null, "price_set": { "id": "pset_01JB2K9E3F" } },
"shipping_profile": { "id": "sp_01JB2K9C2D", "name": "Default" },
"prices": [
{ "id": "price_01JB2K9G4H", "amount": 4500, "currency_code": "eur", "min_quantity": null, "max_quantity": null }
],
"inventory_item_link": [],
"calculated_price": {
"calculated_amount": 4500,
"original_amount": 4500,
"currency_code": "eur"
},
"metadata": null,
"created_at": "2026-05-01T10:00:00.000Z",
"updated_at": "2026-05-01T10:00:00.000Z"
}
],
"count": 3,
"offset": 0,
"limit": 50
}
```
# Retrieve Offer
Source: https://docs.mercurjs.com/references/api/store/offers/retrieve-offer
GET /store/offers/{id}
Retrieve a seller offer by ID with its per-offer calculated price.
Returns a single offer; offers from hidden sellers or on unpublished products return a `404`.
Customer authentication is optional. `calculated_price` is computed per offer only when requested in `fields` and a pricing context (`region_id`, `country_code`, or `cart_id`) resolves; requesting `inventory_quantity` or `in_stock` requires a single sales channel on the publishable key or via `sales_channel_id`.
## Path parameters
The offer's ID.
## Query parameters
Comma-separated fields and relations to include; add `calculated_price`, `inventory_quantity`, or `in_stock` to receive the computed fields.
Region used to build the pricing context for `calculated_price`.
Country used for the tax portion of the pricing context.
Province used for the tax portion of the pricing context.
Cart whose region and customer resolve the pricing context.
## Response
The offer's ID.ID of the selling seller.ID of the offered product variant.ID of the offered product.ID of the offer's shipping profile.The offer's SKU.The offer's EAN.The offer's UPC.The seller's `id`, `name`, and `handle`.The variant's `id`, `title`, `sku`, and `price_set`.The shipping profile's `id` and `name`.Raw offer prices with `amount`, `currency_code`, `min_quantity`, and `max_quantity`.Inventory items backing the offer, with location stock levels.Per-offer calculated price with tax-adjusted amounts when a tax context resolves; only present when requested.Available quantity in the sales channel's stock locations; only present when requested.Whether the offer has available inventory; only present when requested.Custom key-value data.Creation timestamp.Last update timestamp.
```bash cURL theme={null}
curl 'http://localhost:9000/store/offers/offer_01JB2K6G5H?region_id=reg_01JB2K4S1T&fields=%2Bcalculated_price' \
-H 'x-publishable-api-key: pk_01JB2K3XYZ'
```
```ts JS Client theme={null}
const { offer } = await client.store.offers.$id.query({
$id: "offer_01JB2K6G5H",
region_id: "reg_01JB2K4S1T",
fields: "+calculated_price",
})
```
```json 200 theme={null}
{
"offer": {
"id": "offer_01JB2K6G5H",
"seller_id": "sel_01JB2K8A1B",
"variant_id": "variant_01JB2K6E4F",
"product_id": "prod_01JB2K5M8N",
"shipping_profile_id": "sp_01JB2K9C2D",
"sku": "NT-LINEN-M",
"ean": null,
"upc": null,
"seller": { "id": "sel_01JB2K8A1B", "name": "Nordic Textiles", "handle": "nordic-textiles" },
"product_variant": { "id": "variant_01JB2K6E4F", "title": "M", "sku": null, "price_set": { "id": "pset_01JB2K9E3F" } },
"shipping_profile": { "id": "sp_01JB2K9C2D", "name": "Default" },
"prices": [
{ "id": "price_01JB2K9G4H", "amount": 4500, "currency_code": "eur", "min_quantity": null, "max_quantity": null }
],
"inventory_item_link": [],
"calculated_price": {
"calculated_amount": 4500,
"original_amount": 4500,
"currency_code": "eur"
},
"metadata": null,
"created_at": "2026-05-01T10:00:00.000Z",
"updated_at": "2026-05-01T10:00:00.000Z"
}
}
```
# List Order Groups
Source: https://docs.mercurjs.com/references/api/store/order-groups/list-order-groups
GET /store/order-groups
Retrieve the authenticated customer's order groups.
Returns a paginated list of the logged-in customer's order groups, the multi-seller wrappers created when a cart is completed.
Customer authentication is required (`Authorization: Bearer ` or a session cookie); results are always scoped to the authenticated customer.
## Query parameters
Maximum number of order groups to return.
Number of order groups to skip before collecting results.
Field to sort by, prefixed with `-` for descending order.
Comma-separated fields to include; only fields from the route's allowed list are accepted.
Filter by one or more order group IDs.
Filter by creation date using operators like `$gt`, `$gte`, `$lt`, `$lte`.
Filter by update date using operators like `$gt`, `$gte`, `$lt`, `$lte`.
## Response
The order group's ID.ID of the purchasing customer.Number of per-seller orders in the group.Combined total across all child orders.
Child orders, each with `seller_id` and its `items` (including each item's variant, product, and seller).
Creation timestamp.Last update timestamp.Total number of matching order groups.Number of skipped order groups.Maximum number of returned order groups.
```bash cURL theme={null}
curl 'http://localhost:9000/store/order-groups?limit=10' \
-H 'x-publishable-api-key: pk_01JB2K3XYZ' \
-H 'Authorization: Bearer '
```
```ts JS Client theme={null}
const { order_groups, count } = await client.store.orderGroups.query({ limit: 10 })
```
```json 200 theme={null}
{
"order_groups": [
{
"id": "og_01JB2KE7NP",
"customer_id": "cus_01JB2KE8QR",
"seller_count": 2,
"total": 9900,
"orders": [
{
"id": "order_01JB2KH1WX",
"seller_id": "sel_01JB2K8A1B",
"items": [{ "id": "ordli_01JB2KH2YZ", "title": "M", "quantity": 1 }]
},
{
"id": "order_01JB2KH3AB",
"seller_id": "sel_01JB2KF9ST",
"items": [{ "id": "ordli_01JB2KH4CD", "title": "One Size", "quantity": 1 }]
}
],
"created_at": "2026-05-01T10:05:00.000Z",
"updated_at": "2026-05-01T10:05:00.000Z"
}
],
"count": 4,
"offset": 0,
"limit": 10
}
```
# Retrieve Order Group
Source: https://docs.mercurjs.com/references/api/store/order-groups/retrieve-order-group
GET /store/order-groups/{id}
Retrieve one of the authenticated customer's order groups by ID.
Returns a single order group with its per-seller child orders; order groups belonging to another customer return a `404`.
Customer authentication is required (`Authorization: Bearer ` or a session cookie).
## Path parameters
The order group's ID.
## Query parameters
Comma-separated fields to include; only fields from the route's allowed list are accepted.
## Response
The order group's ID.ID of the purchasing customer.Number of per-seller orders in the group.Combined total across all child orders.
Child orders, each with `seller_id` and its `items` (including each item's variant, product, and seller).
Creation timestamp.Last update timestamp.
```bash cURL theme={null}
curl 'http://localhost:9000/store/order-groups/og_01JB2KE7NP' \
-H 'x-publishable-api-key: pk_01JB2K3XYZ' \
-H 'Authorization: Bearer '
```
```ts JS Client theme={null}
const { order_group } = await client.store.orderGroups.$id.query({ $id: "og_01JB2KE7NP" })
```
```json 200 theme={null}
{
"order_group": {
"id": "og_01JB2KE7NP",
"customer_id": "cus_01JB2KE8QR",
"seller_count": 2,
"total": 9900,
"orders": [
{
"id": "order_01JB2KH1WX",
"seller_id": "sel_01JB2K8A1B",
"items": [{ "id": "ordli_01JB2KH2YZ", "title": "M", "quantity": 1 }]
},
{
"id": "order_01JB2KH3AB",
"seller_id": "sel_01JB2KF9ST",
"items": [{ "id": "ordli_01JB2KH4CD", "title": "One Size", "quantity": 1 }]
}
],
"created_at": "2026-05-01T10:05:00.000Z",
"updated_at": "2026-05-01T10:05:00.000Z"
}
}
```
# List Product Attributes
Source: https://docs.mercurjs.com/references/api/store/product-attributes/list-product-attributes
GET /store/product-attributes
Retrieve active global product attributes.
Returns a paginated list of active, global product attributes (attributes scoped to a single product are excluded).
## Query parameters
Maximum number of attributes to return.
Number of attributes to skip before collecting results.
Field to sort by, prefixed with `-` for descending order.
Comma-separated fields and relations to include; prefix with `+`/`-` to add to or remove from the defaults.
Free-text search term applied to attribute fields.
Filter by one or more attribute IDs.
Filter by one or more attribute handles.
Filter by attribute type. Values: `single_select`, `multi_select`, `unit`, `toggle`, `text`.
Filter attributes that define variant axes.
Filter attributes usable as storefront filters.
Filter by creation date using operators like `$gt`, `$gte`, `$lt`, `$lte`.
Filter by update date using operators like `$gt`, `$gte`, `$lt`, `$lte`.
Join multiple filter objects with a logical AND.
Join multiple filter objects with a logical OR.
## Response
The attribute's ID.The attribute's display name.URL-safe handle.The attribute's description.One of `single_select`, `multi_select`, `unit`, `toggle`, `text`.Whether sellers must set a value.Whether the attribute can drive storefront filters.Whether the attribute defines a variant axis.Always `null` on the store API (global attributes only).Sort rank.The attribute's predefined values.Custom key-value data.Creation timestamp.Last update timestamp.Total number of matching attributes.Number of skipped attributes.Maximum number of returned attributes.
```bash cURL theme={null}
curl 'http://localhost:9000/store/product-attributes?is_filterable=true' \
-H 'x-publishable-api-key: pk_01JB2K3XYZ'
```
```ts JS Client theme={null}
const { product_attributes, count } = await client.store.productAttributes.query({
is_filterable: true,
})
```
```json 200 theme={null}
{
"product_attributes": [
{
"id": "attr_01JB2K7A1B",
"name": "Material",
"handle": "material",
"description": null,
"type": "single_select",
"is_required": false,
"is_filterable": true,
"is_variant_axis": false,
"product_id": null,
"rank": 0,
"values": [
{ "id": "attrval_01JB2K7C2D", "name": "Linen", "rank": 0 },
{ "id": "attrval_01JB2K7E3F", "name": "Cotton", "rank": 1 }
],
"metadata": null,
"created_at": "2026-05-01T10:00:00.000Z",
"updated_at": "2026-05-01T10:00:00.000Z"
}
],
"count": 5,
"offset": 0,
"limit": 50
}
```
# Retrieve Product Attribute
Source: https://docs.mercurjs.com/references/api/store/product-attributes/retrieve-product-attribute
GET /store/product-attributes/{id}
Retrieve an active global product attribute by ID.
Returns a single active, global product attribute; inactive or product-scoped attributes return a `404`.
## Path parameters
The attribute's ID.
## Query parameters
Comma-separated fields and relations to include; prefix with `+`/`-` to add to or remove from the defaults.
## Response
The attribute's ID.The attribute's display name.URL-safe handle.The attribute's description.One of `single_select`, `multi_select`, `unit`, `toggle`, `text`.Whether sellers must set a value.Whether the attribute can drive storefront filters.Whether the attribute defines a variant axis.Always `null` on the store API (global attributes only).Sort rank.The attribute's predefined values.Custom key-value data.Creation timestamp.Last update timestamp.
```bash cURL theme={null}
curl 'http://localhost:9000/store/product-attributes/attr_01JB2K7A1B' \
-H 'x-publishable-api-key: pk_01JB2K3XYZ'
```
```ts JS Client theme={null}
const { product_attribute } = await client.store.productAttributes.$id.query({
$id: "attr_01JB2K7A1B",
})
```
```json 200 theme={null}
{
"product_attribute": {
"id": "attr_01JB2K7A1B",
"name": "Material",
"handle": "material",
"description": null,
"type": "single_select",
"is_required": false,
"is_filterable": true,
"is_variant_axis": false,
"product_id": null,
"rank": 0,
"values": [
{ "id": "attrval_01JB2K7C2D", "name": "Linen", "rank": 0 }
],
"metadata": null,
"created_at": "2026-05-01T10:00:00.000Z",
"updated_at": "2026-05-01T10:00:00.000Z"
}
}
```
# List Product Categories
Source: https://docs.mercurjs.com/references/api/store/product-categories/list-product-categories
GET /store/product-categories
Retrieve active, public product categories.
Returns a paginated list of product categories; inactive and internal categories are always excluded.
## Query parameters
Maximum number of categories to return.
Number of categories to skip before collecting results.
Field to sort by, prefixed with `-` for descending order.
Comma-separated fields and relations to include; prefix with `+`/`-` to add to or remove from the defaults.
Free-text search term applied to category fields.
Filter by one or more category IDs.
Filter by one or more category handles.
Filter by parent category ID; pass `null` to fetch top-level categories.
Include each category's ancestors in `parent_category`.
Include each category's descendants in `category_children`.
Filter by creation date using operators like `$gt`, `$gte`, `$lt`, `$lte`.
Filter by update date using operators like `$gt`, `$gte`, `$lt`, `$lte`.
Join multiple filter objects with a logical AND.
Join multiple filter objects with a logical OR.
## Response
The category's ID.The category's name.The category's description.URL-safe handle.Sort rank among siblings.ID of the parent category.The parent category.Direct child categories.Custom key-value data.Creation timestamp.Last update timestamp.Total number of matching categories.Number of skipped categories.Maximum number of returned categories.
```bash cURL theme={null}
curl 'http://localhost:9000/store/product-categories?parent_category_id=null&limit=50' \
-H 'x-publishable-api-key: pk_01JB2K3XYZ'
```
```ts JS Client theme={null}
const { product_categories, count } = await client.store.productCategories.query({
parent_category_id: "null",
limit: 50,
})
```
```json 200 theme={null}
{
"product_categories": [
{
"id": "pcat_01JB2K6A2B",
"name": "Shirts",
"description": "",
"handle": "shirts",
"rank": 0,
"parent_category_id": null,
"parent_category": null,
"category_children": [{ "id": "pcat_01JB2K6H7J", "name": "Linen Shirts" }],
"metadata": null,
"created_at": "2026-05-01T10:00:00.000Z",
"updated_at": "2026-05-01T10:00:00.000Z"
}
],
"count": 8,
"offset": 0,
"limit": 50
}
```
# Retrieve Product Category
Source: https://docs.mercurjs.com/references/api/store/product-categories/retrieve-product-category
GET /store/product-categories/{id}
Retrieve an active, public product category by ID.
Returns a single category; inactive or internal categories return a `404`.
## Path parameters
The category's ID.
## Query parameters
Comma-separated fields and relations to include; prefix with `+`/`-` to add to or remove from the defaults.
Include the category's ancestors in `parent_category`.
Include the category's descendants in `category_children`.
## Response
The category's ID.The category's name.The category's description.URL-safe handle.Sort rank among siblings.ID of the parent category.The parent category.Direct child categories.Custom key-value data.Creation timestamp.Last update timestamp.
```bash cURL theme={null}
curl 'http://localhost:9000/store/product-categories/pcat_01JB2K6A2B?include_descendants_tree=true' \
-H 'x-publishable-api-key: pk_01JB2K3XYZ'
```
```ts JS Client theme={null}
const { product_category } = await client.store.productCategories.$id.query({
$id: "pcat_01JB2K6A2B",
include_descendants_tree: true,
})
```
```json 200 theme={null}
{
"product_category": {
"id": "pcat_01JB2K6A2B",
"name": "Shirts",
"description": "",
"handle": "shirts",
"rank": 0,
"parent_category_id": null,
"parent_category": null,
"category_children": [{ "id": "pcat_01JB2K6H7J", "name": "Linen Shirts" }],
"metadata": null,
"created_at": "2026-05-01T10:00:00.000Z",
"updated_at": "2026-05-01T10:00:00.000Z"
}
}
```
# List Products
Source: https://docs.mercurjs.com/references/api/store/products/list-products
GET /store/products
Retrieve published products from visible sellers.
Returns a paginated list of published products belonging to open, visible sellers.
Customer authentication is optional. When a customer token is provided, the customer's group memberships are included in the pricing context, which can change `variants.calculated_price` results. Variant prices are only computed when you request `variants.calculated_price` in `fields` (or pass `region_id`); the calculated price reflects the cheapest offer for each variant.
## Query parameters
Maximum number of products to return.
Number of products to skip before collecting results.
Field to sort by, prefixed with `-` for descending order (for example `-created_at`).
Comma-separated fields and relations to include; prefix with `+`/`-` to add to or remove from the defaults.
Free-text search term applied to product fields.
Filter by one or more product IDs.
Filter by exact product title.
Filter by product handle.
Filter by one or more collection IDs.
Filter by one or more product type IDs.
Filter by one or more category IDs; only active, non-internal categories match.
Filter by one or more tag IDs.
Filter by gift-card products.
Region used to build the pricing context for `variants.calculated_price`.
Currency used in the pricing context.
Filter by creation date using operators like `$gt`, `$gte`, `$lt`, `$lte`.
Filter by update date using operators like `$gt`, `$gte`, `$lt`, `$lte`.
Join multiple filter objects with a logical AND.
Join multiple filter objects with a logical OR.
## Response
The product's ID.The product's title.The product's subtitle.Always `published` on the store API.The product's description.URL-safe handle.Whether the product is a gift card.Whether promotions can apply to the product.Thumbnail URL.The product's collection.The product's type.The product's tags.The product's images.The product's categories.Product options with their values.
Product variants with their options; `calculated_price` and `offer_id` are added when requested and reflect the cheapest offer.
Attribute values assigned to the product, each with its parent attribute.Attributes scoped to this product, with their values.Custom key-value data.Creation timestamp.Last update timestamp.Total number of matching products.Number of skipped products.Maximum number of returned products.
```bash cURL theme={null}
curl 'http://localhost:9000/store/products?limit=20®ion_id=reg_01JB2K4S1T&fields=*variants.calculated_price' \
-H 'x-publishable-api-key: pk_01JB2K3XYZ'
```
```ts JS Client theme={null}
const { products, count } = await client.store.products.query({
limit: 20,
region_id: "reg_01JB2K4S1T",
fields: "*variants.calculated_price",
})
```
```json 200 theme={null}
{
"products": [
{
"id": "prod_01JB2K5M8N",
"title": "Linen Shirt",
"subtitle": null,
"status": "published",
"description": "A breathable linen shirt.",
"handle": "linen-shirt",
"is_giftcard": false,
"discountable": true,
"thumbnail": "https://cdn.example.com/linen-shirt.png",
"collection": null,
"type": null,
"tags": [],
"images": [],
"categories": [{ "id": "pcat_01JB2K6A2B", "name": "Shirts" }],
"options": [{ "id": "opt_01JB2K6C3D", "title": "Size", "values": [{ "value": "M" }] }],
"variants": [
{
"id": "variant_01JB2K6E4F",
"title": "M",
"offer_id": "offer_01JB2K6G5H",
"calculated_price": {
"calculated_amount": 4500,
"original_amount": 5000,
"currency_code": "eur"
}
}
],
"product_attribute_values": [],
"scoped_attributes": [],
"metadata": null,
"created_at": "2026-05-01T10:00:00.000Z",
"updated_at": "2026-05-01T10:00:00.000Z"
}
],
"count": 42,
"offset": 0,
"limit": 20
}
```
# Retrieve Product
Source: https://docs.mercurjs.com/references/api/store/products/retrieve-product
GET /store/products/{id}
Retrieve a published product by ID.
Returns a single published product; a `404` is returned if the product is unpublished or its seller is not visible.
Customer authentication is optional. When a customer token is provided, the customer's group memberships enter the pricing context used for `variants.calculated_price`. Prices are computed only when `variants.calculated_price` is requested in `fields` (or `region_id` is passed) and reflect the cheapest offer per variant.
## Path parameters
The product's ID.
## Query parameters
Comma-separated fields and relations to include; prefix with `+`/`-` to add to or remove from the defaults.
Region used to build the pricing context for `variants.calculated_price`.
Currency used in the pricing context.
## Response
The product's ID.The product's title.URL-safe handle.Always `published` on the store API.The product's description.Thumbnail URL.The product's collection.The product's type.The product's tags.The product's images.The product's categories.Product options with their values.
Product variants; `calculated_price` and `offer_id` are added when requested and reflect the cheapest offer.
Attribute values assigned to the product.Attributes scoped to this product, with their values.Custom key-value data.
```bash cURL theme={null}
curl 'http://localhost:9000/store/products/prod_01JB2K5M8N?region_id=reg_01JB2K4S1T&fields=*variants.calculated_price' \
-H 'x-publishable-api-key: pk_01JB2K3XYZ'
```
```ts JS Client theme={null}
const { product } = await client.store.products.$id.query({
$id: "prod_01JB2K5M8N",
region_id: "reg_01JB2K4S1T",
fields: "*variants.calculated_price",
})
```
```json 200 theme={null}
{
"product": {
"id": "prod_01JB2K5M8N",
"title": "Linen Shirt",
"handle": "linen-shirt",
"status": "published",
"description": "A breathable linen shirt.",
"thumbnail": "https://cdn.example.com/linen-shirt.png",
"collection": null,
"type": null,
"tags": [],
"images": [],
"categories": [{ "id": "pcat_01JB2K6A2B", "name": "Shirts" }],
"options": [{ "id": "opt_01JB2K6C3D", "title": "Size", "values": [{ "value": "M" }] }],
"variants": [
{
"id": "variant_01JB2K6E4F",
"title": "M",
"offer_id": "offer_01JB2K6G5H",
"calculated_price": {
"calculated_amount": 4500,
"original_amount": 5000,
"currency_code": "eur"
}
}
],
"product_attribute_values": [],
"scoped_attributes": [],
"metadata": null
}
}
```
# List Sellers
Source: https://docs.mercurjs.com/references/api/store/sellers/list-sellers
GET /store/sellers
Retrieve open sellers visible on the storefront.
Returns a paginated list of sellers; only sellers with status `open` that are outside any scheduled closure window (`closed_from` / `closed_to`) are included.
## Query parameters
Maximum number of sellers to return.
Number of sellers to skip before collecting results.
Field to sort by, prefixed with `-` for descending order.
Comma-separated fields and relations to include; prefix with `+`/`-` to add to or remove from the defaults.
Free-text search term applied to seller fields.
Filter by one or more seller IDs.
Filter by one or more seller names.
Filter by seller handle.
Filter premium sellers.
Filter by creation date using operators like `$gt`, `$gte`, `$lt`, `$lte`.
Filter by update date using operators like `$gt`, `$gte`, `$lt`, `$lte`.
## Response
The seller's ID.The seller's display name.URL-safe handle.The seller's storefront description.Logo URL.Banner image URL.Whether the seller has premium status.Custom key-value data.Total number of matching sellers.Number of skipped sellers.Maximum number of returned sellers.
```bash cURL theme={null}
curl 'http://localhost:9000/store/sellers?limit=20' \
-H 'x-publishable-api-key: pk_01JB2K3XYZ'
```
```ts JS Client theme={null}
const { sellers, count } = await client.store.sellers.query({ limit: 20 })
```
```json 200 theme={null}
{
"sellers": [
{
"id": "sel_01JB2K8A1B",
"name": "Nordic Textiles",
"handle": "nordic-textiles",
"description": "Scandinavian linen and wool.",
"logo": "https://cdn.example.com/nordic-logo.png",
"banner": null,
"is_premium": false,
"metadata": null
}
],
"count": 12,
"offset": 0,
"limit": 20
}
```
# Retrieve Seller
Source: https://docs.mercurjs.com/references/api/store/sellers/retrieve-seller
GET /store/sellers/{id}
Retrieve an open seller by ID.
Returns a single seller; sellers that are not `open` or are inside a scheduled closure window return a `404`.
## Path parameters
The seller's ID.
## Query parameters
Comma-separated fields and relations to include; prefix with `+`/`-` to add to or remove from the defaults.
## Response
The seller's ID.The seller's display name.URL-safe handle.The seller's storefront description.Logo URL.Banner image URL.Whether the seller has premium status.Custom key-value data.
```bash cURL theme={null}
curl 'http://localhost:9000/store/sellers/sel_01JB2K8A1B' \
-H 'x-publishable-api-key: pk_01JB2K3XYZ'
```
```ts JS Client theme={null}
const { seller } = await client.store.sellers.$id.query({ $id: "sel_01JB2K8A1B" })
```
```json 200 theme={null}
{
"seller": {
"id": "sel_01JB2K8A1B",
"name": "Nordic Textiles",
"handle": "nordic-textiles",
"description": "Scandinavian linen and wool.",
"logo": "https://cdn.example.com/nordic-logo.png",
"banner": null,
"is_premium": false,
"metadata": null
}
}
```
# List Shipping Options
Source: https://docs.mercurjs.com/references/api/store/shipping-options/list-shipping-options
GET /store/shipping-options
Retrieve available shipping options for a cart, grouped by seller.
Returns the shipping options that can fulfill the given cart, grouped by the seller of the items each option applies to.
## Query parameters
ID of the cart to list shipping options for.
Set to `true` to list return shipping options.
Comma-separated fields and relations to include on each shipping option; prefix with `+`/`-` to add to or remove from the defaults.
## Response
Map of seller ID to the shipping options available for that seller's items.
Shipping options for the seller, each with `id`, `name`, `price_type`, `provider_id`, calculated `amount`, and `type`.
```bash cURL theme={null}
curl 'http://localhost:9000/store/shipping-options?cart_id=cart_01JB2KB1CD' \
-H 'x-publishable-api-key: pk_01JB2K3XYZ'
```
```ts JS Client theme={null}
const { shipping_options } = await client.store.shippingOptions.query({
cart_id: "cart_01JB2KB1CD",
})
```
```json 200 theme={null}
{
"shipping_options": {
"sel_01JB2K8A1B": [
{
"id": "so_01JB2KC4GH",
"name": "Standard Shipping",
"price_type": "flat",
"provider_id": "manual_manual",
"amount": 900,
"type": { "label": "Standard", "code": "standard" }
}
],
"sel_01JB2KF9ST": [
{
"id": "so_01JB2KG0UV",
"name": "Express",
"price_type": "flat",
"provider_id": "manual_manual",
"amount": 1500,
"type": { "label": "Express", "code": "express" }
}
]
}
}
```
# Vendor API
Source: https://docs.mercurjs.com/references/api/vendor
Seller-scoped routes under /vendor/* where every request operates on the authenticated member's selected seller.
The Vendor API is the seller's surface.
Every route is scoped to one seller through the member authentication and `x-seller-id` mechanism described in [API conventions](/rc/references/api/conventions#vendor). A seller can never read or mutate another seller's data.
## Account and team
| Method | Path | Purpose |
| --------------- | ------------------------------------------ | ---------------------------------------------------------- |
| `GET` `POST` | `/vendor/sellers` | Registration lookup / create a seller account (**public**) |
| `POST` | `/vendor/sellers/select` | Select the active seller for the session |
| `GET` `POST` | `/vendor/sellers/me` | Retrieve / update the current seller |
| `POST` | `/vendor/sellers/:id/address` | Upsert the seller address |
| `POST` | `/vendor/sellers/:id/payment-details` | Upsert payment details |
| `POST` `DELETE` | `/vendor/sellers/:id/professional-details` | Upsert / remove professional details |
| `GET` `POST` | `/vendor/sellers/:id/members` | List / manage members |
| `POST` `DELETE` | `/vendor/sellers/:id/members/:member_id` | Update role / remove a member |
| `GET` | `/vendor/sellers/:id/members/me` | The current member within this seller |
| `GET` | `/vendor/sellers/:id/members/invites` | List pending invites |
| `GET` `POST` | `/vendor/members/me` | Retrieve / update the current member profile |
| `POST` | `/vendor/members/invites/accept` | Accept an invite (**public**) |
## Products and offers
Products are shared master records. Updates stage a [change request](/platform/product-edit/overview) rather than writing directly, and the list shows products the seller created plus published, unrestricted products.
| Method | Path | Purpose |
| --------------------- | ------------------------------------------- | ----------------------------------------------- |
| `GET` `POST` | `/vendor/products` | List / create a product (created as `proposed`) |
| `GET` `POST` `DELETE` | `/vendor/products/:id` | Retrieve / request an update / request deletion |
| `GET` | `/vendor/products/:id/preview` | Preview with pending changes applied |
| `POST` | `/vendor/products/:id/cancel` | Withdraw the pending change request |
| `POST` | `/vendor/products/:id/attributes/batch` | Batch add / update / remove attributes |
| `GET` `POST` | `/vendor/products/:id/variants` | List / create variants |
| `GET` `POST` `DELETE` | `/vendor/products/:id/variants/:variant_id` | Retrieve / update / delete a variant |
| `GET` | `/vendor/product-variants` | List variants across products |
| `GET` `POST` | `/vendor/offers` | List / create offers |
| `GET` `POST` `DELETE` | `/vendor/offers/:id` | Retrieve / update / delete an offer |
| `POST` | `/vendor/offers/batch` | Batch create offers |
| `POST` | `/vendor/offers/:id/inventory-items/batch` | Link inventory items to an offer |
### Catalog taxonomy (read-only)
`GET /vendor/product-categories[/:id]`, `/vendor/product-tags[/:id]`, `/vendor/product-types[/:id]`, `/vendor/product-attributes[/:id]`, and `/vendor/collections[/:id]`. Use `POST /vendor/product-categories/:id/products` and `POST /vendor/collections/:id/products` to place own products.
## Orders and fulfillment
| Method | Path | Purpose |
| ------ | ------------------------------------------------------------------- | ------------------------------- |
| `GET` | `/vendor/orders` | List the seller's orders |
| `GET` | `/vendor/orders/:id` | Retrieve an order |
| `GET` | `/vendor/orders/:id/preview` | Preview with pending edits |
| `GET` | `/vendor/orders/:id/changes` | List order changes |
| `GET` | `/vendor/orders/:id/commission-lines` | Commission charged on the order |
| `POST` | `/vendor/orders/:id/complete` | Complete the order |
| `POST` | `/vendor/orders/:id/cancel` | Cancel the order |
| `POST` | `/vendor/orders/:id/fulfillments` | Create a fulfillment |
| `POST` | `/vendor/orders/:id/fulfillments/:fulfillment_id/cancel` | Cancel a fulfillment |
| `POST` | `/vendor/orders/:id/fulfillments/:fulfillment_id/mark-as-delivered` | Mark delivered |
| `POST` | `/vendor/orders/:id/fulfillments/:fulfillment_id/shipments` | Create a shipment |
### Order edits, returns, claims, exchanges
Full RMA suites exist per domain, following the same action pattern Medusa uses (begin → stage item/shipping actions → request → confirm):
* `POST /vendor/order-edits` + `/:id/request|confirm|items|shipping-method` sub-routes
* `GET/POST /vendor/returns` + `/:id/request-items|dismiss-items|receive-items|receive|shipping-method` sub-routes
* `GET/POST /vendor/claims` + `/:id/claim-items|inbound|outbound` sub-routes
* `GET/POST /vendor/exchanges` + `/:id/inbound|outbound` sub-routes
* `GET /vendor/return-reasons[/:id]`, `GET /vendor/refund-reasons[/:id]`
### Payments
| Method | Path | Purpose |
| ------ | ------------------------------------ | ------------------------ |
| `GET` | `/vendor/payments[/:id]` | List / retrieve payments |
| `POST` | `/vendor/payments/:id/capture` | Capture a payment |
| `POST` | `/vendor/payments/:id/refund` | Refund a payment |
| `GET` | `/vendor/payments/payment-providers` | List payment providers |
## Payouts
| Method | Path | Purpose |
| ------------ | ---------------------------------------- | ------------------------------------ |
| `GET` | `/vendor/payouts[/:id]` | Payout history |
| `GET` `POST` | `/vendor/payout-accounts` | Retrieve / create the payout account |
| `POST` | `/vendor/payout-accounts/:id/onboarding` | Start or refresh provider onboarding |
## Inventory and stock locations
| Method | Path | Purpose |
| --------------------- | ---------------------------------------------------------- | ------------------------------------- |
| `GET` `POST` | `/vendor/inventory-items` | List / create inventory items |
| `GET` `POST` `DELETE` | `/vendor/inventory-items/:id` | Retrieve / update / delete an item |
| `GET` `POST` | `/vendor/inventory-items/:id/location-levels` | List / create location levels |
| `POST` `DELETE` | `/vendor/inventory-items/:id/location-levels/:location_id` | Update / delete a level |
| `POST` | `/vendor/inventory-items/location-levels/batch` | Batch update levels |
| `GET` `POST` | `/vendor/reservations[/:id]` | Manage reservations |
| `GET` `POST` | `/vendor/stock-locations` | List / create stock locations |
| `GET` `POST` `DELETE` | `/vendor/stock-locations/:id` | Retrieve / update / delete a location |
| `POST` | `/vendor/stock-locations/:id/fulfillment-sets` | Create a fulfillment set |
| `POST` | `/vendor/stock-locations/:id/fulfillment-providers` | Link fulfillment providers |
| `POST` | `/vendor/stock-locations/:id/sales-channels` | Link sales channels |
## Shipping
| Method | Path | Purpose |
| --------------------- | ----------------------------------------------------- | ------------------------------------ |
| `GET` `POST` | `/vendor/shipping-options` | List / create shipping options |
| `GET` `POST` `DELETE` | `/vendor/shipping-options/:id` | Retrieve / update / delete an option |
| `POST` | `/vendor/shipping-options/:id/rules/batch` | Batch manage option rules |
| `GET` | `/vendor/shipping-option-types[/:id]` | List option types |
| `GET` `POST` | `/vendor/shipping-profiles` | List / create profiles |
| `GET` `POST` `DELETE` | `/vendor/shipping-profiles/:id` | Retrieve / update / delete a profile |
| `DELETE` | `/vendor/fulfillment-sets/:id` | Delete a fulfillment set |
| `POST` | `/vendor/fulfillment-sets/:id/service-zones` | Create a service zone |
| `GET` `POST` `DELETE` | `/vendor/fulfillment-sets/:id/service-zones/:zone_id` | Manage a service zone |
| `GET` | `/vendor/fulfillment-providers` | List fulfillment providers |
## Pricing, promotions, customers
| Method | Path | Purpose |
| ------------ | --------------------------------------- | -------------------------------------------------------------------------- |
| `GET` `POST` | `/vendor/price-lists[/:id]` | Manage price lists (+ `/:id/prices`, `/:id/prices/batch`, `/:id/products`) |
| `GET` | `/vendor/price-preferences[/:id]` | Read price preferences |
| `GET` `POST` | `/vendor/promotions[/:id]` | Manage promotions (+ rule batch sub-routes and rule option lookups) |
| `GET` `POST` | `/vendor/campaigns[/:id]` | Manage campaigns (+ `/:id/promotions`) |
| `GET` | `/vendor/customers[/:id]` | The seller's customers |
| `POST` | `/vendor/customers/:id/customer-groups` | Manage a customer's groups |
| `GET` `POST` | `/vendor/customer-groups[/:id]` | Manage customer groups (+ `/:id/customers`) |
## Configuration and misc
| Method | Path | Purpose |
| ------------ | ------------------------------ | ----------------------------------------- |
| `GET` `POST` | `/vendor/sales-channels[/:id]` | Manage sales channels (+ `/:id/products`) |
| `GET` | `/vendor/regions[/:id]` | Read regions |
| `GET` | `/vendor/currencies[/:code]` | Read currencies |
| `GET` | `/vendor/stores` | List stores (**public**) |
| `GET` | `/vendor/feature-flags` | Feature flags (**public**) |
| `POST` | `/vendor/uploads` | Upload files |
## Next steps
# Accept Member Invite
Source: https://docs.mercurjs.com/references/api/vendor/members/accept-member-invite
POST /vendor/members/invites/accept
Accept a seller team invite using its token.
Accepts a member invite and joins the inviting seller's team.
This is a public route: no `Authorization` or `x-seller-id` header is required. The invite is identified by the `invite_token` sent in the invitation email.
## Body parameters
The token from the invitation email.
First name for the new member profile.
Last name for the new member profile.
## Response
The member's ID.The member's first name.The member's last name.The member's email.
```bash cURL theme={null}
curl -X POST 'http://localhost:9000/vendor/members/invites/accept' \
-H 'Content-Type: application/json' \
-d '{"invite_token": "", "first_name": "John", "last_name": "Roe"}'
```
```ts JS Client theme={null}
const { member } = await client.vendor.members.invites.accept.mutate({
invite_token: "",
first_name: "John",
last_name: "Roe",
})
```
```json 200 theme={null}
{
"member": {
"id": "mem_01HXYZ",
"first_name": "John",
"last_name": "Roe",
"email": "teammate@acme.com"
}
}
```
# Retrieve Current Member
Source: https://docs.mercurjs.com/references/api/vendor/members/retrieve-current-member
GET /vendor/members/me
Retrieve the authenticated member's profile for the active seller.
Returns the seller member record for the authenticated member, including the member profile, role, and the seller with its address, payment details, and professional details.
## Query parameters
Comma-separated fields to include in the response.
## Response
The seller member's ID.Whether the member owns the seller account.The member's profile (first name, last name, email, locale).The member's role for this seller.The active seller, with `address`, `payment_details`, and `professional_details`.
```bash cURL theme={null}
curl 'http://localhost:9000/vendor/members/me' \
-H 'Authorization: Bearer ' \
-H 'x-seller-id: '
```
```ts JS Client theme={null}
const { seller_member } = await client.vendor.members.me.query()
```
```json 200 theme={null}
{
"seller_member": {
"id": "selmem_01HXYZ",
"is_owner": true,
"member": { "id": "mem_01HXYZ", "first_name": "Jane", "last_name": "Doe", "email": "owner@acme.com" },
"rbac_role": { "id": "role_seller_administration", "name": "Administration" },
"seller": { "id": "sel_01HXYZ", "name": "Acme Store", "status": "open" }
}
}
```
# Update Current Member
Source: https://docs.mercurjs.com/references/api/vendor/members/update-current-member
POST /vendor/members/me
Update the authenticated member's profile.
Updates the member's personal details and returns the seller member record for the active seller.
## Body parameters
The member's first name.
The member's last name.
The member's preferred locale (e.g. `en-US`).
## Query parameters
Comma-separated fields to include in the response.
## Response
The seller member's ID.Whether the member owns the seller account.The updated member profile.The member's role for this seller.The active seller.
```bash cURL theme={null}
curl -X POST 'http://localhost:9000/vendor/members/me' \
-H 'Authorization: Bearer ' \
-H 'x-seller-id: ' \
-H 'Content-Type: application/json' \
-d '{"first_name": "Jane", "last_name": "Smith"}'
```
```ts JS Client theme={null}
const { seller_member } = await client.vendor.members.me.mutate({
first_name: "Jane",
last_name: "Smith",
})
```
```json 200 theme={null}
{
"seller_member": {
"id": "selmem_01HXYZ",
"is_owner": true,
"member": { "id": "mem_01HXYZ", "first_name": "Jane", "last_name": "Smith" },
"rbac_role": { "id": "role_seller_administration", "name": "Administration" },
"seller": { "id": "sel_01HXYZ", "name": "Acme Store" }
}
}
```
# Batch Create Offers
Source: https://docs.mercurjs.com/references/api/vendor/offers/batch-create-offers
POST /vendor/offers/batch
Create up to 100 offers in a single request.
Creates multiple offers at once; the whole batch is validated and processed together.
## Query parameters
Comma-separated fields to include in the returned offers. Prefix with `+`/`-` to add to or remove from the defaults.
## Body parameters
Between 1 and 100 offers to create.
The seller's SKU for the listing.ID of the product variant being listed.ID of the shipping profile used to fulfill the offer.
At least one price.
Price amount.Currency code, e.g. `usd`.Minimum quantity for the price to apply; positive integer.Maximum quantity for the price to apply; positive integer.Price rules as attribute-to-value pairs.
At least one inventory item.
Title for the created inventory item.SKU for the created inventory item.Units consumed per sale; positive integer.Stock location ID.Stocked quantity at the location; non-negative integer.EAN barcode.UPC barcode.Whether stock is tracked for the offer. Set to `false` to sell without inventory checks.Whether the offer can be purchased when it is out of stock.Custom key-value pairs.
## Response
The offer's ID.ID of the listed product variant.The seller's SKU for the listing.Whether stock is tracked for the offer.Whether the offer can be purchased when it is out of stock.The offer's prices.Linked inventory items.
```bash cURL theme={null}
curl -X POST 'http://localhost:9000/vendor/offers/batch' \
-H 'Authorization: Bearer ' \
-H 'x-seller-id: ' \
-H 'Content-Type: application/json' \
-d '{
"offers": [
{
"sku": "ACME-SHIRT-S",
"variant_id": "variant_01HABC",
"shipping_profile_id": "sp_01HXYZ",
"prices": [{ "amount": 2500, "currency_code": "usd" }],
"inventory_items": [{ "required_quantity": 1 }]
},
{
"sku": "ACME-SHIRT-M",
"variant_id": "variant_01HXYZ",
"shipping_profile_id": "sp_01HXYZ",
"prices": [{ "amount": 2500, "currency_code": "usd" }],
"inventory_items": [{ "required_quantity": 1 }]
}
]
}'
```
```ts JS Client theme={null}
const { offers } = await client.vendor.offers.batch.mutate({
offers: [
{
sku: "ACME-SHIRT-M",
variant_id: "variant_01HXYZ",
shipping_profile_id: "sp_01HXYZ",
prices: [{ amount: 2500, currency_code: "usd" }],
inventory_items: [{ required_quantity: 1 }],
},
],
})
```
```json 201 theme={null}
{
"offers": [
{
"id": "offer_01HABC",
"variant_id": "variant_01HABC",
"sku": "ACME-SHIRT-S"
},
{
"id": "offer_01HXYZ",
"variant_id": "variant_01HXYZ",
"sku": "ACME-SHIRT-M"
}
]
}
```
# Batch Offer Inventory Items
Source: https://docs.mercurjs.com/references/api/vendor/offers/batch-offer-inventory-items
POST /vendor/offers/{id}/inventory-items/batch
Link, update, or unlink inventory items on an offer.
Manages the inventory items backing an offer in one request and returns the refreshed offer.
## Path parameters
The offer's ID.
## Body parameters
Inventory items to link to the offer.
ID of an existing inventory item.Units consumed per sale; positive integer.
Existing links to update.
ID of the linked inventory item.New units consumed per sale; positive integer.
Inventory item IDs to unlink from the offer.
## Response
Links created by this request.
Links updated by this request.
Inventory item IDs unlinked by this request.
The offer's ID.The seller's SKU for the listing.Whether stock is tracked for the offer.Whether the offer can be purchased when it is out of stock.The offer's inventory items after the batch, with `id`, `inventory_item_id`, `required_quantity`, and `sku`.
```bash cURL theme={null}
curl -X POST 'http://localhost:9000/vendor/offers/offer_01HXYZ/inventory-items/batch' \
-H 'Authorization: Bearer ' \
-H 'x-seller-id: ' \
-H 'Content-Type: application/json' \
-d '{
"create": [{ "inventory_item_id": "iitem_01HABC", "required_quantity": 2 }],
"update": [{ "inventory_item_id": "iitem_01HXYZ", "required_quantity": 1 }],
"delete": ["iitem_01HDEF"]
}'
```
```ts JS Client theme={null}
const { offer, created, updated, deleted } =
await client.vendor.offers.$id.inventoryItems.batch.mutate({
$id: "offer_01HXYZ",
create: [{ inventory_item_id: "iitem_01HABC", required_quantity: 2 }],
update: [{ inventory_item_id: "iitem_01HXYZ", required_quantity: 1 }],
delete: ["iitem_01HDEF"],
})
```
```json 200 theme={null}
{
"created": [
{ "inventory_item_id": "iitem_01HABC", "required_quantity": 2 }
],
"updated": [
{ "inventory_item_id": "iitem_01HXYZ", "required_quantity": 1 }
],
"deleted": ["iitem_01HDEF"],
"offer": {
"id": "offer_01HXYZ",
"sku": "ACME-SHIRT-M",
"inventory_items": [
{ "inventory_item_id": "iitem_01HXYZ", "required_quantity": 1 },
{ "inventory_item_id": "iitem_01HABC", "required_quantity": 2 }
]
}
}
```
# Create Offer
Source: https://docs.mercurjs.com/references/api/vendor/offers/create-offer
POST /vendor/offers
Create a listing for a product variant.
Creates the seller's offer for a variant: the seller's SKU, shipping profile, prices, and backing inventory.
A product can be listed by many sellers; the offer is what carries the seller's own price and stock for a variant.
## Query parameters
Comma-separated fields to include in the returned offer. Prefix with `+`/`-` to add to or remove from the defaults.
## Body parameters
The seller's SKU for this listing.
ID of the product variant being listed.
ID of the seller's shipping profile used to fulfill the offer.
Inventory items backing the offer; at least one is required.
Title for the created inventory item.SKU for the created inventory item.Units of the item consumed per sale; positive integer.Stock location ID.Stocked quantity at the location; non-negative integer.
Offer prices; at least one is required.
Price amount.Currency code, e.g. `usd`.Minimum quantity for the price to apply; positive integer.Maximum quantity for the price to apply; positive integer.Price rules as attribute-to-value pairs, e.g. `{"region_id": "reg_01..."}`.
EAN barcode.
UPC barcode.
Whether stock is tracked for the offer. Set to `false` to sell without inventory checks.
Whether the offer can be purchased when it is out of stock.
Custom key-value pairs.
## Response
The offer's ID.ID of the owning seller.ID of the listed product variant.ID of the parent product.ID of the shipping profile.The seller's SKU for this listing.Whether stock is tracked for the offer.Whether the offer can be purchased when it is out of stock.Offer prices with `id`, `amount`, `currency_code`, `min_quantity`, `max_quantity`, and `price_rules`.Linked inventory items with `id`, `inventory_item_id`, `required_quantity`, and `sku`.
```bash cURL theme={null}
curl -X POST 'http://localhost:9000/vendor/offers' \
-H 'Authorization: Bearer ' \
-H 'x-seller-id: ' \
-H 'Content-Type: application/json' \
-d '{
"sku": "ACME-SHIRT-M",
"variant_id": "variant_01HXYZ",
"shipping_profile_id": "sp_01HXYZ",
"prices": [{ "amount": 2500, "currency_code": "usd" }],
"inventory_items": [
{
"required_quantity": 1,
"stock_levels": [{ "location_id": "sloc_01HXYZ", "stocked_quantity": 100 }]
}
]
}'
```
```ts JS Client theme={null}
const { offer } = await client.vendor.offers.mutate({
sku: "ACME-SHIRT-M",
variant_id: "variant_01HXYZ",
shipping_profile_id: "sp_01HXYZ",
prices: [{ amount: 2500, currency_code: "usd" }],
inventory_items: [
{
required_quantity: 1,
stock_levels: [{ location_id: "sloc_01HXYZ", stocked_quantity: 100 }],
},
],
})
```
```json 201 theme={null}
{
"offer": {
"id": "offer_01HXYZ",
"seller_id": "sel_01HXYZ",
"variant_id": "variant_01HXYZ",
"product_id": "prod_01HXYZ",
"shipping_profile_id": "sp_01HXYZ",
"sku": "ACME-SHIRT-M",
"manage_inventory": true,
"allow_backorder": false,
"prices": [
{ "id": "price_01HXYZ", "amount": 2500, "currency_code": "usd" }
],
"inventory_items": [
{ "inventory_item_id": "iitem_01HXYZ", "required_quantity": 1 }
]
}
}
```
# Delete Offer
Source: https://docs.mercurjs.com/references/api/vendor/offers/delete-offer
DELETE /vendor/offers/{id}
Delete one of the seller's offers.
Deletes the offer immediately; the underlying product and variant are unaffected.
## Path parameters
The offer's ID.
## Response
The deleted offer's ID.
Always `offer`.
Always `true`.
```bash cURL theme={null}
curl -X DELETE 'http://localhost:9000/vendor/offers/offer_01HXYZ' \
-H 'Authorization: Bearer ' \
-H 'x-seller-id: '
```
```ts JS Client theme={null}
const { id, deleted } = await client.vendor.offers.$id.delete({
$id: "offer_01HXYZ",
})
```
```json 200 theme={null}
{
"id": "offer_01HXYZ",
"object": "offer",
"deleted": true
}
```
# List Offers
Source: https://docs.mercurjs.com/references/api/vendor/offers/list-offers
GET /vendor/offers
Retrieve a paginated list of the seller's offers.
Returns the seller's offers with variant, shipping profile, prices, and linked inventory items.
## Query parameters
The maximum number of offers to return.
The number of offers to skip before returning results.
The field to sort by, e.g. `created_at` or `-created_at` for descending.
Comma-separated fields to include in the response. Prefix with `+`/`-` to add to or remove from the defaults.
Search term matched against offer fields.
Filter by offer ID(s).
Filter by product variant ID(s).
Filter by shipping profile ID(s).
Filter by seller SKU(s).
Filter by EAN(s).
Filter by UPC(s).
Filter by creation date using operators like `$gt`, `$lt`, `$gte`, `$lte`.
Filter by update date using operators like `$gt`, `$lt`, `$gte`, `$lte`.
## Response
The offer's ID.ID of the owning seller.ID of the listed product variant.ID of the parent product.ID of the shipping profile used to fulfill the offer.The seller's SKU for this listing.EAN barcode.UPC barcode.Whether stock is tracked for the offer.Whether the offer can be purchased when it is out of stock.The seller with `id`, `name`, and `handle`.The variant with `id`, `title`, and `sku`.The shipping profile with `id` and `name`.Offer prices with `id`, `amount`, `currency_code`, `min_quantity`, `max_quantity`, and `price_rules`.Linked inventory items with `id`, `inventory_item_id`, `required_quantity`, and `sku`.Total number of matching offers.Number of skipped offers.Maximum number of returned offers.
```bash cURL theme={null}
curl 'http://localhost:9000/vendor/offers?variant_id=variant_01HXYZ' \
-H 'Authorization: Bearer ' \
-H 'x-seller-id: '
```
```ts JS Client theme={null}
const { offers, count } = await client.vendor.offers.query({
variant_id: "variant_01HXYZ",
})
```
```json 200 theme={null}
{
"offers": [
{
"id": "offer_01HXYZ",
"seller_id": "sel_01HXYZ",
"variant_id": "variant_01HXYZ",
"product_id": "prod_01HXYZ",
"shipping_profile_id": "sp_01HXYZ",
"sku": "ACME-SHIRT-M",
"manage_inventory": true,
"allow_backorder": false,
"prices": [
{ "id": "price_01HXYZ", "amount": 2500, "currency_code": "usd" }
],
"inventory_items": [
{ "inventory_item_id": "iitem_01HXYZ", "required_quantity": 1 }
]
}
],
"count": 1,
"offset": 0,
"limit": 50
}
```
# Retrieve Offer
Source: https://docs.mercurjs.com/references/api/vendor/offers/retrieve-offer
GET /vendor/offers/{id}
Retrieve one of the seller's offers by its ID.
Returns a single offer owned by the seller; requesting another seller's offer fails.
## Path parameters
The offer's ID.
## Query parameters
Comma-separated fields to include in the response. Prefix with `+`/`-` to add to or remove from the defaults.
## Response
The offer's ID.ID of the owning seller.ID of the listed product variant.ID of the parent product.ID of the shipping profile.The seller's SKU for this listing.EAN barcode.UPC barcode.Whether stock is tracked for the offer.Whether the offer can be purchased when it is out of stock.The seller with `id`, `name`, and `handle`.The variant with `id`, `title`, and `sku`.The shipping profile with `id` and `name`.Offer prices with `id`, `amount`, `currency_code`, `min_quantity`, `max_quantity`, and `price_rules`.Linked inventory items with `id`, `inventory_item_id`, `required_quantity`, and `sku`.Custom key-value pairs.
```bash cURL theme={null}
curl 'http://localhost:9000/vendor/offers/offer_01HXYZ' \
-H 'Authorization: Bearer ' \
-H 'x-seller-id: '
```
```ts JS Client theme={null}
const { offer } = await client.vendor.offers.$id.query({
$id: "offer_01HXYZ",
})
```
```json 200 theme={null}
{
"offer": {
"id": "offer_01HXYZ",
"seller_id": "sel_01HXYZ",
"variant_id": "variant_01HXYZ",
"product_id": "prod_01HXYZ",
"sku": "ACME-SHIRT-M",
"manage_inventory": true,
"allow_backorder": false,
"prices": [
{ "id": "price_01HXYZ", "amount": 2500, "currency_code": "usd" }
],
"inventory_items": [
{ "inventory_item_id": "iitem_01HXYZ", "required_quantity": 1 }
]
}
}
```
# Update Offer
Source: https://docs.mercurjs.com/references/api/vendor/offers/update-offer
POST /vendor/offers/{id}
Update one of the seller's offers.
Updates the offer directly (no change request) and returns the refreshed offer.
## Path parameters
The offer's ID.
## Body parameters
The seller's SKU for this listing.
ID of the shipping profile used to fulfill the offer.
Whether stock is tracked for the offer. Set to `false` to sell without inventory checks.
Whether the offer can be purchased when it is out of stock.
Prices to upsert; include `id` to update an existing price, omit it to add one.
Existing price ID to update.Price amount.Currency code, e.g. `usd`.Minimum quantity for the price to apply; positive integer.Maximum quantity for the price to apply; positive integer.Price rules as attribute-to-value pairs.
Custom key-value pairs.
## Response
The offer's ID.The seller's SKU for this listing.ID of the shipping profile.Whether stock is tracked for the offer.Whether the offer can be purchased when it is out of stock.The offer's prices after the update.Linked inventory items.
```bash cURL theme={null}
curl -X POST 'http://localhost:9000/vendor/offers/offer_01HXYZ' \
-H 'Authorization: Bearer ' \
-H 'x-seller-id: ' \
-H 'Content-Type: application/json' \
-d '{"prices": [{ "id": "price_01HXYZ", "amount": 2300, "currency_code": "usd" }]}'
```
```ts JS Client theme={null}
const { offer } = await client.vendor.offers.$id.mutate({
$id: "offer_01HXYZ",
prices: [{ id: "price_01HXYZ", amount: 2300, currency_code: "usd" }],
})
```
```json 200 theme={null}
{
"offer": {
"id": "offer_01HXYZ",
"sku": "ACME-SHIRT-M",
"manage_inventory": true,
"allow_backorder": false,
"prices": [
{ "id": "price_01HXYZ", "amount": 2300, "currency_code": "usd" }
]
}
}
```
# Cancel Fulfillment
Source: https://docs.mercurjs.com/references/api/vendor/orders/cancel-fulfillment
POST /vendor/orders/{id}/fulfillments/{fulfillment_id}/cancel
Cancel a fulfillment on the order.
Cancels the fulfillment and returns the updated order.
A fulfillment cannot be canceled after it has been shipped.
## Path parameters
The order's ID.The fulfillment's ID.
## Query parameters
Comma-separated list of fields to include in the returned order, prefix with `+`/`-` to add or remove from defaults.
## Response
The order's ID.The order's status.The order's fulfillments, including the canceled one.The order's line items.
```bash cURL theme={null}
curl -X POST 'http://localhost:9000/vendor/orders/order_01HXYZABCDEF/fulfillments/ful_01HXYZABCDEF/cancel' \
-H 'Authorization: Bearer ' \
-H 'x-seller-id: '
```
```ts JS Client theme={null}
const { order } =
await client.vendor.orders.$id.fulfillments.$fulfillment_id.cancel.mutate({
$id: "order_01HXYZABCDEF",
$fulfillment_id: "ful_01HXYZABCDEF",
})
```
```json 200 theme={null}
{
"order": {
"id": "order_01HXYZABCDEF",
"display_id": 42,
"status": "pending",
"fulfillments": [
{
"id": "ful_01HXYZABCDEF",
"canceled_at": "2026-06-04T10:00:00.000Z"
}
]
}
}
```
# Cancel Order
Source: https://docs.mercurjs.com/references/api/vendor/orders/cancel-order
POST /vendor/orders/{id}/cancel
Cancel the order.
Cancels the order on behalf of the seller and returns the updated order.
An order can only be canceled if it has no fulfillments or captured payments that block cancellation.
## Path parameters
The order's ID.
## Query parameters
Comma-separated list of fields to include in the returned order, prefix with `+`/`-` to add or remove from defaults.
## Response
The order's ID.The order's status, now `canceled`.Cancellation timestamp.The order's line items.Order totals summary.
```bash cURL theme={null}
curl -X POST 'http://localhost:9000/vendor/orders/order_01HXYZABCDEF/cancel' \
-H 'Authorization: Bearer ' \
-H 'x-seller-id: '
```
```ts JS Client theme={null}
const { order } = await client.vendor.orders.$id.cancel.mutate({
$id: "order_01HXYZABCDEF",
})
```
```json 200 theme={null}
{
"order": {
"id": "order_01HXYZABCDEF",
"display_id": 42,
"status": "canceled",
"canceled_at": "2026-06-05T10:00:00.000Z"
}
}
```
# Complete Order
Source: https://docs.mercurjs.com/references/api/vendor/orders/complete-order
POST /vendor/orders/{id}/complete
Mark the order as completed.
Completes the order and returns the updated order.
## Path parameters
The order's ID.
## Query parameters
Comma-separated list of fields to include in the returned order, prefix with `+`/`-` to add or remove from defaults.
## Response
The order's ID.The order's status, now `completed`.The order's line items.Order totals summary.Last update timestamp.
```bash cURL theme={null}
curl -X POST 'http://localhost:9000/vendor/orders/order_01HXYZABCDEF/complete' \
-H 'Authorization: Bearer ' \
-H 'x-seller-id: '
```
```ts JS Client theme={null}
const { order } = await client.vendor.orders.$id.complete.mutate({
$id: "order_01HXYZABCDEF",
})
```
```json 200 theme={null}
{
"order": {
"id": "order_01HXYZABCDEF",
"display_id": 42,
"status": "completed",
"currency_code": "usd",
"updated_at": "2026-06-05T10:00:00.000Z"
}
}
```
# Create Fulfillment
Source: https://docs.mercurjs.com/references/api/vendor/orders/create-fulfillment
POST /vendor/orders/{id}/fulfillments
Create a fulfillment for the order's items.
Creates a fulfillment for the specified items from a stock location owned by the seller.
## Path parameters
The order's ID.
## Body parameters
Items to fulfill.
Line item ID.Quantity to fulfill; integer, minimum 0.Whether the fulfillment requires shipping.ID of the stock location to fulfill from.
## Response
The fulfillment's ID.ID of the stock location fulfilled from.Whether the fulfillment requires shipping.When the fulfillment was packed.When the fulfillment was shipped.When the fulfillment was delivered.Creation timestamp.
```bash cURL theme={null}
curl -X POST 'http://localhost:9000/vendor/orders/order_01HXYZABCDEF/fulfillments' \
-H 'Authorization: Bearer ' \
-H 'x-seller-id: ' \
-H 'Content-Type: application/json' \
-d '{
"items": [{ "id": "ordli_01HXYZABCDEF", "quantity": 1 }],
"requires_shipping": true,
"location_id": "sloc_01HXYZABCDEF"
}'
```
```ts JS Client theme={null}
const { fulfillment } = await client.vendor.orders.$id.fulfillments.mutate({
$id: "order_01HXYZABCDEF",
items: [{ id: "ordli_01HXYZABCDEF", quantity: 1 }],
requires_shipping: true,
location_id: "sloc_01HXYZABCDEF",
})
```
```json 200 theme={null}
{
"fulfillment": {
"id": "ful_01HXYZABCDEF",
"location_id": "sloc_01HXYZABCDEF",
"requires_shipping": true,
"packed_at": "2026-06-03T10:00:00.000Z",
"shipped_at": null,
"delivered_at": null,
"created_at": "2026-06-03T10:00:00.000Z"
}
}
```
# Create Shipment
Source: https://docs.mercurjs.com/references/api/vendor/orders/create-shipment
POST /vendor/orders/{id}/fulfillments/{fulfillment_id}/shipments
Create a shipment for a fulfillment.
Marks the fulfillment as shipped with optional tracking labels and returns the updated order.
## Path parameters
The order's ID.The fulfillment's ID.
## Query parameters
Comma-separated list of fields to include in the returned order, prefix with `+`/`-` to add or remove from defaults.
## Body parameters
Items included in the shipment.
Line item ID.Quantity shipped.
Tracking labels for the shipment.
The shipment's tracking number.URL to track the shipment.URL of the shipping label.
## Response
The order's ID.The order's status.The order's fulfillments with `shipped_at` set.The order's line items.
```bash cURL theme={null}
curl -X POST 'http://localhost:9000/vendor/orders/order_01HXYZABCDEF/fulfillments/ful_01HXYZABCDEF/shipments' \
-H 'Authorization: Bearer ' \
-H 'x-seller-id: ' \
-H 'Content-Type: application/json' \
-d '{
"items": [{ "id": "ordli_01HXYZABCDEF", "quantity": 1 }],
"labels": [
{
"tracking_number": "1Z999AA10123456784",
"tracking_url": "https://track.example.com/1Z999AA10123456784",
"label_url": "https://labels.example.com/1Z999AA10123456784.pdf"
}
]
}'
```
```ts JS Client theme={null}
const { order } =
await client.vendor.orders.$id.fulfillments.$fulfillment_id.shipments.mutate({
$id: "order_01HXYZABCDEF",
$fulfillment_id: "ful_01HXYZABCDEF",
items: [{ id: "ordli_01HXYZABCDEF", quantity: 1 }],
labels: [
{
tracking_number: "1Z999AA10123456784",
tracking_url: "https://track.example.com/1Z999AA10123456784",
label_url: "https://labels.example.com/1Z999AA10123456784.pdf",
},
],
})
```
```json 200 theme={null}
{
"order": {
"id": "order_01HXYZABCDEF",
"display_id": 42,
"status": "pending",
"fulfillments": [
{
"id": "ful_01HXYZABCDEF",
"shipped_at": "2026-06-04T10:00:00.000Z",
"delivered_at": null
}
]
}
}
```
# Get Order Commission Lines
Source: https://docs.mercurjs.com/references/api/vendor/orders/get-order-commission-lines
GET /vendor/orders/{id}/commission-lines
Retrieve the commission lines charged on the order.
Returns the marketplace commission lines applied to the order's items and shipping methods for the authenticated seller.
## Path parameters
The order's ID.
## Response
The commission line's ID.ID of the line item the commission applies to, if any.ID of the shipping method the commission applies to, if any.ID of the commission rate that produced the line.Code of the applied commission rule.The commission rate applied.The commission amount charged.Description of the commission line.Creation timestamp.Last update timestamp.Number of commission lines returned.
```bash cURL theme={null}
curl 'http://localhost:9000/vendor/orders/order_01HXYZABCDEF/commission-lines' \
-H 'Authorization: Bearer ' \
-H 'x-seller-id: '
```
```ts JS Client theme={null}
const { commission_lines, count } =
await client.vendor.orders.$id.commissionLines.query({
$id: "order_01HXYZABCDEF",
})
```
```json 200 theme={null}
{
"commission_lines": [
{
"id": "comline_01HXYZABCDEF",
"item_id": "ordli_01HXYZABCDEF",
"shipping_method_id": null,
"commission_rate_id": "comrate_01HXYZABCDEF",
"code": "default",
"rate": 10,
"amount": 250,
"description": null,
"created_at": "2026-06-01T10:00:00.000Z",
"updated_at": "2026-06-01T10:00:00.000Z"
}
],
"count": 1
}
```
# List Order Changes
Source: https://docs.mercurjs.com/references/api/vendor/orders/list-order-changes
GET /vendor/orders/{id}/changes
Retrieve the change history of an order.
Returns all order changes (edits, returns, exchanges, claims) recorded for the order, with their actions.
## Path parameters
The order's ID.
## Query parameters
Comma-separated list of fields to include, prefix with `+`/`-` to add or remove from defaults.
## Response
The order change's ID.ID of the order the change belongs to.Order version the change targets.Type of change, e.g. `edit`, `return`, `exchange`, `claim`.The change's status, e.g. `pending`, `confirmed`, `canceled`.ID of the actor who created the change.ID of the actor who confirmed the change.ID of the actor who canceled the change.The individual actions that make up the change.Creation timestamp.Last update timestamp.Confirmation timestamp.Cancellation timestamp.
```bash cURL theme={null}
curl 'http://localhost:9000/vendor/orders/order_01HXYZABCDEF/changes' \
-H 'Authorization: Bearer ' \
-H 'x-seller-id: '
```
```ts JS Client theme={null}
const { order_changes } = await client.vendor.orders.$id.changes.query({
$id: "order_01HXYZABCDEF",
})
```
```json 200 theme={null}
{
"order_changes": [
{
"id": "ordch_01HXYZABCDEF",
"order_id": "order_01HXYZABCDEF",
"version": 2,
"change_type": "return",
"status": "confirmed",
"created_by": "sel_01HXYZABCDEF",
"actions": [
{
"id": "ordchact_01HXYZABCDEF",
"action": "RETURN_ITEM",
"details": { "quantity": 1 }
}
],
"created_at": "2026-06-02T10:00:00.000Z",
"confirmed_at": "2026-06-02T11:00:00.000Z"
}
]
}
```
# List Orders
Source: https://docs.mercurjs.com/references/api/vendor/orders/list-orders
GET /vendor/orders
Retrieve a paginated list of the seller's orders.
Returns all non-draft orders belonging to the authenticated seller, with optional filtering by status, customer, region, and more.
## Query parameters
Maximum number of records to return.Number of records to skip.Field to sort by, prefix with `-` for descending order.Comma-separated list of fields to include, prefix with `+`/`-` to add or remove from defaults.Search term matched against order fields.Filter by order ID(s).Filter by order status.Filter by customer ID(s).Filter by sales channel ID(s).Filter by region ID(s).Filter by currency code(s).Filter by fulfillment status.Filter by payment status.Filter by creation date with operators like `$gte`, `$lte`.Filter by update date with operators like `$gte`, `$lte`.
## Response
The order's ID.Human-readable order number.The order's status.The customer's email.The order's currency code.ID of the order's region.ID of the customer.ID of the sales channel.The order's line items, including variant, product, and offer data.The order's shipping address.The order's billing address.The order's shipping methods.Payment collections with payments and refunds.The order's fulfillments.The order's returns with items and reasons.Order totals summary.Custom key-value data.Creation timestamp.Last update timestamp.Cancellation timestamp, if canceled.Total number of matching orders.Number of records skipped.Number of records returned.
```bash cURL theme={null}
curl 'http://localhost:9000/vendor/orders?limit=20&order=-created_at' \
-H 'Authorization: Bearer ' \
-H 'x-seller-id: '
```
```ts JS Client theme={null}
const { orders, count } = await client.vendor.orders.query({
limit: 20,
order: "-created_at",
})
```
```json 200 theme={null}
{
"orders": [
{
"id": "order_01HXYZABCDEF",
"display_id": 42,
"status": "pending",
"email": "jane@example.com",
"currency_code": "usd",
"region_id": "reg_01HXYZABCDEF",
"customer_id": "cus_01HXYZABCDEF",
"sales_channel_id": "sc_01HXYZABCDEF",
"items": [
{
"id": "ordli_01HXYZABCDEF",
"title": "T-Shirt / M",
"quantity": 1,
"unit_price": 2500
}
],
"created_at": "2026-06-01T10:00:00.000Z",
"updated_at": "2026-06-01T10:00:00.000Z",
"canceled_at": null
}
],
"count": 1,
"offset": 0,
"limit": 20
}
```
# Mark Fulfillment as Delivered
Source: https://docs.mercurjs.com/references/api/vendor/orders/mark-fulfillment-delivered
POST /vendor/orders/{id}/fulfillments/{fulfillment_id}/mark-as-delivered
Mark a fulfillment as delivered.
Marks the fulfillment as delivered and returns the updated order.
## Path parameters
The order's ID.The fulfillment's ID.
## Query parameters
Comma-separated list of fields to include in the returned order, prefix with `+`/`-` to add or remove from defaults.
## Response
The order's ID.The order's status.The order's fulfillments with `delivered_at` set.The order's line items.
```bash cURL theme={null}
curl -X POST 'http://localhost:9000/vendor/orders/order_01HXYZABCDEF/fulfillments/ful_01HXYZABCDEF/mark-as-delivered' \
-H 'Authorization: Bearer ' \
-H 'x-seller-id: '
```
```ts JS Client theme={null}
const { order } =
await client.vendor.orders.$id.fulfillments.$fulfillment_id.markAsDelivered.mutate({
$id: "order_01HXYZABCDEF",
$fulfillment_id: "ful_01HXYZABCDEF",
})
```
```json 200 theme={null}
{
"order": {
"id": "order_01HXYZABCDEF",
"display_id": 42,
"status": "pending",
"fulfillments": [
{
"id": "ful_01HXYZABCDEF",
"shipped_at": "2026-06-04T10:00:00.000Z",
"delivered_at": "2026-06-06T09:30:00.000Z"
}
]
}
}
```
# Preview Order
Source: https://docs.mercurjs.com/references/api/vendor/orders/preview-order
GET /vendor/orders/{id}/preview
Preview the order with its pending order change applied.
Returns a preview of the order as it would look once the currently active order change (e.g. a pending edit, return, or exchange) is confirmed.
## Path parameters
The order's ID.
## Response
The order's ID.Human-readable order number.The order's status.Line items with pending change actions applied.Shipping methods with pending change actions applied.The active order change being previewed.Projected order total after the change.
```bash cURL theme={null}
curl 'http://localhost:9000/vendor/orders/order_01HXYZABCDEF/preview' \
-H 'Authorization: Bearer ' \
-H 'x-seller-id: '
```
```ts JS Client theme={null}
const { order } = await client.vendor.orders.$id.preview.query({
$id: "order_01HXYZABCDEF",
})
```
```json 200 theme={null}
{
"order": {
"id": "order_01HXYZABCDEF",
"display_id": 42,
"status": "pending",
"items": [
{
"id": "ordli_01HXYZABCDEF",
"title": "T-Shirt / M",
"quantity": 1,
"unit_price": 2500
}
],
"order_change": {
"id": "ordch_01HXYZABCDEF",
"change_type": "edit",
"status": "pending"
},
"total": 2500
}
}
```
# Retrieve Order
Source: https://docs.mercurjs.com/references/api/vendor/orders/retrieve-order
GET /vendor/orders/{id}
Retrieve a single order by ID.
Returns an order belonging to the authenticated seller, including items, addresses, payments, fulfillments, and returns.
## Path parameters
The order's ID.
## Query parameters
Comma-separated list of fields to include, prefix with `+`/`-` to add or remove from defaults.
## Response
The order's ID.Human-readable order number.The order's status.The customer's email.The order's currency code.ID of the order's region.ID of the customer.ID of the sales channel.The order's line items, including variant, product, and offer data.The order's shipping address.The order's billing address.The order's shipping methods.Payment collections with payments and refunds.The order's fulfillments.The order's returns with items and reasons.Order totals summary.Custom key-value data.Creation timestamp.Last update timestamp.Cancellation timestamp, if canceled.
```bash cURL theme={null}
curl 'http://localhost:9000/vendor/orders/order_01HXYZABCDEF' \
-H 'Authorization: Bearer ' \
-H 'x-seller-id: '
```
```ts JS Client theme={null}
const { order } = await client.vendor.orders.$id.query({
$id: "order_01HXYZABCDEF",
})
```
```json 200 theme={null}
{
"order": {
"id": "order_01HXYZABCDEF",
"display_id": 42,
"status": "pending",
"email": "jane@example.com",
"currency_code": "usd",
"items": [
{
"id": "ordli_01HXYZABCDEF",
"title": "T-Shirt / M",
"quantity": 1,
"unit_price": 2500
}
],
"summary": {
"current_order_total": 2500,
"paid_total": 2500
},
"created_at": "2026-06-01T10:00:00.000Z",
"updated_at": "2026-06-01T10:00:00.000Z"
}
}
```
# Create Onboarding
Source: https://docs.mercurjs.com/references/api/vendor/payout-accounts/create-onboarding
POST /vendor/payout-accounts/{id}/onboarding
Start or refresh provider onboarding for a payout account.
Creates an onboarding session with the payout provider. For Stripe Connect, the returned `data` contains the hosted onboarding link.
## Path parameters
The payout account's ID.
## Body parameters
Provider-specific data forwarded when creating the onboarding.
Provider-specific context (e.g. `refresh_url` and `return_url` for Stripe Connect).
## Response
The onboarding's ID.Provider-specific onboarding data, such as the hosted onboarding URL.The context used to create the onboarding.Creation timestamp.Update timestamp.
```bash cURL theme={null}
curl -X POST 'http://localhost:9000/vendor/payout-accounts/pacc_01HXYZ/onboarding' \
-H 'Authorization: Bearer ' \
-H 'x-seller-id: ' \
-H 'Content-Type: application/json' \
-d '{"context": {"refresh_url": "https://vendor.example.com/payouts", "return_url": "https://vendor.example.com/payouts"}}'
```
```ts JS Client theme={null}
const { onboarding } = await client.vendor.payoutAccounts.$id.onboarding.mutate({
$id: "pacc_01HXYZ",
context: {
refresh_url: "https://vendor.example.com/payouts",
return_url: "https://vendor.example.com/payouts",
},
})
```
```json 201 theme={null}
{
"onboarding": {
"id": "onb_01HXYZ",
"data": { "url": "https://connect.stripe.com/setup/s/..." },
"context": { "return_url": "https://vendor.example.com/payouts" },
"created_at": "2026-01-15T10:00:00.000Z"
}
}
```
# Create Payout Account
Source: https://docs.mercurjs.com/references/api/vendor/payout-accounts/create-payout-account
POST /vendor/payout-accounts
Create the seller's payout account with the configured provider.
Creates a payout account for the current seller through the configured payout provider (e.g. Stripe Connect).
## Body parameters
Provider-specific data forwarded when creating the account.
Provider-specific context (e.g. `{ "country": "US" }` for Stripe Connect).
## Response
The payout account's ID.One of `pending`, `active`, `restricted`, `rejected`.Provider-specific account data.The context used to create the account.The onboarding record, once created.Creation timestamp.
```bash cURL theme={null}
curl -X POST 'http://localhost:9000/vendor/payout-accounts' \
-H 'Authorization: Bearer ' \
-H 'x-seller-id: ' \
-H 'Content-Type: application/json' \
-d '{"context": {"country": "US"}}'
```
```ts JS Client theme={null}
const { payout_account } = await client.vendor.payoutAccounts.mutate({
context: { country: "US" },
})
```
```json 201 theme={null}
{
"payout_account": {
"id": "pacc_01HXYZ",
"status": "pending",
"context": { "country": "US" },
"created_at": "2026-01-15T10:00:00.000Z"
}
}
```
# List Payout Accounts
Source: https://docs.mercurjs.com/references/api/vendor/payout-accounts/retrieve-payout-account
GET /vendor/payout-accounts
List the seller's payout accounts.
Returns the payout accounts linked to the current seller, each with its onboarding.
A seller typically has at most one payout account, so the first entry is usually the one you need.
## Query parameters
Maximum number of items to return.
Number of items to skip.
Field to sort by, prefixed with `-` for descending order.
Comma-separated fields to include in the response.
## Response
The payout account's ID.One of `pending`, `active`, `restricted`, `rejected`.Provider-specific account data.Context passed when the account was created.The account's onboarding record (`id`, `data`, `context`).Creation timestamp.Update timestamp.Total number of payout accounts.Number of items skipped.Maximum number of items returned.
```bash cURL theme={null}
curl 'http://localhost:9000/vendor/payout-accounts' \
-H 'Authorization: Bearer ' \
-H 'x-seller-id: '
```
```ts JS Client theme={null}
const { payout_accounts } = await client.vendor.payoutAccounts.query()
```
```json 200 theme={null}
{
"payout_accounts": [
{
"id": "pacc_01HXYZ",
"status": "active",
"onboarding": { "id": "onb_01HXYZ", "data": { "url": "https://connect.stripe.com/setup/..." } },
"created_at": "2026-01-15T10:00:00.000Z"
}
],
"count": 1,
"offset": 0,
"limit": 20
}
```
# Retrieve Payout Account
Source: https://docs.mercurjs.com/references/api/vendor/payout-accounts/retrieve-payout-account-by-id
GET /vendor/payout-accounts/{id}
Retrieve one of the seller's payout accounts by ID.
Returns a payout account, verifying it belongs to the current seller.
## Path parameters
The payout account's ID.
## Query parameters
Comma-separated fields to include in the response.
## Response
The payout account's ID.One of `pending`, `active`, `restricted`, `rejected`.Provider-specific account data.Context passed when the account was created.The account's onboarding record (`id`, `data`, `context`).Creation timestamp.Update timestamp.
```bash cURL theme={null}
curl 'http://localhost:9000/vendor/payout-accounts/pacc_01HXYZ' \
-H 'Authorization: Bearer ' \
-H 'x-seller-id: '
```
```ts JS Client theme={null}
const { payout_account } = await client.vendor.payoutAccounts.$id.query({
$id: "pacc_01HXYZ",
})
```
```json 200 theme={null}
{
"payout_account": {
"id": "pacc_01HXYZ",
"status": "active",
"onboarding": { "id": "onb_01HXYZ" },
"created_at": "2026-01-15T10:00:00.000Z"
}
}
```
# List Payouts
Source: https://docs.mercurjs.com/references/api/vendor/payouts/list-payouts
GET /vendor/payouts
List the seller's payouts.
Returns the payouts belonging to the current seller.
## Query parameters
Maximum number of items to return.
Number of items to skip.
Field to sort by, prefixed with `-` for descending order.
Comma-separated fields to include in the response.
Filter by payout status: `pending`, `processing`, `paid`, `failed`, `canceled`.
Filter by creation date using operators like `$gte`, `$lte`, `$gt`, `$lt`.
Filter by update date using operators like `$gte`, `$lte`, `$gt`, `$lt`.
## Response
The payout's ID.Human-readable payout number.The payout amount.The payout currency.One of `pending`, `processing`, `paid`, `failed`, `canceled`.Provider-specific data.Creation timestamp.Update timestamp.Total number of payouts.Number of items skipped.Maximum number of items returned.
```bash cURL theme={null}
curl 'http://localhost:9000/vendor/payouts?status=paid' \
-H 'Authorization: Bearer ' \
-H 'x-seller-id: '
```
```ts JS Client theme={null}
const { payouts, count } = await client.vendor.payouts.query({
status: "paid",
})
```
```json 200 theme={null}
{
"payouts": [
{
"id": "pout_01HXYZ",
"display_id": 42,
"amount": 1250,
"currency_code": "usd",
"status": "paid",
"created_at": "2026-01-15T10:00:00.000Z"
}
],
"count": 1,
"offset": 0,
"limit": 20
}
```
# Retrieve Payout
Source: https://docs.mercurjs.com/references/api/vendor/payouts/retrieve-payout
GET /vendor/payouts/{id}
Retrieve one of the seller's payouts by ID.
Returns a payout, verifying it belongs to the current seller.
## Path parameters
The payout's ID.
## Query parameters
Comma-separated fields to include in the response.
## Response
The payout's ID.Human-readable payout number.The payout amount.The payout currency.One of `pending`, `processing`, `paid`, `failed`, `canceled`.Provider-specific data.Creation timestamp.Update timestamp.
```bash cURL theme={null}
curl 'http://localhost:9000/vendor/payouts/pout_01HXYZ' \
-H 'Authorization: Bearer ' \
-H 'x-seller-id: '
```
```ts JS Client theme={null}
const { payout } = await client.vendor.payouts.$id.query({
$id: "pout_01HXYZ",
})
```
```json 200 theme={null}
{
"payout": {
"id": "pout_01HXYZ",
"display_id": 42,
"amount": 1250,
"currency_code": "usd",
"status": "paid",
"created_at": "2026-01-15T10:00:00.000Z"
}
}
```
# List Product Attributes
Source: https://docs.mercurjs.com/references/api/vendor/product-attributes/list-product-attributes
GET /vendor/product-attributes
Retrieve a paginated list of global product attributes.
Returns global attribute definitions (product-scoped attributes are excluded) with their values and category assignments.
## Query parameters
The maximum number of attributes to return.
The number of attributes to skip before returning results.
The field to sort by, e.g. `rank` or `-created_at`.
Comma-separated fields to include in the response. Prefix with `+`/`-` to add to or remove from the defaults.
Search term matched against attribute fields.
Filter by attribute ID(s).
Filter by attribute handle(s).
Filter by type. Values: `single_select`, `multi_select`, `unit`, `toggle`, `text`.
Filter by whether the attribute is required.
Filter by whether the attribute drives variant combinations.
Filter by whether the attribute is filterable.
Filter by whether the attribute is active.
Filter by assigned category ID(s).
Filter by creation date using operators like `$gt`, `$lt`, `$gte`, `$lte`.
Filter by update date using operators like `$gt`, `$lt`, `$gte`, `$lte`.
Join multiple filter objects with a logical AND.
Join multiple filter objects with a logical OR.
## Response
The attribute's ID.The attribute's name.The attribute's handle.The attribute's description.One of `single_select`, `multi_select`, `unit`, `toggle`, `text`.Whether a value is required on products.Whether the attribute can be used for filtering.Whether the attribute drives variant combinations.Whether the attribute is active.Sort rank of the attribute.The attribute's values with `id`, `name`, `handle`, and `rank`.Assigned categories with `id`, `name`, and `handle`.Total number of matching attributes.Number of skipped attributes.Maximum number of returned attributes.
```bash cURL theme={null}
curl 'http://localhost:9000/vendor/product-attributes?type=multi_select' \
-H 'Authorization: Bearer ' \
-H 'x-seller-id: '
```
```ts JS Client theme={null}
const { product_attributes, count } =
await client.vendor.productAttributes.query({ type: "multi_select" })
```
```json 200 theme={null}
{
"product_attributes": [
{
"id": "pattr_01HXYZ",
"name": "Color",
"handle": "color",
"type": "multi_select",
"is_variant_axis": true,
"values": [
{ "id": "pattrval_01HXYZ", "name": "Red", "rank": 0 }
]
}
],
"count": 1,
"offset": 0,
"limit": 50
}
```
# Retrieve Product Attribute
Source: https://docs.mercurjs.com/references/api/vendor/product-attributes/retrieve-product-attribute
GET /vendor/product-attributes/{id}
Retrieve a product attribute by its ID.
Returns a single attribute definition with its values and category assignments.
## Path parameters
The attribute's ID.
## Query parameters
Comma-separated fields to include in the response. Prefix with `+`/`-` to add to or remove from the defaults.
## Response
The attribute's ID.The attribute's name.The attribute's handle.The attribute's description.One of `single_select`, `multi_select`, `unit`, `toggle`, `text`.Whether a value is required on products.Whether the attribute can be used for filtering.Whether the attribute drives variant combinations.Whether the attribute is active.Sort rank of the attribute.Custom key-value pairs.The attribute's values with `id`, `name`, `handle`, and `rank`.Assigned categories with `id`, `name`, and `handle`.
```bash cURL theme={null}
curl 'http://localhost:9000/vendor/product-attributes/pattr_01HXYZ' \
-H 'Authorization: Bearer ' \
-H 'x-seller-id: '
```
```ts JS Client theme={null}
const { product_attribute } = await client.vendor.productAttributes.$id.query({
$id: "pattr_01HXYZ",
})
```
```json 200 theme={null}
{
"product_attribute": {
"id": "pattr_01HXYZ",
"name": "Color",
"handle": "color",
"type": "multi_select",
"is_variant_axis": true,
"values": [
{ "id": "pattrval_01HXYZ", "name": "Red", "rank": 0 },
{ "id": "pattrval_01HABC", "name": "Blue", "rank": 1 }
]
}
}
```
# List Variants
Source: https://docs.mercurjs.com/references/api/vendor/product-variants/list-variants
GET /vendor/product-variants
Retrieve a paginated list of product variants across products.
Returns product variants regardless of parent product, with the parent product embedded.
## Query parameters
The maximum number of variants to return.
The number of variants to skip before returning results.
The field to sort by, e.g. `created_at` or `-created_at` for descending.
Comma-separated fields to include in the response. Prefix with `+`/`-` to add to or remove from the defaults.
Search term matched against variant fields.
Filter by variant ID(s).
Filter by whether inventory is managed for the variant.
Filter by whether backorders are allowed.
Filter by SKU(s).
Filter by EAN(s).
Filter by UPC(s).
Filter by barcode(s).
Filter by parent product ID(s).
Filter by creation date using operators like `$gt`, `$lt`, `$gte`, `$lte`.
Filter by update date using operators like `$gt`, `$lt`, `$gte`, `$lte`.
Filter by deletion date using operators like `$gt`, `$lt`, `$gte`, `$lte`.
Join multiple filter objects with a logical AND.
Join multiple filter objects with a logical OR.
## Response
The variant's ID.The variant's title.The variant's SKU.EAN barcode.UPC barcode.Generic barcode.ID of the parent product.The parent product record.Whether inventory is managed for the variant.Whether backorders are allowed.Sort rank of the variant.Total number of matching variants.Number of skipped variants.Maximum number of returned variants.
```bash cURL theme={null}
curl 'http://localhost:9000/vendor/product-variants?sku=ACME-SHIRT-M' \
-H 'Authorization: Bearer ' \
-H 'x-seller-id: '
```
```ts JS Client theme={null}
const { variants, count } = await client.vendor.productVariants.query({
sku: "ACME-SHIRT-M",
})
```
```json 200 theme={null}
{
"variants": [
{
"id": "variant_01HXYZ",
"title": "M",
"sku": "ACME-SHIRT-M",
"product_id": "prod_01HXYZ",
"product": { "id": "prod_01HXYZ", "title": "Acme T-Shirt" }
}
],
"count": 1,
"offset": 0,
"limit": 50
}
```
# Batch Product Attributes
Source: https://docs.mercurjs.com/references/api/vendor/products/batch-product-attributes
POST /vendor/products/{id}/attributes/batch
Stage attribute additions, removals, and updates on a product.
Stages attribute operations as a pending change request instead of applying them directly.
The endpoint responds with `202 Accepted` and a `product_change` record containing `ATTRIBUTE_ADD` / `ATTRIBUTE_REMOVE` / `ATTRIBUTE_UPDATE` actions.
## Path parameters
The product's ID.
## Body parameters
Attributes to attach; each entry references an existing attribute by `id` or defines one inline by `title`.
Existing attribute ID (referencing form).IDs of attribute values to select (referencing form).Scalar value for `text`, `unit`, or `toggle` attributes.Attribute title (inline form).One of `single_select`, `multi_select`, `text`, `toggle`, `unit`. Required for inline non-axis attributes unless `value` is a boolean.Value names to create and select (inline form).Whether the attribute drives variant combinations; only allowed on `multi_select` attributes.Whether the attribute is filterable (inline form).Whether the attribute is required (inline form).Attribute description (inline form).Custom key-value pairs (inline form).
IDs of attributes to detach from the product.
Changes to attributes already attached to the product.
The attribute's ID.New attribute title.Values to select, plain strings or `{ "value": "..." }` objects.Value IDs to deselect.New scalar value for `text`, `unit`, or `toggle` attributes.
## Response
The change request's ID.ID of the product the change targets.One of `pending`, `confirmed`, `declined`, `canceled`.Staged attribute operations, each with `id`, `action`, `details`, `ordering`, and `applied`.
```bash cURL theme={null}
curl -X POST 'http://localhost:9000/vendor/products/prod_01HXYZ/attributes/batch' \
-H 'Authorization: Bearer ' \
-H 'x-seller-id: ' \
-H 'Content-Type: application/json' \
-d '{
"add": [{ "id": "pattr_01HXYZ", "value_ids": ["pattrval_01HXYZ"] }],
"remove": ["pattr_01HABC"]
}'
```
```ts JS Client theme={null}
const { product_change } =
await client.vendor.products.$id.attributes.batch.mutate({
$id: "prod_01HXYZ",
add: [{ id: "pattr_01HXYZ", value_ids: ["pattrval_01HXYZ"] }],
remove: ["pattr_01HABC"],
})
```
```json 202 theme={null}
{
"product_change": {
"id": "prodch_01HXYZ",
"product_id": "prod_01HXYZ",
"status": "pending",
"actions": [
{
"id": "prodchact_01HXYZ",
"action": "ATTRIBUTE_ADD",
"details": { "id": "pattr_01HXYZ", "value_ids": ["pattrval_01HXYZ"] },
"applied": false
}
]
}
}
```
# Cancel Product Change
Source: https://docs.mercurjs.com/references/api/vendor/products/cancel-product-change
POST /vendor/products/{id}/cancel
Cancel the seller's pending change request for a product.
Cancels the product's pending change request; responds `404` when no pending change exists.
## Path parameters
The product's ID.
## Body parameters
Optional note recorded with the cancellation.
## Response
The change request's ID.ID of the product the change targeted.`canceled` after this call.ID of the seller that canceled the change.Timestamp of the cancellation.The staged operations that were discarded.
```bash cURL theme={null}
curl -X POST 'http://localhost:9000/vendor/products/prod_01HXYZ/cancel' \
-H 'Authorization: Bearer ' \
-H 'x-seller-id: ' \
-H 'Content-Type: application/json' \
-d '{}'
```
```ts JS Client theme={null}
const { product_change } = await client.vendor.products.$id.cancel.mutate({
$id: "prod_01HXYZ",
})
```
```json 200 theme={null}
{
"product_change": {
"id": "prodch_01HXYZ",
"product_id": "prod_01HXYZ",
"status": "canceled",
"canceled_by": "sel_01HXYZ",
"canceled_at": "2026-07-03T12:00:00.000Z"
}
}
```
# Create Product
Source: https://docs.mercurjs.com/references/api/vendor/products/create-product
POST /vendor/products
Create a new product owned by the seller.
Creates a product; when no status is provided it is created with status `proposed`.
When the product request flow is enabled, `status` may only be `draft` or `proposed`. Publishing requires operator approval.
## Query parameters
Comma-separated fields to include in the returned product. Prefix with `+`/`-` to add to or remove from the defaults.
## Body parameters
The product's title.
The product's subtitle.
The product's description.
One of `draft`, `proposed`, `published`, `rejected`. Restricted to `draft` / `proposed` when the product request flow is enabled.
Whether the product is a gift card.
Whether discounts can apply to the product.
Image URL.
URL of the product's thumbnail image.
The product's URL handle; generated from the title when omitted.
ID of the product in an external system.
ID of the product type.
ID of the collection the product belongs to.
Category ID.Tag ID.Option title, e.g. `Size`.Allowed option values.
Product attribute assignments; each entry references an existing attribute by `id` or defines one inline by `title`.
Existing attribute ID (referencing form).IDs of attribute values to select (referencing form).Scalar value for `text`, `unit`, or `toggle` attributes.Attribute title (inline form).One of `single_select`, `multi_select`, `text`, `toggle`, `unit` (inline form).Value names to create and select (inline form).Whether the attribute drives variant combinations (inline form).Whether the attribute is filterable (inline form).Whether the attribute is required (inline form).Attribute description (inline form).Custom key-value pairs (inline form).Variant title.Variant SKU.EAN barcode.UPC barcode.ISBN code.Amazon ASIN.GTIN code.Generic barcode.Harmonized System code.Manufacturer ID code.Sort rank of the variant.Weight of the variant.Length of the variant.Height of the variant.Width of the variant.Country of origin.Material of the variant.Custom key-value pairs.Option title to value mapping, e.g. `{"Size": "M"}`.Weight of the product.Length of the product.Height of the product.Width of the product.Harmonized System code.Manufacturer ID code.Country of origin.Material of the product.Custom key-value pairs.Extra data passed to workflow hooks.
## Response
The product's ID.The product's title.The product's status (`proposed` unless overridden).The product's URL handle.The created variants.The product's attribute assignments.
```bash cURL theme={null}
curl -X POST 'http://localhost:9000/vendor/products' \
-H 'Authorization: Bearer ' \
-H 'x-seller-id: ' \
-H 'Content-Type: application/json' \
-d '{
"title": "Acme T-Shirt",
"options": [{ "title": "Size", "values": ["S", "M"] }],
"variants": [
{ "title": "S", "sku": "ACME-SHIRT-S", "options": { "Size": "S" } },
{ "title": "M", "sku": "ACME-SHIRT-M", "options": { "Size": "M" } }
]
}'
```
```ts JS Client theme={null}
const { product } = await client.vendor.products.mutate({
title: "Acme T-Shirt",
options: [{ title: "Size", values: ["S", "M"] }],
variants: [
{ title: "S", sku: "ACME-SHIRT-S", options: { Size: "S" } },
{ title: "M", sku: "ACME-SHIRT-M", options: { Size: "M" } },
],
})
```
```json 201 theme={null}
{
"product": {
"id": "prod_01HXYZ",
"title": "Acme T-Shirt",
"status": "proposed",
"handle": "acme-t-shirt",
"variants": [
{ "id": "variant_01HXYZ", "title": "M", "sku": "ACME-SHIRT-M" }
]
}
}
```
# Create Product Variant
Source: https://docs.mercurjs.com/references/api/vendor/products/create-product-variant
POST /vendor/products/{id}/variants
Stage the addition of a variant to a product.
Stages a `VARIANT_ADD` operation as a pending change request instead of creating the variant directly.
The endpoint responds with `202 Accepted` and a `product_change` record; the variant appears on the product once the change is confirmed. The staged variant is always created with `manage_inventory: false`. Stock is tracked on offers.
## Path parameters
The product's ID.
## Body parameters
The variant's title.The variant's SKU.EAN barcode.UPC barcode.ISBN code.Amazon ASIN.GTIN code.Generic barcode.Harmonized System code.Manufacturer ID code.Sort rank of the variant.Weight of the variant.Length of the variant.Height of the variant.Width of the variant.Country of origin.Material of the variant.Whether backorders are allowed.Accepted in the body but forced to `false` when the change is staged.Variant thumbnail URL.Custom key-value pairs.Option title to value mapping, e.g. `{"Size": "L"}`.
## Response
The change request's ID.ID of the product the change targets.One of `pending`, `confirmed`, `declined`, `canceled`.Staged operations, each with `id`, `action` (`VARIANT_ADD`), `details`, `ordering`, and `applied`.
```bash cURL theme={null}
curl -X POST 'http://localhost:9000/vendor/products/prod_01HXYZ/variants' \
-H 'Authorization: Bearer ' \
-H 'x-seller-id: ' \
-H 'Content-Type: application/json' \
-d '{"title": "L", "sku": "ACME-SHIRT-L", "options": {"Size": "L"}}'
```
```ts JS Client theme={null}
const { product_change } = await client.vendor.products.$id.variants.mutate({
$id: "prod_01HXYZ",
title: "L",
sku: "ACME-SHIRT-L",
options: { Size: "L" },
})
```
```json 202 theme={null}
{
"product_change": {
"id": "prodch_01HXYZ",
"product_id": "prod_01HXYZ",
"status": "pending",
"actions": [
{
"id": "prodchact_01HXYZ",
"action": "VARIANT_ADD",
"details": { "title": "L", "sku": "ACME-SHIRT-L" },
"applied": false
}
]
}
}
```
# Delete Product
Source: https://docs.mercurjs.com/references/api/vendor/products/delete-product
DELETE /vendor/products/{id}
Stage a product deletion as a change request.
Does not delete the product directly. It stages a pending `PRODUCT_DELETE` change request that an operator confirms or declines.
The endpoint responds with `202 Accepted` and a `product_change` record; the product remains until the change is confirmed.
## Path parameters
The product's ID.
## Response
The change request's ID.ID of the product the change targets.One of `pending`, `confirmed`, `declined`, `canceled`.ID of the seller that staged the change.Staged operations, each with `id`, `action` (`PRODUCT_DELETE`), `details`, `ordering`, and `applied`.
```bash cURL theme={null}
curl -X DELETE 'http://localhost:9000/vendor/products/prod_01HXYZ' \
-H 'Authorization: Bearer ' \
-H 'x-seller-id: '
```
```ts JS Client theme={null}
const { product_change } = await client.vendor.products.$id.delete({
$id: "prod_01HXYZ",
})
```
```json 202 theme={null}
{
"product_change": {
"id": "prodch_01HXYZ",
"product_id": "prod_01HXYZ",
"status": "pending",
"actions": [
{
"id": "prodchact_01HXYZ",
"action": "PRODUCT_DELETE",
"applied": false
}
]
}
}
```
# Delete Product Variant
Source: https://docs.mercurjs.com/references/api/vendor/products/delete-product-variant
DELETE /vendor/products/{id}/variants/{variant_id}
Stage the removal of a product variant as a change request.
Stages a `VARIANT_REMOVE` operation as a pending change request instead of deleting the variant directly.
The endpoint responds with `202 Accepted` and a `product_change` record; the variant remains until the change is confirmed.
## Path parameters
The product's ID.
The variant's ID.
## Response
The change request's ID.ID of the product the change targets.One of `pending`, `confirmed`, `declined`, `canceled`.Staged operations, each with `id`, `action` (`VARIANT_REMOVE`), `details`, `ordering`, and `applied`.
```bash cURL theme={null}
curl -X DELETE 'http://localhost:9000/vendor/products/prod_01HXYZ/variants/variant_01HXYZ' \
-H 'Authorization: Bearer ' \
-H 'x-seller-id: '
```
```ts JS Client theme={null}
const { product_change } =
await client.vendor.products.$id.variants.$variant_id.delete({
$id: "prod_01HXYZ",
$variant_id: "variant_01HXYZ",
})
```
```json 202 theme={null}
{
"product_change": {
"id": "prodch_01HXYZ",
"product_id": "prod_01HXYZ",
"status": "pending",
"actions": [
{
"id": "prodchact_01HXYZ",
"action": "VARIANT_REMOVE",
"details": { "variant_id": "variant_01HXYZ" },
"applied": false
}
]
}
}
```
# List Product Variants
Source: https://docs.mercurjs.com/references/api/vendor/products/list-product-variants
GET /vendor/products/{id}/variants
Retrieve a paginated list of a product's variants.
Returns the variants belonging to the given product.
## Path parameters
The product's ID.
## Query parameters
The maximum number of variants to return.
The number of variants to skip before returning results.
The field to sort by, e.g. `variant_rank` or `-created_at`.
Comma-separated fields to include in the response. Prefix with `+`/`-` to add to or remove from the defaults.
Search term matched against variant fields.
Filter by whether inventory is managed for the variant.
Filter by whether backorders are allowed.
Filter by creation date using operators like `$gt`, `$lt`, `$gte`, `$lte`.
Filter by update date using operators like `$gt`, `$lt`, `$gte`, `$lte`.
Join multiple filter objects with a logical AND.
Join multiple filter objects with a logical OR.
## Response
The variant's ID.The variant's title.The variant's SKU.ID of the parent product.Whether inventory is managed for the variant.Whether backorders are allowed.Sort rank of the variant.Selected option values with `id`, `value`, and the parent `option`.Variant images with `id`, `url`, and `rank`.Total number of matching variants.Number of skipped variants.Maximum number of returned variants.
```bash cURL theme={null}
curl 'http://localhost:9000/vendor/products/prod_01HXYZ/variants?limit=20' \
-H 'Authorization: Bearer ' \
-H 'x-seller-id: '
```
```ts JS Client theme={null}
const { variants, count } = await client.vendor.products.$id.variants.query({
$id: "prod_01HXYZ",
limit: 20,
})
```
```json 200 theme={null}
{
"variants": [
{
"id": "variant_01HXYZ",
"title": "M",
"sku": "ACME-SHIRT-M",
"product_id": "prod_01HXYZ",
"manage_inventory": false,
"allow_backorder": false
}
],
"count": 1,
"offset": 0,
"limit": 20
}
```
# List Products
Source: https://docs.mercurjs.com/references/api/vendor/products/list-products
GET /vendor/products
Retrieve a paginated list of products visible to the seller.
Returns products the seller created plus published products that are not restricted from the seller.
Products are shared master records. This list includes catalog products from other sellers (when published and unrestricted) so you can create offers against them.
## Query parameters
The maximum number of products to return.
The number of products to skip before returning results.
The field to sort by, e.g. `created_at` or `-created_at` for descending.
Comma-separated fields to include in the response. Prefix with `+`/`-` to add to or remove from the defaults. Include `variants.offers` to attach the seller's offers to each variant.
Search term matched against product fields.
Filter by product ID(s).
Filter by product title.
Filter by product handle.
Filter by status. Values: `draft`, `proposed`, `published`, `rejected`.
Filter by collection ID(s).
Filter by product type ID(s).
Filter by category ID(s).
Filter by tag ID(s).
Filter by variant SKU.
Filter by variant EAN.
Filter by variant UPC.
Filter by variant barcode.
Filter to products the seller has (or has not) created an offer for.
Filter by creation date using operators like `$gt`, `$lt`, `$gte`, `$lte`.
Filter by update date using operators like `$gt`, `$lt`, `$gte`, `$lte`.
Filter by deletion date using operators like `$gt`, `$lt`, `$gte`, `$lte`.
Join multiple filter objects with a logical AND.
Join multiple filter objects with a logical OR.
## Response
The product's ID.The product's title.One of `draft`, `proposed`, `published`, `rejected`.The product's URL handle.URL of the product's thumbnail image.The product's images with `id`, `url`, and `rank`.The associated collection with `id`, `title`, and `handle`.Associated categories with `id`, `name`, and `handle`.The product's variants with `id`, `title`, `sku`, `manage_inventory`, `allow_backorder`, and `variant_rank`.Product attributes grouped per attribute with their selected values.Total number of matching products.Number of skipped products.Maximum number of returned products.
```bash cURL theme={null}
curl 'http://localhost:9000/vendor/products?limit=20&status[]=published' \
-H 'Authorization: Bearer ' \
-H 'x-seller-id: '
```
```ts JS Client theme={null}
const { products, count } = await client.vendor.products.query({
limit: 20,
status: ["published"],
})
```
```json 200 theme={null}
{
"products": [
{
"id": "prod_01HXYZ",
"title": "Acme T-Shirt",
"status": "published",
"handle": "acme-t-shirt",
"thumbnail": "https://cdn.example.com/shirt.png",
"variants": [
{ "id": "variant_01HXYZ", "title": "M", "sku": "ACME-SHIRT-M" }
]
}
],
"count": 1,
"offset": 0,
"limit": 20
}
```
# Preview Product Change
Source: https://docs.mercurjs.com/references/api/vendor/products/preview-product
GET /vendor/products/{id}/preview
Retrieve the seller's pending change request for a product.
Returns the seller's `pending` change request for the product, or `null` when there is none.
## Path parameters
The product's ID.
## Response
The change request's ID.ID of the product the change targets.Always `pending` for this endpoint.ID of the seller that staged the change.Staged operations, each with `id`, `action`, `details`, `ordering`, and `applied`.Internal note attached to the change.Note from the operator, e.g. a revision request.
```bash cURL theme={null}
curl 'http://localhost:9000/vendor/products/prod_01HXYZ/preview' \
-H 'Authorization: Bearer ' \
-H 'x-seller-id: '
```
```ts JS Client theme={null}
const { product_change } = await client.vendor.products.$id.preview.query({
$id: "prod_01HXYZ",
})
```
```json 200 theme={null}
{
"product_change": {
"id": "prodch_01HXYZ",
"product_id": "prod_01HXYZ",
"status": "pending",
"actions": [
{
"id": "prodchact_01HXYZ",
"action": "UPDATE",
"details": { "title": "Acme T-Shirt v2" },
"applied": false
}
]
}
}
```
# Retrieve Product
Source: https://docs.mercurjs.com/references/api/vendor/products/retrieve-product
GET /vendor/products/{id}
Retrieve a product by its ID.
Returns a single product with its images, variants, and attribute assignments.
## Path parameters
The product's ID.
## Query parameters
Comma-separated fields to include in the response. Prefix with `+`/`-` to add to or remove from the defaults. Include `variants.offers` to attach the seller's offers to each variant.
## Response
The product's ID.The product's title.The product's subtitle.The product's description.One of `draft`, `proposed`, `published`, `rejected`.The product's URL handle.URL of the product's thumbnail image.Images with `id`, `url`, and `rank`.The associated collection with `id`, `title`, and `handle`.Associated categories with `id`, `name`, and `handle`.Variants with `id`, `title`, `sku`, `manage_inventory`, `allow_backorder`, and `variant_rank`.Product attributes grouped per attribute with their selected values.Custom key-value pairs.
```bash cURL theme={null}
curl 'http://localhost:9000/vendor/products/prod_01HXYZ' \
-H 'Authorization: Bearer ' \
-H 'x-seller-id: '
```
```ts JS Client theme={null}
const { product } = await client.vendor.products.$id.query({
$id: "prod_01HXYZ",
})
```
```json 200 theme={null}
{
"product": {
"id": "prod_01HXYZ",
"title": "Acme T-Shirt",
"status": "published",
"handle": "acme-t-shirt",
"variants": [
{ "id": "variant_01HXYZ", "title": "M", "sku": "ACME-SHIRT-M" }
]
}
}
```
# Retrieve Product Variant
Source: https://docs.mercurjs.com/references/api/vendor/products/retrieve-product-variant
GET /vendor/products/{id}/variants/{variant_id}
Retrieve a single variant of a product.
Returns a variant scoped to the given product.
## Path parameters
The product's ID.
The variant's ID.
## Query parameters
Comma-separated fields to include in the response. Prefix with `+`/`-` to add to or remove from the defaults.
## Response
The variant's ID.The variant's title.The variant's SKU.EAN barcode.UPC barcode.Generic barcode.ID of the parent product.Whether inventory is managed for the variant.Whether backorders are allowed.Sort rank of the variant.Selected option values with `id`, `value`, and the parent `option`.Variant images with `id`, `url`, and `rank`.Custom key-value pairs.
```bash cURL theme={null}
curl 'http://localhost:9000/vendor/products/prod_01HXYZ/variants/variant_01HXYZ' \
-H 'Authorization: Bearer ' \
-H 'x-seller-id: '
```
```ts JS Client theme={null}
const { variant } = await client.vendor.products.$id.variants.$variant_id.query({
$id: "prod_01HXYZ",
$variant_id: "variant_01HXYZ",
})
```
```json 200 theme={null}
{
"variant": {
"id": "variant_01HXYZ",
"title": "M",
"sku": "ACME-SHIRT-M",
"product_id": "prod_01HXYZ",
"manage_inventory": false,
"allow_backorder": false,
"options": [
{ "id": "optval_01HXYZ", "value": "M", "option": { "id": "opt_01HXYZ", "title": "Size" } }
]
}
}
```
# Update Product
Source: https://docs.mercurjs.com/references/api/vendor/products/update-product
POST /vendor/products/{id}
Stage an update to a product as a change request.
Does not modify the product directly. It stages a pending change request that an operator confirms or declines.
The endpoint responds with `202 Accepted` and a `product_change` record, not the updated product. Use [Preview Product Change](/rc/references/api/vendor/products/preview-product) to inspect the pending change and [Cancel Product Change](/rc/references/api/vendor/products/cancel-product-change) to withdraw it.
## Path parameters
The product's ID.
## Body parameters
The product's title.The product's subtitle.The product's description.Whether discounts can apply to the product.Whether the product is a gift card.Existing image ID to keep.Image URL.URL of the product's thumbnail image.The product's URL handle.ID of the product in an external system.ID of the product type.ID of the collection the product belongs to.Category ID.Tag ID.Option title.Allowed option values.
Variant updates staged with the change.
Existing variant ID to update.Variant title.Variant SKU.EAN barcode.UPC barcode.ISBN code.Amazon ASIN.GTIN code.Generic barcode.Harmonized System code.Manufacturer ID code.Variant thumbnail URL.Sort rank of the variant.Weight of the variant.Length of the variant.Height of the variant.Width of the variant.Country of origin.Material of the variant.Custom key-value pairs.Option title to value mapping.Weight of the product.Length of the product.Height of the product.Width of the product.Harmonized System code.Manufacturer ID code.Country of origin.Material of the product.Custom key-value pairs.Extra data passed to workflow hooks.
## Response
The change request's ID.ID of the product the change targets.One of `pending`, `confirmed`, `declined`, `canceled`.ID of the seller that staged the change.Staged operations, each with `id`, `action` (e.g. `UPDATE`), `details`, `ordering`, and `applied`.Internal note attached to the change.Note from the operator, e.g. a revision request.
```bash cURL theme={null}
curl -X POST 'http://localhost:9000/vendor/products/prod_01HXYZ' \
-H 'Authorization: Bearer ' \
-H 'x-seller-id: ' \
-H 'Content-Type: application/json' \
-d '{"title": "Acme T-Shirt v2", "description": "Softer fabric."}'
```
```ts JS Client theme={null}
const { product_change } = await client.vendor.products.$id.mutate({
$id: "prod_01HXYZ",
title: "Acme T-Shirt v2",
description: "Softer fabric.",
})
```
```json 202 theme={null}
{
"product_change": {
"id": "prodch_01HXYZ",
"product_id": "prod_01HXYZ",
"status": "pending",
"created_by": "sel_01HXYZ",
"actions": [
{
"id": "prodchact_01HXYZ",
"action": "UPDATE",
"details": { "title": "Acme T-Shirt v2" },
"applied": false
}
]
}
}
```
# Update Product Variant
Source: https://docs.mercurjs.com/references/api/vendor/products/update-product-variant
POST /vendor/products/{id}/variants/{variant_id}
Stage an update to a product variant as a change request.
Stages a `VARIANT_UPDATE` operation as a pending change request instead of updating the variant directly.
The endpoint responds with `202 Accepted` and a `product_change` record, not the updated variant.
## Path parameters
The product's ID.
The variant's ID.
## Body parameters
The variant's title.The variant's SKU.EAN barcode.UPC barcode.ISBN code.Amazon ASIN.GTIN code.Generic barcode.Harmonized System code.Manufacturer ID code.Variant thumbnail URL.Sort rank of the variant.Weight of the variant.Length of the variant.Height of the variant.Width of the variant.Country of origin.Material of the variant.Whether backorders are allowed.Whether inventory is managed for the variant.Custom key-value pairs.Option title to value mapping, e.g. `{"Size": "M"}`.Image URLs to attach to the variant.Image IDs to detach from the variant.
## Response
The change request's ID.ID of the product the change targets.One of `pending`, `confirmed`, `declined`, `canceled`.Staged operations, each with `id`, `action` (`VARIANT_UPDATE`), `details`, `ordering`, and `applied`.
```bash cURL theme={null}
curl -X POST 'http://localhost:9000/vendor/products/prod_01HXYZ/variants/variant_01HXYZ' \
-H 'Authorization: Bearer ' \
-H 'x-seller-id: ' \
-H 'Content-Type: application/json' \
-d '{"sku": "ACME-SHIRT-M-V2", "weight": 200}'
```
```ts JS Client theme={null}
const { product_change } =
await client.vendor.products.$id.variants.$variant_id.mutate({
$id: "prod_01HXYZ",
$variant_id: "variant_01HXYZ",
sku: "ACME-SHIRT-M-V2",
weight: 200,
})
```
```json 202 theme={null}
{
"product_change": {
"id": "prodch_01HXYZ",
"product_id": "prod_01HXYZ",
"status": "pending",
"actions": [
{
"id": "prodchact_01HXYZ",
"action": "VARIANT_UPDATE",
"details": { "variant_id": "variant_01HXYZ", "fields": { "sku": "ACME-SHIRT-M-V2" } },
"applied": false
}
]
}
}
```
# Create Seller
Source: https://docs.mercurjs.com/references/api/vendor/sellers/create-seller
POST /vendor/sellers
Register a new seller account with its first member.
Creates a seller account, optionally with an initial address, professional details, and payment details.
This is a public route: no `Authorization` or `x-seller-id` header is required. When called without an authenticated member, `member_email` is required so the owner member can be created.
## Body parameters
The seller's display name.
The seller's contact email.
The seller's default currency code (e.g. `usd`).
Unique handle for the seller's storefront URL.
The seller's contact phone number.
Email for the owner member, required when there is no authenticated member.
First name of the owner member.
Last name of the owner member.
Description of the seller's store.
The seller's address.
Address label.Company name.Contact first name.Contact last name.Street address.Apartment, suite, etc.City.Two-letter country code.Province or state.Postal code.Phone number.
The seller's business registration details.
Registered corporate name.Business registration number.Tax identification number.
The seller's bank account details.
Bank country code.Account holder name.Bank name.IBAN.BIC / SWIFT code.Routing number.Account number.
Custom key-value pairs.
Custom data passed to workflow hooks.
## Response
The seller's ID.The seller's name.The seller's handle.The seller's email.One of `open`, `pending_approval`, `suspended`, `terminated`.The seller's default currency.
```bash cURL theme={null}
curl -X POST 'http://localhost:9000/vendor/sellers' \
-H 'Content-Type: application/json' \
-d '{
"name": "Acme Store",
"email": "store@acme.com",
"currency_code": "usd",
"member_email": "owner@acme.com",
"first_name": "Jane",
"last_name": "Doe"
}'
```
```ts JS Client theme={null}
const { seller } = await client.vendor.sellers.mutate({
name: "Acme Store",
email: "store@acme.com",
currency_code: "usd",
member_email: "owner@acme.com",
first_name: "Jane",
last_name: "Doe",
})
```
```json 201 theme={null}
{
"seller": {
"id": "sel_01HXYZ",
"name": "Acme Store",
"handle": "acme-store",
"email": "store@acme.com",
"status": "pending_approval",
"currency_code": "usd"
}
}
```
# Delete Professional Details
Source: https://docs.mercurjs.com/references/api/vendor/sellers/delete-professional-details
DELETE /vendor/sellers/{id}/professional-details
Remove the seller's business registration details.
Deletes the seller's professional details and returns the full seller object.
## Path parameters
The seller's ID.
## Query parameters
Comma-separated fields to include in the response.
## Response
The seller's ID.`null` after deletion.
```bash cURL theme={null}
curl -X DELETE 'http://localhost:9000/vendor/sellers/sel_01HXYZ/professional-details' \
-H 'Authorization: Bearer ' \
-H 'x-seller-id: '
```
```ts JS Client theme={null}
const { seller } = await client.vendor.sellers.$id.professionalDetails.delete({
$id: "sel_01HXYZ",
})
```
```json 200 theme={null}
{
"seller": {
"id": "sel_01HXYZ",
"name": "Acme Store",
"professional_details": null
}
}
```
# Invite Seller Member
Source: https://docs.mercurjs.com/references/api/vendor/sellers/invite-seller-member
POST /vendor/sellers/{id}/members
Invite a new member to the seller's team.
Creates a member invite for the given email and role.
## Path parameters
The seller's ID.
## Body parameters
Email address to send the invite to.
The role assigned on acceptance. One of `role_seller_administration`, `role_seller_inventory_management`, `role_seller_order_management`, `role_seller_accounting`, `role_seller_support`.
## Response
The invite's ID.The invited email.The role granted on acceptance.Whether the invite has been accepted.Expiration timestamp.
```bash cURL theme={null}
curl -X POST 'http://localhost:9000/vendor/sellers/sel_01HXYZ/members' \
-H 'Authorization: Bearer ' \
-H 'x-seller-id: ' \
-H 'Content-Type: application/json' \
-d '{"email": "teammate@acme.com", "role_id": "role_seller_order_management"}'
```
```ts JS Client theme={null}
const { member_invite } = await client.vendor.sellers.$id.members.mutate({
$id: "sel_01HXYZ",
email: "teammate@acme.com",
role_id: "role_seller_order_management",
})
```
```json 201 theme={null}
{
"member_invite": {
"id": "meminv_01HXYZ",
"email": "teammate@acme.com",
"role_id": "role_seller_order_management",
"accepted": false,
"expires_at": "2026-01-22T10:00:00.000Z"
}
}
```
# List Member Invites
Source: https://docs.mercurjs.com/references/api/vendor/sellers/list-member-invites
GET /vendor/sellers/{id}/members/invites
List the seller's pending member invites.
Returns the seller's member invites that have not been accepted yet.
## Path parameters
The seller's ID.
## Query parameters
Maximum number of items to return.
Number of items to skip.
Field to sort by, prefixed with `-` for descending order.
Comma-separated fields to include in the response.
## Response
The invite's ID.The invited email.Always `false` in this list.The role granted on acceptance.Expiration timestamp.Creation timestamp.Total number of pending invites.Number of items skipped.Maximum number of items returned.
```bash cURL theme={null}
curl 'http://localhost:9000/vendor/sellers/sel_01HXYZ/members/invites' \
-H 'Authorization: Bearer ' \
-H 'x-seller-id: '
```
```ts JS Client theme={null}
const { member_invites } = await client.vendor.sellers.$id.members.invites.query({
$id: "sel_01HXYZ",
})
```
```json 200 theme={null}
{
"member_invites": [
{
"id": "meminv_01HXYZ",
"email": "teammate@acme.com",
"accepted": false,
"role_id": "role_seller_order_management",
"expires_at": "2026-01-22T10:00:00.000Z",
"created_at": "2026-01-15T10:00:00.000Z"
}
],
"count": 1,
"offset": 0,
"limit": 50
}
```
# List Seller Members
Source: https://docs.mercurjs.com/references/api/vendor/sellers/list-seller-members
GET /vendor/sellers/{id}/members
List the members of a seller account.
Returns the seller's team members with their roles.
## Path parameters
The seller's ID.
## Query parameters
Maximum number of items to return.
Number of items to skip.
Field to sort by, prefixed with `-` for descending order.
Comma-separated fields to include in the response.
## Response
The seller member's ID.Whether the member owns the seller account.The member's profile (name, email, etc.).The member's role for this seller.Creation timestamp.Total number of members.Number of items skipped.Maximum number of items returned.
```bash cURL theme={null}
curl 'http://localhost:9000/vendor/sellers/sel_01HXYZ/members' \
-H 'Authorization: Bearer ' \
-H 'x-seller-id: '
```
```ts JS Client theme={null}
const { seller_members } = await client.vendor.sellers.$id.members.query({
$id: "sel_01HXYZ",
})
```
```json 200 theme={null}
{
"seller_members": [
{
"id": "selmem_01HXYZ",
"is_owner": true,
"member": { "id": "mem_01HXYZ", "first_name": "Jane", "last_name": "Doe", "email": "owner@acme.com" },
"rbac_role": { "id": "role_seller_administration", "name": "Administration" },
"created_at": "2026-01-15T10:00:00.000Z"
}
],
"count": 1,
"offset": 0,
"limit": 50
}
```
# List Sellers
Source: https://docs.mercurjs.com/references/api/vendor/sellers/list-sellers
GET /vendor/sellers
List the seller accounts the authenticated member belongs to.
Returns the member's seller memberships (excluding terminated sellers), each with the related seller and role.
This route requires only the `Authorization` header (no `x-seller-id`) since it is used before a seller is selected.
## Query parameters
Maximum number of items to return.
Number of items to skip.
Field to sort by, prefixed with `-` for descending order.
Comma-separated fields to include in the response.
## Response
The seller member's ID.The related seller.The member's role for this seller.Total number of memberships.Number of items skipped.Maximum number of items returned.
```bash cURL theme={null}
curl 'http://localhost:9000/vendor/sellers' \
-H 'Authorization: Bearer '
```
```ts JS Client theme={null}
const { seller_members } = await client.vendor.sellers.query()
```
```json 200 theme={null}
{
"seller_members": [
{
"id": "selmem_01HXYZ",
"seller": { "id": "sel_01HXYZ", "name": "Acme Store", "status": "open" },
"rbac_role": { "id": "role_seller_administration", "name": "Administration" }
}
],
"count": 1,
"offset": 0,
"limit": 50
}
```
# Remove Seller Member
Source: https://docs.mercurjs.com/references/api/vendor/sellers/remove-seller-member
DELETE /vendor/sellers/{id}/members/{member_id}
Remove a member from the seller's team.
Removes the seller member and returns a deletion confirmation.
## Path parameters
The seller's ID.
The seller member's ID.
## Response
The removed seller member's ID.Always `seller_member`.Whether the member was removed.
```bash cURL theme={null}
curl -X DELETE 'http://localhost:9000/vendor/sellers/sel_01HXYZ/members/selmem_01HXYZ' \
-H 'Authorization: Bearer ' \
-H 'x-seller-id: '
```
```ts JS Client theme={null}
const { deleted } = await client.vendor.sellers.$id.members.$member_id.delete({
$id: "sel_01HXYZ",
$member_id: "selmem_01HXYZ",
})
```
```json 200 theme={null}
{
"id": "selmem_01HXYZ",
"object": "seller_member",
"deleted": true
}
```
# Retrieve Current Seller
Source: https://docs.mercurjs.com/references/api/vendor/sellers/retrieve-current-seller
GET /vendor/sellers/me
Retrieve the seller the request is scoped to.
Returns the seller identified by the current seller context (`x-seller-id` header or session), including address, payment details, and professional details.
## Query parameters
Comma-separated fields to include in the response.
## Response
The seller's ID.The seller's name.The seller's handle.The seller's email.The seller's phone.Store description.Logo URL.Banner URL.Website URL.Default currency.One of `open`, `pending_approval`, `suspended`, `terminated`.Approval timestamp.Rejection timestamp.Whether the seller has premium status.Start of a temporary store closure.End of a temporary store closure.Closure message shown to customers.The seller's address.The seller's bank details.The seller's business registration details.Custom key-value pairs.
```bash cURL theme={null}
curl 'http://localhost:9000/vendor/sellers/me' \
-H 'Authorization: Bearer ' \
-H 'x-seller-id: '
```
```ts JS Client theme={null}
const { seller } = await client.vendor.sellers.me.query()
```
```json 200 theme={null}
{
"seller": {
"id": "sel_01HXYZ",
"name": "Acme Store",
"handle": "acme-store",
"email": "store@acme.com",
"status": "open",
"currency_code": "usd",
"address": { "city": "New York", "country_code": "us" }
}
}
```
# Retrieve Current Seller Member
Source: https://docs.mercurjs.com/references/api/vendor/sellers/retrieve-current-seller-member
GET /vendor/sellers/{id}/members/me
Retrieve the authenticated member's membership in a seller.
Returns the seller member record linking the authenticated member to the given seller.
## Path parameters
The seller's ID.
## Query parameters
Comma-separated fields to include in the response.
## Response
The seller member's ID.Whether the member owns the seller account.The member's profile.The member's role for this seller.Creation timestamp.
```bash cURL theme={null}
curl 'http://localhost:9000/vendor/sellers/sel_01HXYZ/members/me' \
-H 'Authorization: Bearer ' \
-H 'x-seller-id: '
```
```ts JS Client theme={null}
const { seller_member } = await client.vendor.sellers.$id.members.me.query({
$id: "sel_01HXYZ",
})
```
```json 200 theme={null}
{
"seller_member": {
"id": "selmem_01HXYZ",
"is_owner": true,
"member": { "id": "mem_01HXYZ", "first_name": "Jane", "last_name": "Doe" },
"rbac_role": { "id": "role_seller_administration", "name": "Administration" },
"created_at": "2026-01-15T10:00:00.000Z"
}
}
```
# Select Seller
Source: https://docs.mercurjs.com/references/api/vendor/sellers/select-seller
POST /vendor/sellers/select
Set the active seller for the member's session.
Stores the chosen seller in the session after verifying the member belongs to it.
Requires only the `Authorization` header. Session-based clients get the seller stored server-side; token-based clients should send `x-seller-id` on subsequent requests instead.
## Body parameters
The ID of the seller to activate for this session.
## Response
Whether the seller was selected.
```bash cURL theme={null}
curl -X POST 'http://localhost:9000/vendor/sellers/select' \
-H 'Authorization: Bearer ' \
-H 'Content-Type: application/json' \
-d '{"seller_id": "sel_01HXYZ"}'
```
```ts JS Client theme={null}
const { success } = await client.vendor.sellers.select.mutate({
seller_id: "sel_01HXYZ",
})
```
```json 200 theme={null}
{ "success": true }
```
# Update Current Seller
Source: https://docs.mercurjs.com/references/api/vendor/sellers/update-current-seller
POST /vendor/sellers/me
Update the seller the request is scoped to.
Updates the current seller's profile and returns the full seller object.
## Body parameters
The seller's display name.
Unique handle for the seller's storefront URL.
The seller's contact email.
The seller's contact phone number.
Description of the seller's store.
URL of the seller's logo.
URL of the seller's banner image.
The seller's website URL.
Start date of a temporary store closure (ISO 8601).
End date of a temporary store closure (ISO 8601).
Message shown to customers while the store is closed.
Custom key-value pairs.
Custom data passed to workflow hooks.
## Query parameters
Comma-separated fields to include in the response.
## Response
The seller's ID.The seller's name.The seller's handle.One of `open`, `pending_approval`, `suspended`, `terminated`.
```bash cURL theme={null}
curl -X POST 'http://localhost:9000/vendor/sellers/me' \
-H 'Authorization: Bearer ' \
-H 'x-seller-id: ' \
-H 'Content-Type: application/json' \
-d '{"description": "Handmade goods from Brooklyn."}'
```
```ts JS Client theme={null}
const { seller } = await client.vendor.sellers.me.mutate({
description: "Handmade goods from Brooklyn.",
})
```
```json 200 theme={null}
{
"seller": {
"id": "sel_01HXYZ",
"name": "Acme Store",
"handle": "acme-store",
"description": "Handmade goods from Brooklyn.",
"status": "open"
}
}
```
# Update Member Role
Source: https://docs.mercurjs.com/references/api/vendor/sellers/update-member-role
POST /vendor/sellers/{id}/members/{member_id}
Change the role of a seller team member.
Updates the role assigned to a seller member.
## Path parameters
The seller's ID.
The seller member's ID.
## Body parameters
The new role. One of `role_seller_administration`, `role_seller_inventory_management`, `role_seller_order_management`, `role_seller_accounting`, `role_seller_support`.
## Response
An empty object on success.
```bash cURL theme={null}
curl -X POST 'http://localhost:9000/vendor/sellers/sel_01HXYZ/members/selmem_01HXYZ' \
-H 'Authorization: Bearer ' \
-H 'x-seller-id: ' \
-H 'Content-Type: application/json' \
-d '{"role_id": "role_seller_accounting"}'
```
```ts JS Client theme={null}
await client.vendor.sellers.$id.members.$member_id.mutate({
$id: "sel_01HXYZ",
$member_id: "selmem_01HXYZ",
role_id: "role_seller_accounting",
})
```
```json 200 theme={null}
{}
```
# Upsert Payment Details
Source: https://docs.mercurjs.com/references/api/vendor/sellers/upsert-payment-details
POST /vendor/sellers/{id}/payment-details
Create or update the seller's bank account details.
Upserts the seller's payment details and returns the full seller object.
## Path parameters
The seller's ID.
## Body parameters
Bank country code.Account holder name.Bank name.IBAN.BIC / SWIFT code.Routing number.Account number.Custom data passed to workflow hooks.
## Query parameters
Comma-separated fields to include in the response.
## Response
The seller's ID.The updated payment details.
```bash cURL theme={null}
curl -X POST 'http://localhost:9000/vendor/sellers/sel_01HXYZ/payment-details' \
-H 'Authorization: Bearer ' \
-H 'x-seller-id: ' \
-H 'Content-Type: application/json' \
-d '{"holder_name": "Acme Inc.", "iban": "DE89370400440532013000", "bic": "COBADEFFXXX"}'
```
```ts JS Client theme={null}
const { seller } = await client.vendor.sellers.$id.paymentDetails.mutate({
$id: "sel_01HXYZ",
holder_name: "Acme Inc.",
iban: "DE89370400440532013000",
bic: "COBADEFFXXX",
})
```
```json 200 theme={null}
{
"seller": {
"id": "sel_01HXYZ",
"name": "Acme Store",
"payment_details": {
"holder_name": "Acme Inc.",
"iban": "DE89370400440532013000",
"bic": "COBADEFFXXX"
}
}
}
```
# Upsert Professional Details
Source: https://docs.mercurjs.com/references/api/vendor/sellers/upsert-professional-details
POST /vendor/sellers/{id}/professional-details
Create or update the seller's business registration details.
Upserts the seller's professional details and returns the full seller object.
## Path parameters
The seller's ID.
## Body parameters
Registered corporate name.Business registration number.Tax identification number.Custom data passed to workflow hooks.
## Query parameters
Comma-separated fields to include in the response.
## Response
The seller's ID.The updated professional details.
```bash cURL theme={null}
curl -X POST 'http://localhost:9000/vendor/sellers/sel_01HXYZ/professional-details' \
-H 'Authorization: Bearer ' \
-H 'x-seller-id: ' \
-H 'Content-Type: application/json' \
-d '{"corporate_name": "Acme Inc.", "registration_number": "HRB 12345", "tax_id": "DE123456789"}'
```
```ts JS Client theme={null}
const { seller } = await client.vendor.sellers.$id.professionalDetails.mutate({
$id: "sel_01HXYZ",
corporate_name: "Acme Inc.",
registration_number: "HRB 12345",
tax_id: "DE123456789",
})
```
```json 200 theme={null}
{
"seller": {
"id": "sel_01HXYZ",
"name": "Acme Store",
"professional_details": {
"corporate_name": "Acme Inc.",
"registration_number": "HRB 12345",
"tax_id": "DE123456789"
}
}
}
```
# Upsert Seller Address
Source: https://docs.mercurjs.com/references/api/vendor/sellers/upsert-seller-address
POST /vendor/sellers/{id}/address
Create or update the seller's address.
Upserts the seller's address and returns the full seller object.
## Path parameters
The seller's ID.
## Body parameters
Address label.Company name.Contact first name.Contact last name.Street address.Apartment, suite, etc.City.Two-letter country code.Province or state.Postal code.Phone number.Custom key-value pairs.Custom data passed to workflow hooks.
## Query parameters
Comma-separated fields to include in the response.
## Response
The seller's ID.The updated address.
```bash cURL theme={null}
curl -X POST 'http://localhost:9000/vendor/sellers/sel_01HXYZ/address' \
-H 'Authorization: Bearer ' \
-H 'x-seller-id: ' \
-H 'Content-Type: application/json' \
-d '{"address_1": "123 Main St", "city": "New York", "country_code": "us", "postal_code": "10001"}'
```
```ts JS Client theme={null}
const { seller } = await client.vendor.sellers.$id.address.mutate({
$id: "sel_01HXYZ",
address_1: "123 Main St",
city: "New York",
country_code: "us",
postal_code: "10001",
})
```
```json 200 theme={null}
{
"seller": {
"id": "sel_01HXYZ",
"name": "Acme Store",
"address": {
"address_1": "123 Main St",
"city": "New York",
"country_code": "us",
"postal_code": "10001"
}
}
}
```
# Configuration
Source: https://docs.mercurjs.com/references/configuration
withMercur() options, environment variables, and provider configuration.
Mercur is configured through the standard Medusa config file.
`withMercur()` wraps `defineConfig()` and wires the marketplace layer in. You pass it the same shape you would pass to Medusa, plus one Mercur-specific field.
## withMercur()
```ts medusa-config.ts theme={null}
import { loadEnv } from "@medusajs/framework/utils"
import { withMercur } from "@mercurjs/core"
loadEnv(process.env.NODE_ENV || "development", process.cwd())
module.exports = withMercur({
projectConfig: {
databaseUrl: process.env.DATABASE_URL,
redisUrl: process.env.REDIS_URL,
http: {
storeCors: process.env.STORE_CORS!,
adminCors: process.env.ADMIN_CORS!,
vendorCors: process.env.VENDOR_CORS!, // Mercur-specific
authCors: process.env.AUTH_CORS!,
jwtSecret: process.env.JWT_SECRET,
cookieSecret: process.env.COOKIE_SECRET,
},
},
featureFlags: {
seller_registration: true,
},
modules: [
// your modules and providers
],
})
```
The option surface is Medusa's `InputConfigWithArrayModules` extended with:
| Option | Type | Purpose |
| ------------------------------- | -------- | ------------------------------------------------------------------------------ |
| `projectConfig.http.vendorCors` | `string` | CORS origins for the Vendor API (`/vendor/*`). The only Mercur-specific option |
### What withMercur applies
| Behavior | Detail |
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| Registers the core plugin | Appends `@mercurjs/core` to `plugins` unless already present |
| Registers RBAC | Appends the `@medusajs/medusa/rbac` module and forces `featureFlags.rbac = true`, so vendor role scoping works out of the box |
| Disables the Medusa admin | `admin.disable` defaults to `true`, since Mercur ships its own dashboards |
| Adjusts core middlewares | Replaces the Medusa middlewares that Mercur overrides (e.g. collections with media) |
Everything else you pass is forwarded to `defineConfig()` unchanged, so any valid Medusa configuration stays valid here.
## Environment variables
The starter (`apps/api` and projects created with `create-mercur-app`) reads:
| Variable | Purpose | Default |
| ------------------- | ----------------------------------------------------- | ----------------------------------------------------------------- |
| `DATABASE_URL` | Postgres connection string | Required |
| `REDIS_URL` | Redis for cache, event bus, workflow engine, locking | `redis://localhost:6379` |
| `STORE_CORS` | Storefront CORS origins | Required |
| `ADMIN_CORS` | Admin panel CORS origins | Required |
| `VENDOR_CORS` | Vendor panel CORS origins | Required |
| `AUTH_CORS` | Auth endpoint CORS origins | Required |
| `JWT_SECRET` | JWT signing secret | `supersecret` (change in production) |
| `COOKIE_SECRET` | Cookie signing secret | `supersecret` (change in production) |
| `FILE_BACKEND_URL` | Public origin baked into uploaded file URLs | `http://localhost:9000/static` |
| `MERCUR_VENDOR_URL` | Vendor dashboard base URL used in member invite links | `""`; can also be set via the seller module's `vendor_url` option |
| `NODE_ENV` | Environment selection | `development` |
## Dashboard modules
The admin and vendor dashboards are served by two UI modules:
```ts theme={null}
modules: [
{
resolve: "@mercurjs/core/modules/admin-ui",
options: { appDir: "./admin", path: "/dashboard" },
},
{
resolve: "@mercurjs/core/modules/vendor-ui",
options: { appDir: "./vendor", path: "/seller" },
},
]
```
| Option | Type | Purpose |
| ----------------------------------------- | ------------------- | ---------------------------------------------------- |
| `disable` | `boolean` | Skip serving this dashboard from the API process |
| `path` | `string` | Mount path (e.g. `/dashboard`, `/seller`) |
| `appDir` | `string` | Directory of the dashboard Vite app |
| `viteDevServerPort` / `viteDevServerHost` | `number` / `string` | Dev-mode proxy target (host defaults to `localhost`) |
## Stripe Connect payout provider
Register `@mercurjs/payout-stripe-connect` as a provider of the payout module:
```ts theme={null}
{
resolve: "@mercurjs/core/modules/payout",
options: {
providers: [
{
resolve: "@mercurjs/payout-stripe-connect",
id: "stripe-connect",
options: {
apiKey: process.env.STRIPE_SECRET_KEY,
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
},
},
],
},
}
```
| Option | Type | Default | Purpose |
| --------------------------------------------- | ---------- | -------- | ----------------------------------------------------------------- |
| `apiKey` | `string` | Required | Stripe secret key |
| `webhookSecret` | `string` | Required | Verifies incoming Stripe webhook signatures |
| `accountValidation.detailsSubmitted` | `boolean` | `true` | Require onboarding details submitted before the account is active |
| `accountValidation.chargesEnabled` | `boolean` | `true` | Require charges enabled |
| `accountValidation.payoutsEnabled` | `boolean` | `true` | Require payouts enabled |
| `accountValidation.noOutstandingRequirements` | `boolean` | `true` | Treat pending Stripe requirements as `restricted` |
| `accountValidation.requiredCapabilities` | `string[]` | `[]` | Stripe capabilities that must be `active` |
The payout module itself also accepts scheduling options (`authorizationWindowMs`, `sellerActionWindowMs`, `captureSafetyBufferMs`, `requiredFulfillmentStatus`). See the [Payout module](/platform/payout/overview).
## Next steps
# Reference
Source: https://docs.mercurjs.com/references/overview
Technical reference for Mercur's HTTP APIs, panel extensions, and configuration.
This section is the technical contract of the Mercur core plugin. The
[Learn](/learn/introduction) tab explains concepts and the
[Platform](/platform/store/overview) tab documents each domain's data models,
workflows, service, and events. These pages cover the HTTP APIs, the panel
extension helpers, and configuration.
## HTTP API
Mercur extends the Medusa server with three route surfaces. Start with the
conventions page: authentication, seller scoping, and pagination work the same
way across all of them.
Authentication, seller scoping, pagination, field selection, and webhooks.
Operator routes under /admin/\*.
Seller-scoped routes under /vendor/\*.
Storefront routes under /store/\*.
## Panel extensions
File-based helpers for extending the Admin and Vendor panels without forking.
Typed targets, persistence, and how the helpers fit together.
defineWidgetConfig: zones, config, and component props.
defineCustomFieldsConfig: forms, displays, and list columns.
defineNavigationConfig and createFormHelper.
## Configuration
withMercur(), environment variables, and provider options.
Data models, workflows, service, and events for each domain.
# Create a new page
Source: https://docs.mercurjs.com/references/panel-extensions/create-page
Add a page to the admin or vendor panel with file-based routing, then register it in the sidebar.
A page is a React component mounted at a route. Pages are file-based. You drop a
`page.tsx` under a panel's `src/routes/` folder and the SDK registers it at build
time, the same way [Next.js](https://nextjs.org) and [Remix](https://remix.run)
map folders to routes.
This guide walks through creating a page, then documents the routing conventions,
the sidebar config, and the `defineNavigationConfig` and `createFormHelper`
helpers.
## Create a page
Create `page.tsx` in a new folder under `src/routes/`. The folder path becomes
the URL, so this file mounts at `/erp-sync`.
```tsx apps/vendor/src/routes/erp-sync/page.tsx theme={null}
const ErpSyncPage = () => {
return
ERP sync
}
export default ErpSyncPage
```
The default export is the only required part. Only files named `page` register
as routes, so you can co-locate a `loader.ts` or components in the same folder.
A page has no sidebar entry until it exports a `config` with a `label`. The
config is a plain object.
```tsx apps/vendor/src/routes/erp-sync/page.tsx theme={null}
import { ArrowsPointingOut } from "@medusajs/icons"
export const config = {
label: "ERP sync",
icon: ArrowsPointingOut,
}
```
Start the panel and open the route. The page renders and its sidebar entry
appears.
```bash Terminal theme={null}
bun run dev
```
The vendor panel runs on `http://localhost:7001` and the admin panel on
`http://localhost:7000`.
## Route paths
Folder and file names map to path segments. Wrap a segment in brackets or
parentheses to make it dynamic or optional.
| Folder segment | Route path | Description |
| -------------- | ------------ | ---------------------------------------------------- |
| `products` | `/products` | Static segment. |
| `[id]` | `/:id` | Dynamic parameter. |
| `[[id]]` | `/:id?` | Optional dynamic parameter. |
| `[*]` | `/*` | Splat. Matches the rest of the path. |
| `[[*]]` | `/*?` | Optional splat. |
| `(preview)` | `/preview?` | Optional static segment. |
| `@modal` | nested route | Parallel segment. Renders inside its parent's route. |
Read a dynamic segment with React Router's `useParams()`. A file at
`src/routes/orders/[id]/page.tsx` mounts at `/orders/:id`.
## Where the page mounts
The route path and the page's `config.public` flag decide which layout wraps the
page.
| Layout | When | Example route |
| -------- | ----------------------------------------------- | ------------------------------------------------- |
| Main | Default. Any protected route. | `src/routes/erp-sync/page.tsx` |
| Settings | Route path starts with `/settings/`. | `src/routes/settings/erp/page.tsx` |
| Public | `config.public` is `true`. Renders before auth. | `src/routes/status/page.tsx` with `config.public` |
Main and settings routes render inside the authenticated shell. Public routes
render on their own, so use them for pages a signed-out user must reach.
## Sidebar config
The `config` object controls the page's sidebar entry.
| Field | Type | Description |
| --------------- | -------------------------- | --------------------------------------------------------------- |
| `label` | `string` | Sidebar label. A page with no `label` has no sidebar entry. |
| `icon` | `ComponentType` (optional) | Sidebar icon, from `@medusajs/icons`. |
| `rank` | `number` (optional) | Order among sibling items. Lower ranks first. |
| `nested` | `string` (optional) | Parent item id to nest this entry under. |
| `translationNs` | `string` (optional) | i18n namespace used to resolve `label` as a translation key. |
| `public` | `boolean` (optional) | Render the route before authentication under the public layout. |
A page can exist without a sidebar entry. Omit `label` when the route is reached
from a link or a widget rather than the sidebar.
## Load data for a page
Export a `loader` to fetch data before the component renders, and a `handle` to
attach route metadata such as a breadcrumb. Both are React Router route options,
picked up as named exports.
```tsx apps/vendor/src/routes/erp-sync/page.tsx theme={null}
import type { LoaderFunction } from "react-router-dom"
export const loader: LoaderFunction = async () => {
return { syncedAt: new Date().toISOString() }
}
export const handle = {
breadcrumb: () => "ERP sync",
}
```
Read the loader's result with `useLoaderData()` inside the component.
## Reshape built-in navigation
Use `defineNavigationConfig` to reorder, hide, relabel, or re-parent built-in
sidebar items. It lives in a single host-owned file, `src/_navigation.ts`. Blocks
cannot contribute navigation overrides.
```ts apps/vendor/src/_navigation.ts theme={null}
import { defineNavigationConfig } from "@mercurjs/dashboard-sdk"
export default defineNavigationConfig({
items: [
{ id: "orders", rank: 0 },
{ id: "price-lists", hidden: true },
{ id: "payouts", label: "settlements" },
{ id: "categories", nested: null, rank: 1 },
{ id: "campaigns", nested: "orders" },
],
})
```
Each entry is a `NavItemOverride`.
| Field | Type | Description |
| -------- | -------------------------------- | --------------------------------------------------------------------------------- |
| `id` | `NavItemId` | Built-in item to override, top-level or nested. Typed against the panel registry. |
| `rank` | `number` (optional) | Order within the item's parent, or among top-level items. |
| `hidden` | `boolean` (optional) | Remove from the sidebar. The route may still be reachable directly. |
| `label` | `string` (optional) | i18n key or literal replacing the item's label. |
| `icon` | `ComponentType` (optional) | Icon component replacing the item's icon, from `@medusajs/icons`. |
| `nested` | `NavParentId \| null` (optional) | Re-parent under a built-in parent id. `null` promotes a nested item to top level. |
Navigation overrides reshape existing items only. To add a new item, register a
page with a `config.label` as shown above.
## Type a page's forms
`createFormHelper()` returns Medusa's Zod surface for describing field
validation and value types. Import it from `@mercurjs/dashboard-shared`.
```ts theme={null}
import { createFormHelper } from "@mercurjs/dashboard-shared"
const form = createFormHelper()
form.define({ validation, defaultValue, label, description, placeholder, component })
form.string() / form.number() / form.boolean() / form.date()
form.array() / form.object() / form.null() / form.nullable() / form.coerce
```
The generated registry types the target: which zone, tab, and field ids exist.
The Zod `validation` types the value. See [Custom Fields](/references/panel-extensions/custom-fields)
for where `form.define` fields are attached.
## Next steps
Render a component in a slot on an existing page.
Add fields, section rows, and list columns to a built-in model.
# Custom Fields
Source: https://docs.mercurjs.com/references/panel-extensions/custom-fields
Add fields, detail rows, section actions, and list columns to a built-in model.
Custom fields extend a built-in model's forms, detail sections, and list table
without forking the page. You add a field to the create and edit forms, a row to
the detail view, an action to a section menu, or a column to the list.
You write one file per model under `src/custom-fields/` and default-export a
`defineCustomFieldsConfig`. The config targets a model, optionally fetches linked
module data alongside it, and describes what to add to each surface.
## Add a custom field
Create a file under `src/custom-fields/` and default-export
`defineCustomFieldsConfig` with the model you want to extend. Start with
`product`.
```tsx apps/vendor/src/custom-fields/product.tsx theme={null}
import { defineCustomFieldsConfig } from "@mercurjs/dashboard-sdk"
export default defineCustomFieldsConfig({
model: "product",
})
```
Import `createFormHelper` from `@mercurjs/dashboard-shared` and turn a Zod
schema into an input type and validation. The Zod schema drives both the
default input and the validation.
```tsx apps/vendor/src/custom-fields/product.tsx theme={null}
import { createFormHelper } from "@mercurjs/dashboard-shared"
type ProductWithMeta = { metadata?: Record }
const form = createFormHelper()
```
Add the field to a built-in form zone and tab. This injects an `ERP ID` field
into the product edit form.
```tsx apps/vendor/src/custom-fields/product.tsx theme={null}
export default defineCustomFieldsConfig({
model: "product",
forms: [
{
zone: "edit",
tab: "general",
fields: {
erp_id: form.define({
validation: form.string().nullish(),
defaultValue: (data) => (data?.metadata?.erp_id as string) ?? "",
label: "ERP ID",
placeholder: "ERP-000",
}),
},
},
],
})
```
Start the panel and open the product edit form. The field renders in its tab.
```bash Terminal theme={null}
bun run dev
```
For `product`, values submit under `additional_data` and persist onto the
product's `metadata`. See [Persistence](#persistence) for other models.
## Configuration
`defineCustomFieldsConfig` takes one object.
| Field | Type | Description |
| ---------- | ---------------------- | --------------------------------------------------------------------------------------------------- |
| `model` | `CustomFieldModel` | Target model. Start with `"product"`. Typed against `CustomFieldsRegistry`. |
| `link` | `string \| string[]` | Module link or links fetched alongside the entity. Their data is available to columns and displays. |
| `forms` | `CustomFormEntry[]` | Fields injected into built-in create, edit, and onboarding forms. |
| `displays` | `CustomDisplayEntry[]` | Field replace, remove, or add, plus `ActionMenu` actions on detail sections. |
| `list` | `CustomListExtension` | Columns, bulk actions, filters, and view defaults on the model's list table. |
## Add fields to a form
Inject fields into a built-in create, edit, or onboarding form.
```tsx theme={null}
forms: [
{
zone: "edit", // "create" | "edit" | "organize" | "attributes" | "onboarding"
tab: "general", // TabbedForm tab id, or wizard step id for zone: "onboarding"
fields: {
erp_id: form.define({
validation: form.string().nullish(),
defaultValue: (data) => (data?.metadata?.erp_id as string) ?? "",
label: "ERP ID",
description: "External system identifier",
placeholder: "ERP-000",
component: MyErpInput,
}),
},
},
]
```
Each field is a `CustomFormField`.
| Field | Type | Description |
| -------------- | -------------------------------- | -------------------------------------------------------------- |
| `validation` | Zod schema | Drives both the default input type and validation. |
| `defaultValue` | `unknown \| ((data) => unknown)` | Static value or a resolver from the loaded entity. |
| `label` | `string` (optional) | Field label. |
| `description` | `string` (optional) | Help text below the input. |
| `placeholder` | `string` (optional) | Input placeholder. |
| `component` | `ComponentType` (optional) | Custom render. Falls back to a default input for the Zod type. |
**A form-field `component` receives no props.** It renders as ``
inside the field's `additional_data.` React Hook Form context. Read and
write the value with `useFormContext()` or `useController()`, and render through
the `Form.Field` and `Form.Item` chain. Do not use a raw `Controller`. Values
live in form state under `additional_data`. For `product`, custom fields persist
onto the product's `metadata`.
## Extend detail sections
Add, replace, or remove fields on a detail section, and add actions to its
`ActionMenu`.
```tsx theme={null}
displays: [
{
zone: "general", // an existing detail section id
fields: [
{ id: "erp_id", component: ErpRow }, // ADD (unknown id -> new row)
{ id: "status", component: BrandedStatusBadge }, // REPLACE a built-in field
{ id: "created_by", component: null }, // REMOVE a built-in field
],
actions: [
{ rank: 0, component: SyncErpAction }, // add to the section's ActionMenu
],
},
]
```
`fields[]` is `CustomDisplayField`.
| Field | Type | Description |
| ----------- | ---------------------------------- | ---------------------------------------------------------------------------------------------- |
| `id` | `displayFieldIds \| (string & {})` | Built-in field id, which autocompletes, to replace or remove. Any other string adds a new row. |
| `component` | `ComponentType<{ data? }> \| null` | Render component, or `null` to remove a built-in field. |
A display `component` receives the loaded detail entity as `data`, including any
`link`ed module data. If the config declares `link: "brand"`, read it off
`data.brand`.
```tsx theme={null}
const BrandRow = ({ data }: { data?: { brand?: { name: string } } }) => (
{data?.brand?.name}
)
```
`actions[]` is `SectionAction`, the same shape as list `bulkActions`.
| Field | Type | Description |
| ----------- | -------------------------- | --------------------------------------------------------- |
| `rank` | `number` (optional) | Position within the section's `ActionMenu`. |
| `component` | `ComponentType<{ data? }>` | Owns its own label, icon, group placement, and `onClick`. |
## Extend the list table
Override or add columns, register bulk actions and filters, and set view defaults
on the model's list table.
```tsx theme={null}
list: {
columns: [
{ id: "title", component: ({ value }) => {value} }, // override a cell
{ id: "brand_name", header: "Brand", component: ({ row }) => row.brand?.name }, // add from link
],
bulkActions: [{ rank: 0, component: ArchiveBulkAction }],
filters: [/* add or remove list filters */],
viewDefaults: {
columnVisibility: { created_at: false },
columnOrder: ["title", "sku", "erp_id"],
},
}
```
`columns[]` is `CustomColumn`.
| Field | Type | Description |
| ----------- | -------------------------------------------- | -------------------------------------------------------------------- |
| `id` | `string` | Column id. Matches a built-in column to override, or adds a new one. |
| `header` | `string` (optional) | Column header text, for added columns. |
| `component` | `ComponentType<{ row?, value? }>` (optional) | Cell renderer. Receives the full `row` and the cell `value`. |
**Bulk-action rendering is deferred in the MVP.** `bulkActions` are accepted and
surfaced by the config, but not yet mounted into the list toolbar.
**The vendor product list is field-constrained.** It must use the curated fields
from `useProductTableQuery`. The SDK merges `link` fetches with the `+` and `-`
convention, never bare fields, or the list returns a 500. You never hand-write
the field list. The `link` declaration drives it.
## Fetch linked module data
Declare `link` to fetch a module link alongside the entity. Its data rides on the
`data` passed to displays and on the `row` passed to columns.
```tsx theme={null}
export default defineCustomFieldsConfig({
model: "product",
link: "brand", // string | string[]
displays: [/* read data.brand here */],
list: {/* read row.brand here */},
})
```
## Persistence
**The MVP is a UI surface only.** Custom fields render, validate, and display
through the built-in forms, sections, and tables. There is no generic core-side
write path. For `product`, values submit under `additional_data` and persist
onto `metadata`. To store data for other models, wire your own route or
workflow, or use the backend
[Custom Fields module](/rc/resources/customization/custom-fields).
## Next steps
Render a component in a slot on an existing page.
Add a route with file-based routing and register it in the sidebar.
# Panel extensions
Source: https://docs.mercurjs.com/references/panel-extensions/overview
Customize the admin and vendor panels with pages, widgets, custom fields, and navigation, without forking them.
Panel extensions let you customize the Admin and Vendor panels without forking
them. You drop a file into a panel's `src/` folder and the SDK registers it at
build time. There is no manifest to maintain and no core code to patch.
## What you can add
Each extension is a file in a known location. The file's folder decides what it
does.
* **Pages:** add a route and a page with a `page.tsx` under `src/routes/`.
* **Widgets:** render a component in a slot on a built-in page with `defineWidgetConfig`.
* **Custom fields:** add fields, rows, and columns to a built-in model with `defineCustomFieldsConfig`.
* **Navigation:** reorder, hide, or relabel sidebar items with `defineNavigationConfig`.
## File conventions
An extension is discovered by its location under a panel's `src/`. The folder is
the surface, so there is no registration step beyond creating the file.
| Path | Adds | Documented in |
| ------------------------ | ---------------------------------------- | ------------------------------------------------------------- |
| `src/routes/**/page.tsx` | A page and route | [Create a new page](/references/panel-extensions/create-page) |
| `src/widgets/**` | A widget on a zone | [Widgets](/references/panel-extensions/widgets) |
| `src/custom-fields/**` | Model form, display, and list extensions | [Custom Fields](/references/panel-extensions/custom-fields) |
| `src/_navigation.ts` | Sidebar overrides | [Create a new page](/references/panel-extensions/create-page) |
| `src/i18n/index.ts` | Translation resources | Default-exports the i18n resource map |
Widgets and custom fields crawl subfolders, so group related files however you
like. Navigation is a single host-owned file, not a folder crawl.
## Separate apps, no surface field
Admin (`@mercurjs/admin`, port 7000) and vendor (`@mercurjs/vendor`, port 7001)
are separate Vite apps. A file under a panel's `src/` targets that panel, so the
folder you author in is the surface. There is no `surface` field to set. The
helpers are the same in both. Import the config helpers from
`@mercurjs/dashboard-sdk` and `createFormHelper` from `@mercurjs/dashboard-shared`.
## Typed targets
Zone ids, nav item ids, models, and built-in field ids are typed per panel from a
generated `extension-targets.d.ts`. Reference it once per host app so every
extension file type-checks with no per-file import.
```typescript apps/vendor/src/extension-targets.d.ts theme={null}
///
```
```typescript apps/admin-test/src/extension-targets.d.ts theme={null}
///
```
A wrong `zone`, `model`, or nav `id` fails `tsc` (`bun run lint`) rather than
silently doing nothing at runtime.
## Persistence
**The MVP is a UI surface only.** Custom fields render, validate, and display
through the built-in forms, sections, and tables. There is no generic core-side
write path. For `product`, values submit under `additional_data` and persist
onto `metadata`. To store data for other models, wire your own route or
workflow, or use the backend
[Custom Fields module](/rc/resources/customization/custom-fields).
## Explore the extensions
Render a component in a named zone with `defineWidgetConfig`.
Add form fields, detail rows, and list columns with `defineCustomFieldsConfig`.
Add a route with file-based routing, then register it in the sidebar.
## Related guides
Step-by-step build with `defineCustomFieldsConfig`.
Inject a component into a zone.
Reorder and hide sidebar items.
The backend storage layer.
# Widgets
Source: https://docs.mercurjs.com/references/panel-extensions/widgets
Render a React component in a fixed slot on a built-in admin or vendor page.
A widget is a React component that renders in a named slot on a built-in page.
Use a widget to show extra information or an action next to the data a page
already displays, such as a payout summary on the order detail page.
You add a widget by dropping a file into a panel's `src/widgets/` folder. The SDK
finds it at build time. There is no manifest to edit and no route to register.
## Create a widget
Create a file anywhere under `src/widgets/` in the panel you want to extend.
The file name is up to you.
```tsx apps/vendor/src/widgets/product-list-banner.tsx theme={null}
import { defineWidgetConfig } from "@mercurjs/dashboard-sdk"
import { Container, Text } from "@medusajs/ui"
const ProductListBanner = () => {
return (
Welcome to your catalog.
)
}
export default ProductListBanner
```
Export a `config` that names the zone to render in. The last segment of the
zone id, `before` or `after`, sets the placement.
```tsx apps/vendor/src/widgets/product-list-banner.tsx theme={null}
export const config = defineWidgetConfig({
zone: "product.list.before",
})
```
Zone ids are typed. A zone that does not exist fails `tsc` (`bun run lint`),
so you cannot target a page that has no slot. See [Available zones](#vendor-zones)
for the full list.
Start the panel and open the page you targeted. The widget renders in its
zone.
```bash Terminal theme={null}
bun run dev
```
The vendor panel runs on `http://localhost:7001` and the admin panel on
`http://localhost:7000`.
## Configuration
`defineWidgetConfig` takes one object.
| Field | Type | Description |
| ------ | -------------------------------- | ----------------------------------------------------------------------------------------- |
| `zone` | `WidgetZoneId \| WidgetZoneId[]` | The zone or zones to render in. Multiple widgets in one zone stack in registration order. |
| `id` | `string` (optional) | A stable id. Derived from the file path at build time when omitted. |
## Component props
The widget component receives a single prop.
| Prop | Type | Description |
| ------ | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| `data` | `unknown` | The zone's contextual entity, such as the loaded product on a `product.detail.*` zone. Undefined on list and public zones that have no single entity. |
```tsx theme={null}
import { Container } from "@medusajs/ui"
import type { HttpTypes } from "@medusajs/types"
const ProductDetailNote = ({ data }: { data?: HttpTypes.AdminProduct }) => (
{data?.title}
)
```
The public `login.logo`, `login.before`, and `login.after` zones render before
authentication and receive no `data`.
## Zone ids
A zone id reads `..`.
* **Domain** is the page family, such as `product`, `orders`, or `customers`.
* **View** is the surface within that family: `list` for a list page, or
`detail.main` and `detail.side` for the two columns of a detail page.
* **Placement** is `before` or `after`. It is the last segment and decides
whether the widget renders above or below the target.
Each surface in the tables below expands to two zone ids. The `product` detail
main column, for example, gives you `product.detail.main.before` and
`product.detail.main.after`.
## Vendor zones
Widgets in `@mercurjs/vendor` (`apps/vendor`) can target these surfaces. Each cell
expands to a `.before` and an `.after` zone.
| Domain | List | Detail main | Detail side | Other |
| ------------------- | :--: | :---------: | :---------: | ---------------------- |
| `campaigns` | ✓ | ✓ | ✓ | |
| `categories` | ✓ | ✓ | ✓ | |
| `collections` | ✓ | ✓ | | |
| `customer-groups` | ✓ | ✓ | | |
| `customers` | ✓ | ✓ | ✓ | |
| `inventory` | ✓ | ✓ | ✓ | |
| `locations` | ✓ | ✓ | ✓ | |
| `offer-variants` | | ✓ | ✓ | |
| `offers` | ✓ | ✓ | ✓ | |
| `orders` | ✓ | ✓ | ✓ | `detail.summary` |
| `payouts` | ✓ | ✓ | | |
| `price-lists` | ✓ | ✓ | ✓ | |
| `product` | ✓ | ✓ | ✓ | |
| `product-tags` | ✓ | ✓ | | |
| `product-types` | ✓ | ✓ | | |
| `product-variants` | | ✓ | | |
| `profile` | | ✓ | | |
| `promotions` | ✓ | ✓ | ✓ | |
| `regions` | ✓ | ✓ | | |
| `reservations` | ✓ | ✓ | ✓ | |
| `return-reasons` | ✓ | | | |
| `shipping-profiles` | ✓ | ✓ | | |
| `tax-regions` | ✓ | ✓ | | `province.detail.main` |
| `team` | ✓ | | | |
### Vendor public and setup zones
These sit outside the list and detail shape. The `login.*` zones render before
authentication and receive no `data`.
| Zone base | Ids | Renders |
| -------------- | ------------------------------------------- | ---------------------------------- |
| `login.logo` | `login.logo.before`, `login.logo.after` | Around the logo on the login page. |
| `login.before` | `login.before.before`, `login.before.after` | Before the login form. |
| `login.after` | `login.after.before`, `login.after.after` | After the login form. |
| `seller.setup` | `seller.setup.before`, `seller.setup.after` | Around the store setup step. |
## Admin zones
Widgets in `@mercurjs/admin` (`apps/admin-test`) can target these surfaces. Each
cell expands to a `.before` and an `.after` zone.
| Domain | List | Detail main | Detail side | Other |
| ----------------------- | :--: | :---------: | :---------: | ---------------------- |
| `api-keys` | ✓ | ✓ | | |
| `attributes` | ✓ | ✓ | | |
| `campaigns` | ✓ | ✓ | ✓ | |
| `categories` | ✓ | ✓ | ✓ | |
| `collections` | ✓ | ✓ | | |
| `commissions` | ✓ | ✓ | | |
| `customer-groups` | ✓ | ✓ | | |
| `customers` | ✓ | ✓ | ✓ | |
| `inventory` | ✓ | ✓ | ✓ | |
| `locations` | ✓ | ✓ | ✓ | |
| `marketplace` | | ✓ | | |
| `offer-variants` | | ✓ | ✓ | |
| `offers` | ✓ | ✓ | ✓ | |
| `orders` | ✓ | ✓ | ✓ | |
| `payouts` | ✓ | ✓ | | |
| `price-lists` | ✓ | ✓ | ✓ | |
| `product` | | ✓ | ✓ | |
| `products` | ✓ | | | |
| `product-tags` | ✓ | ✓ | | |
| `product-types` | ✓ | ✓ | | |
| `product-variants` | | ✓ | ✓ | |
| `profile` | | ✓ | | |
| `promotions` | ✓ | ✓ | ✓ | |
| `refund-reasons` | ✓ | | | |
| `regions` | ✓ | ✓ | | |
| `reservation` | ✓ | ✓ | ✓ | |
| `return-reasons` | ✓ | | | |
| `sales-channels` | ✓ | ✓ | | |
| `shipping-option-types` | ✓ | ✓ | | |
| `shipping-profiles` | ✓ | ✓ | | |
| `stores` | ✓ | ✓ | ✓ | |
| `tax-regions` | ✓ | ✓ | | `province.detail.main` |
| `users` | ✓ | ✓ | | |
Admin splits the product list and product detail across two domains. List zones
are `products.list.*` (plural) and detail zones are `product.detail.*`
(singular). Vendor uses `product` for both. Detail-page reservation zones are
`reservation.*` (singular) in admin and `reservations.*` (plural) in vendor.
Follow the tables above rather than guessing the pluralization.
## Next steps
Add fields, section rows, and list columns to a built-in model.
Add a route with file-based routing and register it in the sidebar.
# MCP Server
Source: https://docs.mercurjs.com/resources/ai/mcp
Connect your AI environment to Mercur documentation via Model Context Protocol.
# MCP Server
Mercur exposes a [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server that lets AI tools **search Mercur documentation directly**. Instead of relying on training data that may be outdated, your AI assistant queries the actual docs in real time.
```
https://docs.mercurjs.com/mcp
```
***
## Available tool
### SearchMercurJsDocumentation
Searches across all Mercur documentation and returns:
* Relevant excerpts matching your query
* Page titles for context
* Direct links to documentation pages
Use it when you need to look up API references, understand how a module works, find CLI commands, or retrieve examples during development.
***
## Connect to your AI environment
1. Press Cmd + Shift + P
2. Search for **Open MCP settings**
3. Select **Add custom MCP**
Add the following to your `mcp.json`:
```json theme={null}
{
"mcpServers": {
"mercur": {
"url": "https://docs.mercurjs.com/mcp"
}
}
}
```
Restart Cursor and ask: **"Search Mercur docs for how to add a block"**
See [Cursor MCP docs](https://docs.cursor.com/en/context/mcp) for more.
Create a file at `.vscode/mcp.json` in your project root.
```json theme={null}
{
"servers": {
"mercur": {
"type": "http",
"url": "https://docs.mercurjs.com/mcp"
}
}
}
```
Open the Copilot Chat panel and ask: **"List available MCP servers."**
See [VS Code MCP docs](https://code.visualstudio.com/docs/copilot/chat/mcp-servers) for more.
1. Press Cmd + Shift + P
2. Search for **Open Windsurf MCP Configuration**
```json theme={null}
{
"mcpServers": {
"mercur": {
"serverUrl": "https://docs.mercurjs.com/mcp"
}
}
}
```
Open Cascade and ask: **"Search Mercur docs for seller setup"**
1. Go to Claude → Settings → **Connectors**
2. Select **Add custom connector**
3. Enter:
* **Name**: Mercur
* **URL**: `https://docs.mercurjs.com/mcp`
4. Save
In chat, click the **attachments (+)** button and select your Mercur MCP connector.
See [Claude connector docs](https://modelcontextprotocol.io/docs/tutorials/use-remote-mcp-server) for more.
```bash theme={null}
claude mcp add --transport http mercur https://docs.mercurjs.com/mcp
```
```bash theme={null}
claude mcp list
```
Claude Code can now query Mercur documentation directly during any conversation.
See [Claude Code MCP docs](https://docs.anthropic.com/en/docs/claude-code/mcp) for more.
***
## Choosing an approach
| Approach | Best for |
| ------------------------------------------------------- | ------------------------------------------------------------------ |
| **[Bundled docs](/rc/resources/ai/overview)** | Coding agents inside a project: version-matched, offline, no setup |
| **MCP Server** | Live search from your editor, always the latest published docs |
| **[llms.txt](https://docs.mercurjs.com/llms-full.txt)** | Feeding full context to a chat assistant (Claude, ChatGPT) |
# Building with AI
Source: https://docs.mercurjs.com/resources/ai/overview
Mercur ships version-matched docs inside your project so AI coding agents build from accurate APIs instead of stale training data.
Mercur is built so AI coding agents work from **accurate, version-matched documentation** instead of their training data. Training data is almost always out of date for a fast-moving platform. The docs ship *inside your project's dependencies*, and your project tells agents to read them before writing any code.
**Why agents do well here.** Every extension surface an agent touches has a machine-checkable contract: routes generate a typed client (wrong calls fail to compile), pages follow [file conventions](/rc/resources/customization/extending-panels) the SDK validates at build time, and blocks are diffable source. An agent doesn't need to guess whether its change works. The toolchain tells it.
## How it works
When you install a Mercur project, the documentation is bundled as a dependency at `node_modules/@mercurjs/docs/`. It mirrors the structure of this site:
```txt theme={null}
node_modules/@mercurjs/docs/
├── llms.txt # index: every page with a one-line description
└── content/
├── learn/ # concepts: sellers, products, offers, commissions…
├── resources/ # tutorials, integrations, deployment, this guide
├── tools/ # CLI, API client, dashboard SDK
├── references/ # module, HTTP API, and configuration reference
└── user-guide/ # admin and vendor panel usage
```
Because the docs travel with the package, an agent always has documentation that **matches your installed version**. There is no network request, no external lookup, and no drift between what the agent reads and what your code actually runs.
## Set up your project
### New projects
Projects created with `bun create mercur-app@latest` are ready out of the box. The template ships:
* `@mercurjs/docs` as a dependency, so the docs land in `node_modules` on install
* an `AGENTS.md` and a `CLAUDE.md` at the project root that tell agents to read the bundled docs first
Most AI coding agents, such as Claude Code, Cursor, and GitHub Copilot, read `AGENTS.md` automatically when they start a session. There is nothing else to configure.
### Existing projects
Add the docs dependency:
```bash theme={null}
bun add @mercurjs/docs
```
Then create an `AGENTS.md` at the project root with a single, focused instruction:
```md AGENTS.md theme={null}
# Mercur: read the docs before coding
Before any non-trivial change, read the bundled documentation. It is
version-matched to this project's installed packages, and far more accurate
than training data.
1. Read the index: `node_modules/@mercurjs/docs/llms.txt`
2. Open the pages it points to under `node_modules/@mercurjs/docs/content/`
Don't guess at an API, data model, or file convention the docs already describe.
```
[Claude Code](https://docs.anthropic.com/en/docs/claude-code) reads `CLAUDE.md`; point it at the same instructions instead of duplicating them:
```md CLAUDE.md theme={null}
@AGENTS.md
```
## What the agent reads
The workflow is deliberately simple: **index first, then the page.** An agent reads `llms.txt` to see what exists, then opens the one or two `content/` pages relevant to the task before implementing. The bundled docs cover the full domain model (sellers, products, offers, attributes, commissions, payouts, order groups), the CLI, the typed API client, the dashboard SDK, module references, and how-to guides. The agent looks up the correct contract rather than inventing one.
## Verify its work
Mercur gives an agent a fast, machine-checkable way to know whether a change is correct. It is the equivalent of a grading loop the agent can run itself:
| Check | What it proves |
| ---------------------------------- | ------------------------------------------------------------ |
| `bun run build` | Types resolve and the generated client matches the routes |
| `bun run lint` | Code conforms to the project's rules |
| Integration tests (`packages/api`) | Backend behavior still holds |
| `bunx @mercurjs/cli@latest diff` | Local blocks vs. the registry: what changed and what drifted |
Because these are objective, an agent can define what "done" looks like, run the checks, read the output, and iterate until they pass, instead of stopping at "looks plausible."
## More AI tooling
The bundled docs are the foundation. Layer more on top:
Add the official Medusa agent skills for the framework Mercur runs on.
Let your editor search the docs live via Model Context Protocol.
For chat assistants like Claude or ChatGPT, Mercur also publishes a hosted
[`llms.txt`](https://docs.mercurjs.com/llms.txt) index and a full
[`llms-full.txt`](https://docs.mercurjs.com/llms-full.txt) you can load as context.
# Skills
Source: https://docs.mercurjs.com/resources/ai/skills
Agent skills for building on Mercur: the bundled-docs contract plus the official Medusa agent skills.
Agent skills are packaged instructions that teach an AI coding agent how to perform
a specific kind of task the right way for your stack, such as a migration, a form,
or a module. On Mercur, skills build on two foundations: the **version-matched docs**
bundled in your project, and the **official Medusa agent skills** for the
framework Mercur runs on.
## Start with the bundled docs
Mercur's own mechanism is the documentation bundled at
`node_modules/@mercurjs/docs/` plus an `AGENTS.md` that tells agents to read it
before writing code. That's the contract every agent should read first. It's
version-matched to your installed packages, so an agent looks up the real API
instead of guessing.
How the bundled docs work and how to point your agent at them.
## Official Medusa agent skills
Mercur is built on Medusa, so the [official Medusa agent skills](https://docs.medusajs.com/learn/introduction/build-with-llms-ai/agentic-skills)
apply directly to the Medusa layer under your marketplace: modules, workflows,
API routes, migrations, admin widgets, and storefronts. Install them as plugins
in Claude Code, or copy them into any AI tool that supports custom skills.
```bash Claude Code theme={null}
claude
/plugin marketplace add medusajs/medusa-agent-skills
/plugin install medusa-dev@medusa
```
### Available plugins
| Plugin | What it does |
| ---------------------- | -------------------------------------------------------------- |
| `medusa-dev` | Build features, fix bugs, and generate accurate Medusa code |
| `ecommerce-storefront` | A `storefront-best-practices` skill for any frontend framework |
| `learn-medusa` | An interactive experience for learning Medusa |
### `medusa-dev` commands
| Command | Description |
| ----------------------------------------- | --------------------------------------- |
| `/medusa-dev:db-migrate` | Run database migrations |
| `/medusa-dev:db-generate ` | Generate migrations for a custom module |
| `/medusa-dev:new-user ` | Create an admin user |
Using a different agent? These are plain skills. Copy them from the
[medusajs/medusa-agent-skills](https://github.com/medusajs/medusa-agent-skills)
marketplace into any tool that supports custom skills.
## Combine skills with the Mercur docs
For the framework layer, such as a custom module, a workflow, or an admin widget,
lean on the Medusa skills. For anything marketplace-specific, such as sellers,
offers, commissions, payouts, or order groups, pair them with the bundled Mercur
docs so the agent has both the framework skill and the version-matched marketplace
contract. The [Platform](/platform/store/overview) reference is written to be
read by agents: each domain's data models, workflows, service, and events are
documented exactly where an agent looks for them.
# How to Create an API Route
Source: https://docs.mercurjs.com/resources/best-practices/api-routes
Write API routes as thin HTTP adapters that type both sides, validate with Zod, and scope reads through middlewares and queryConfig.
An API route is a thin adapter between HTTP and the rest of the system. It validates the request, runs a [workflow](/rc/resources/best-practices/workflows) for writes or a [Query](/rc/resources/best-practices/workflows#the-query-engine) for reads, and shapes the response. No business logic lives here.
Routes are [Medusa file-based API routes](https://docs.medusajs.com/learn/fundamentals/api-routes). A `route.ts` under `src/api/**` exports handlers named after HTTP verbs, and a sibling `middlewares.ts` wires validation and filters. Examples below use a custom **Brand** module exposed under `/admin/brands`.
## Type both the request and the response
Type every handler on both sides. Mirror how Medusa's own routes are written:
* **`AuthenticatedMedusaRequest`:** the generic is the validated body for writes or the query params type for reads.
* **`MedusaResponse`:** the generic is the response shape, so `res.json(...)` is checked and the SDK infers a real return type instead of `unknown`.
```ts src/api/admin/brands/route.ts theme={null}
import {
AuthenticatedMedusaRequest,
MedusaResponse,
} from "@medusajs/framework/http"
import { ContainerRegistrationKeys } from "@medusajs/framework/utils"
import { createBrandsWorkflow } from "../../../workflows/create-brands"
import {
AdminCreateBrandType,
AdminGetBrandsParamsType,
} from "./validators"
import { AdminBrandListResponse, AdminBrandResponse } from "./types"
export const GET = async (
req: AuthenticatedMedusaRequest,
res: MedusaResponse
) => {
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)
const { data: brands, metadata } = await query.graph({
entity: "brand",
fields: req.queryConfig.fields,
filters: req.filterableFields,
pagination: req.queryConfig.pagination,
})
res.json({
brands,
count: metadata!.count,
offset: metadata!.skip,
limit: metadata!.take,
})
}
export const POST = async (
req: AuthenticatedMedusaRequest,
res: MedusaResponse
) => {
const { result } = await createBrandsWorkflow(req.scope).run({
input: { brands: [req.validatedBody] },
})
res.json({ brand: result[0] })
}
```
Don't leave `MedusaResponse` bare. An untyped response means `res.json({...})` accepts anything and the typed SDK resolves that endpoint to an empty or `unknown` response, the exact opposite of the point of the typed client. Always pass the response generic.
## Only `GET`, `POST`, `DELETE`
Mercur routes use only `GET`, `POST`, and `DELETE`. There is no `PUT` or `PATCH`. Model an update as a `POST` to the resource. Keeping to three verbs is what keeps the typed SDK (`.query` / `.mutate` / `.delete`) consistent across every route.
| Verb | Meaning | SDK method |
| -------- | ----------------------- | ----------- |
| `GET` | Read (list or retrieve) | `.query()` |
| `POST` | Create **and** update | `.mutate()` |
| `DELETE` | Remove | `.delete()` |
## Validation with Zod + exported types
Validation happens in `middlewares.ts` via `validateAndTransformBody` / `validateAndTransformQuery`. Every schema exports its inferred type so the handler generic and the SDK share one source of truth.
Bodies are plain Zod objects:
```ts src/api/admin/brands/validators.ts theme={null}
import { z } from "zod"
export const AdminCreateBrand = z.object({
name: z.string(),
is_active: z.boolean().optional(),
})
export type AdminCreateBrandType = z.infer
```
List and read params use the framework helpers `createFindParams` (pagination, `fields`, and `order`) and `createSelectParams` (retrieve) rather than a hand-rolled object. This wires pagination and field selection consistently across every route:
```ts src/api/admin/brands/validators.ts theme={null}
import { createFindParams, createOperatorMap } from "@medusajs/medusa/api/utils/validators"
export const AdminGetBrandsParams = createFindParams({
limit: 20,
offset: 0,
}).merge(
z.object({
// declare the fields that may be filtered on
id: z.union([z.string(), z.array(z.string())]).optional(),
name: z.string().optional(),
is_active: z.boolean().optional(),
created_at: createOperatorMap().optional(), // gt/lt/gte/lte ranges
})
)
export type AdminGetBrandsParamsType = z.infer
```
The handler then trusts `req.validatedBody` and the validated query to already match those types. Never re-validate inside the handler.
## List vs retrieve
A list route (`GET /admin/brands`) and a retrieve route (`GET /admin/brands/:id`) select fields the same way but differ in their params helper and response shape. Retrieve uses `createSelectParams` (field selection only, no pagination or filters) and returns a single entity:
```ts src/api/admin/brands/[id]/route.ts theme={null}
export const GET = async (
req: AuthenticatedMedusaRequest,
res: MedusaResponse
) => {
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)
const {
data: [brand],
} = await query.graph({
entity: "brand",
fields: req.queryConfig.fields,
filters: { id: req.params.id },
})
if (!brand) {
throw new MedusaError(MedusaError.Types.NOT_FOUND, `Brand ${req.params.id} not found`)
}
res.json({ brand })
}
```
```ts src/api/admin/brands/validators.ts: retrieve params theme={null}
import { createSelectParams } from "@medusajs/medusa/api/utils/validators"
export const AdminGetBrandParams = createSelectParams()
export type AdminGetBrandParamsType = z.infer
```
Both share the same `defaults` idea but declare them separately in the query config (`list` vs `retrieve`). See [`queryConfig`](/rc/resources/best-practices/api-routes#queryconfig-and-field-selection) below.
## Filterable fields
`req.filterableFields` is the parsed, validated filter set produced by `validateAndTransformQuery` from the query params above. Only fields your validator declares can appear there. An unknown query param is dropped, not passed through. The handler forwards it straight to Query:
```ts theme={null}
const { data: brands, metadata } = await query.graph({
entity: "brand",
fields: req.queryConfig.fields,
filters: req.filterableFields, // e.g. { is_active: true, created_at: { gt: ... } }
pagination: req.queryConfig.pagination,
})
```
This is why filtering is declarative and safe. To make a field filterable you add it to the validator. To scope a request you inject onto `req.filterableFields` in middleware (next section). The handler never builds a `where` clause by hand.
## Middlewares as filters
Middlewares aren't only for validation. They're where you inject scoping filters so handlers stay ignorant of the rule. A small middleware writes onto `req.filterableFields`, and because the handler already forwards that to Query, the scope is applied without the handler knowing. For example, force `GET /admin/brands` to only ever return active rows:
```ts src/api/admin/brands/middlewares.ts theme={null}
import {
MedusaRequest,
MedusaResponse,
MedusaNextFunction,
} from "@medusajs/framework/http"
const onlyActive = (
req: MedusaRequest,
_res: MedusaResponse,
next: MedusaNextFunction
) => {
req.filterableFields.is_active = true // a column on the brand module itself
next()
}
export const adminBrandsMiddlewares = [
{
method: ["GET"],
matcher: "/admin/brands",
middlewares: [
validateAndTransformQuery(AdminGetBrandsParams, adminBrandQueryConfig.list),
onlyActive,
],
},
]
```
Injecting a filter in middleware means a new route on the same resource is scoped by construction, not by remembering to add a filter. This works with `query.graph` because `is_active` lives on the brand's own module.
You can only filter this way on a field that belongs to the entity's own module. Filtering by a linked module's field (e.g. products by their `brand`) does not work with `query.graph`. Query aggregates modules after the fact, so there's no join to filter on. Cross-module filtering requires the [Index Module](/rc/resources/best-practices/module-links#filtering-by-a-linked-field--the-index-module) and `query.index`.
## Trust the auth middleware
Authentication and actor resolution happen in middleware (`authenticate`), so by the time your handler runs the actor is already established. Trust it. Read identity from the request context, never from the body:
```ts theme={null}
const userId = req.auth_context.actor_id // set by the authenticate middleware
```
Don't re-derive or re-check identity inside handlers, and don't read user or owner ids from the request body. Always take them from `req.auth_context` (or a context object a scoping middleware populated). Trusting the middleware keeps authorization in one place.
## Vendor routes: `seller_context`
Every route under `/vendor/*` is already authenticated and seller-scoped. You don't wire auth yourself. By the time your handler runs, the caller is a verified seller member and the request carries a `req.seller_context` you can trust:
```ts theme={null}
export const POST = async (
req: AuthenticatedMedusaRequest,
res: MedusaResponse
) => {
const sellerId = req.seller_context!.seller_id // the acting seller
const currency = req.seller_context!.currency_code
// ...run a workflow scoped to this seller
}
```
`req.seller_context` gives you `seller_id`, `currency_code`, and the `seller_member`, all verified, so you never re-check membership in a handler.
Never take a `seller_id` from the request body or query to decide ownership. That's caller-supplied. The only authoritative seller is `req.seller_context.seller_id`. To scope a vendor list route to the caller's data, add the `filterBySellerId()` middleware and every query is constrained automatically, with no per-handler `where`:
```ts src/api/vendor/offers/middlewares.ts theme={null}
import { filterBySellerId } from "@mercurjs/core/..."
{
method: ["GET"],
matcher: "/vendor/offers",
middlewares: [
validateAndTransformQuery(VendorGetOffersParams, vendorOfferQueryConfig.list),
filterBySellerId(),
],
}
```
## `queryConfig` and field selection
`validateAndTransformQuery` takes a query config that controls which `fields` are selectable, `isList`, and default pagination. The handler reads the resolved selection from `req.queryConfig.fields` and pagination from `req.queryConfig.pagination`.
```ts src/api/admin/brands/query-config.ts theme={null}
export const adminBrandQueryConfig = {
list: {
defaults: ["id", "name", "is_active", "created_at"],
isList: true,
},
retrieve: {
defaults: ["id", "name", "is_active"],
},
}
```
**`fields` replaces defaults unless prefixed.** An unprefixed field in the request's `fields` param *replaces* the route's default set. Prefix with `+`/`-` to merge (e.g. `+brand.name`), or base fields like `thumbnail` silently drop. This is the `medusa-fields-param` gotcha.
## Response types
Declare the response shapes next to the route (or in `@mercurjs/types` for shared ones) and use them as the `MedusaResponse` generic. The SDK reads these to type `.query()` and `.mutate()` returns:
```ts src/api/admin/brands/types.ts theme={null}
import { PaginatedResponse } from "@medusajs/framework/types"
export interface AdminBrandResponse {
brand: BrandDTO
}
export type AdminBrandListResponse = PaginatedResponse<{
brands: BrandDTO[]
}>
```
## Checklist for a route
* Handler is thin: validate, run a workflow (writes) or `query.graph` (reads), then respond.
* Both generics set: `AuthenticatedMedusaRequest` and `MedusaResponse`, never a bare `MedusaResponse`.
* Only `GET` / `POST` / `DELETE` exported. Updates are `POST`.
* Query params built with `createFindParams` / `createSelectParams`, bodies with Zod, and inferred types exported.
* Filterable fields declared in the validator. Scoping injected via a `filterableFields` middleware, not inlined.
* Identity read from `req.auth_context`, never the body. On vendor routes, the authoritative seller is `req.seller_context.seller_id` (set by `ensureSellerMiddleware`). Scope reads with `filterBySellerId()`.
* `fields` prefixed with `+`/`-` to merge. Defaults declared in `queryConfig`.
* No mutations outside a workflow.
## Next steps
Run business logic and the Query engine behind your routes.
Filter by a linked field with the Index Module and `query.index`.
# How to Add a Custom Field
Source: https://docs.mercurjs.com/resources/best-practices/custom-fields
Attach extra data to a built-in entity across the full stack: declare it in core, render it in the panels, pull in linked data, and type it end-to-end.
Custom fields attach extra data to a built-in entity such as a product, customer, or order through configuration, with no hand-written model or migration.
What makes them powerful is the full loop across the stack. You declare the field in core, render it in the panels with `defineCustomFieldsConfig`, optionally pull in [linked-module](/rc/resources/best-practices/module-links) data with the `link` property, and [type it end-to-end](/rc/resources/best-practices/types) so every SDK call carries it.
This page is the best-practices view. For the full storage-side setup see [Custom Fields](/rc/resources/customization/custom-fields).
## Reach for a custom field vs a module
The decision is about the shape and lifecycle of the data, not its size.
The data is a plain property of one existing record: `is_featured` on a product, `tier` on a customer, `source` on an order. One row per parent, no lifecycle of its own, read alongside the parent.
The data has its own lifecycle, relates to more than one entity, has many rows per parent, or carries business logic and its own routes. Reviews, tickets, subscriptions.
Custom Fields is strictly one row per parent entity. Forcing a one-to-many or stateful concept into it works until you need a second row or a state transition. If in doubt, model it as a [module](/rc/resources/best-practices/modules).
## The full loop
### 1. Declare the field in core
Register the Custom Fields module and describe the field in `medusa-config.ts`. The module generates the side table, the link, and the schema on `db:migrate`.
```ts medusa-config.ts theme={null}
{
resolve: "@mercurjs/core/modules/custom-fields",
options: {
customFields: {
Product: {
is_featured: { type: "boolean", nullable: true },
},
},
},
}
```
The value now lives in the module's own `custom_fields` side table. It is linked to the product, readable alongside it through `query.graph`, and written through `additional_data` on the entity's create/update route. Filtering products by a custom-field value is cross-module and needs the [Index Module](/rc/resources/best-practices/module-links#filtering-by-a-linked-field--the-index-module), not `query.graph`.
Prefer the `custom_fields` link over stuffing values into `metadata`. `metadata` is an untyped JSON bag with no schema, no queryable columns, and no clean extension point. It turns into a dumping ground. The Custom Fields module gives you a real linked table (`custom_fields.*`) with typed columns you can read and, via the Index Module, filter on, while still being config-only. Reach for `metadata` only for genuinely throwaway, never-queried scratch data.
Mutations still go through workflows. The panel submits custom-field values under `additional_data` on the parent's create/update route, and `additional_data` is exactly what [workflow hooks](/rc/resources/best-practices/workflows#hooks--let-others-extend-your-workflow) receive. So the same values you enter in the panel can be consumed by a `productsCreated` / `productUpdated` hook to run follow-up logic, persist to the linked table, or trigger side effects. Never write a custom-field value with a direct route write.
### 2. Render it in the panel with `defineCustomFieldsConfig`
Drop one file per model under the panel's `src/custom-fields/`. A single config contributes form fields (edit drawer, submitted under `additional_data`), read-only displays (detail sections), and list columns. Declare `link: "custom_fields"` so the module's data is fetched alongside the product and available to your fields and displays.
```tsx apps/vendor/src/custom-fields/product.tsx theme={null}
import { defineCustomFieldsConfig } from "@mercurjs/dashboard-sdk"
import { createFormHelper } from "@mercurjs/dashboard-shared"
type ProductWithCustomFields = { custom_fields?: { is_featured?: boolean } }
const form = createFormHelper()
export default defineCustomFieldsConfig({
model: "product",
link: "custom_fields", // fetch custom_fields.* with the product, no hand-written field list
forms: [
{
zone: "edit",
fields: {
is_featured: form.define({
validation: form.boolean().optional(),
label: "Featured",
// read the current value from the linked table, not metadata
defaultValue: (data) => Boolean(data?.custom_fields?.is_featured),
}),
},
},
],
displays: [
{
zone: "general",
fields: [
{
id: "is_featured",
component: ({ data }) => (data.custom_fields?.is_featured ? "Featured" : "-"),
},
],
},
],
})
```
The `zone` values are typed. The panel's codegen scans the host `` / `` usages and emits the valid zones per model into `extension-targets.d.ts`. `zone: "nope"` fails `tsc`. You don't hand-maintain that list.
The `displays` fields follow an add / replace / remove convention keyed by `id`:
* **Unknown id:** appends a new read-only row.
* **Built-in id with component:** replaces that field's render.
* **Built-in id with `component: null`:** hides the field.
## The extension API `link` property
A custom-field config can also declare module links to fetch alongside the entity with the `link` property. This is how you surface data from a linked module such as a `brand` in the product's columns and displays without wiring a second query.
```tsx apps/vendor/src/custom-fields/product.tsx theme={null}
export default defineCustomFieldsConfig({
model: "product",
link: "brand", // fetch brand.* with each product, one or an array of links
list: {
columns: [
// linked data is available on the row, no extra fetch
{ id: "brand_name", header: "Brand", component: ({ row }) => row.brand?.name },
],
},
displays: [
{
zone: "general",
fields: [{ id: "brand", component: ({ data }) => data.brand?.name ?? "-" }],
},
],
})
```
`link` replaces the old "remember to add the fields to every fetch" chore. Under the hood the panel reads the registry's links (`getLinks(model)`) and merges them into the built-in list, detail, and edit fetches with `withLinkFields(fields, links)` (`+brand.*`), so the linked data is present in all three places automatically. There's no `extendFields`: declaring the `link` is what makes its fields available to both columns and displays.
The link must actually exist as a [module link](/rc/resources/best-practices/module-links) and, in the vendor panel, respect the curated-field constraint. The fetch derived from `link` runs against the vendor product query, which rejects arbitrary `*`-relation overrides. Declare the link, then reference only its real fields.
## 3. Type it end-to-end
The rendered value comes back from the API, but the panel's `ProductDTO` doesn't know about `is_featured` yet. Close the gap with a one-line declaration-merging `.d.ts` so every SDK endpoint is typed, with no per-call casts.
```ts apps/vendor/src/types/custom-fields.d.ts theme={null}
import "@medusajs/types"
declare module "@medusajs/types" {
interface ProductDTO {
custom_fields?: { is_featured?: boolean }
}
}
```
Now `product.custom_fields?.is_featured` is typed on every `sdk.vendor.products.*` response. The runtime value is delivered by the `link` / registry merge above, not a hand-added `+field.*` (the vendor product query rejects arbitrary `*`-relation overrides). The mechanics, why merging into the upstream interface flows through, are covered in [Types & augmentation](/rc/resources/best-practices/types#the-scenario-a-custom-field-typed-end-to-end).
## The full override flow: `additional_data` → route → workflow hook
Rendering and typing a field is only half the story. The reason custom fields submit under `additional_data` is that it's the framework's built-in extension channel: values entered in the panel travel through the entity's existing API route into the workflow's hooks, where your own code consumes them, without forking the route or the workflow. This is exactly what a Mercur override looks like.
The flow has three links in the chain.
You already did this. A `defineCustomFieldsConfig` `edit`/`create` field is submitted as `additional_data.` on the entity's create/update request. Nothing else to wire on the frontend.
The vendor/admin product routes accept an `additional_data` body param, but each key must be declared or it's rejected. Register the allowed keys with `additionalDataValidator` in a middleware, with no need to touch the route handler.
```ts src/api/middlewares.ts theme={null}
import { defineMiddlewares } from "@medusajs/framework/http"
import { z } from "@medusajs/framework/zod"
export default defineMiddlewares({
routes: [
{
method: "POST",
matcher: "/vendor/products",
additionalDataValidator: {
brand_id: z.string().optional(),
},
},
],
})
```
Mercur's product create workflow is `createProductsWorkflow` from `@mercurjs/core/workflows` (id `mercur-create-products`). It is what the vendor route runs, and it exposes a `productsCreated` hook that runs after the products are created, receiving both the created records and your `additional_data`. Consume it to perform the real work, here [linking](/rc/resources/best-practices/module-links) the product to a brand, with a compensation function so a failure rolls the link back.
```ts src/workflows/hooks/created-product.ts theme={null}
import { createProductsWorkflow } from "@mercurjs/core/workflows"
import { StepResponse } from "@medusajs/framework/workflows-sdk"
import { Modules } from "@medusajs/framework/utils"
import { LinkDefinition } from "@medusajs/framework/types"
import { BRAND_MODULE } from "../../modules/brand"
createProductsWorkflow.hooks.productsCreated(
async ({ products, additional_data }, { container }) => {
if (!additional_data?.brand_id) {
return new StepResponse([], [])
}
const link = container.resolve("link")
const links: LinkDefinition[] = products.map((product) => ({
[Modules.PRODUCT]: { product_id: product.id },
[BRAND_MODULE]: { brand_id: additional_data.brand_id },
}))
await link.create(links)
return new StepResponse(links, links)
},
// compensation: undo the links if a later step fails
async (links, { container }) => {
if (!links?.length) {
return
}
await container.resolve("link").dismiss(links)
}
)
```
Mercur's `createProductsWorkflow` wraps Medusa's stock create-products flow and adds the marketplace layer (seller association, attributes, audit trail). Because it re-exposes the `validate` and `productsCreated` hooks, you extend the Mercur flow the same way you would a plain Medusa one. Consume its hook, don't fork it.
This is the override pattern in one sentence: the panel writes to `additional_data`, the route lets it through via `additionalDataValidator`, and a `hooks.` consumer turns it into real behaviour, all additively, without copying or replacing any built-in code. It's how you extend a Mercur (or Medusa) flow instead of forking it. See [Workflows → hooks](/rc/resources/best-practices/workflows#hooks--let-others-extend-your-workflow).
The hook runs inside the workflow, so its mutation still obeys the [one-mutation-per-step + compensation](/rc/resources/best-practices/workflows#one-mutation-per-step--compensation) rule. Always pair `link.create` with a `link.dismiss` compensation. Never do the work in a route handler after the workflow returns. Put it in the hook.
## Checklist
* Data is genuinely one row per parent with no lifecycle: use a custom field, otherwise a module.
* Field registered in `medusa-config.ts`, `db:migrate` run, writes go through `additional_data` on a workflow.
* Panel: one `defineCustomFieldsConfig` per model contributes forms, displays, and list, with typed `zone`s.
* Linked-module data pulled in with the `link` property (not a hand-written second fetch), the link exists and respects vendor field constraints.
* Extended fields typed once via a `.d.ts` merging into the framework DTO, and requested with `+…*` so they arrive.
* The override chain is complete: panel, then `additional_data`, then `additionalDataValidator` declares the key, then a `hooks.` consumer does the work inside the workflow, with compensation. No route or workflow forked.
## Next steps
Relate modules and fetch linked data alongside an entity.
Model data that has its own lifecycle, rows, and routes.
Extend a built-in flow through its hooks instead of forking it.
Type a custom field end-to-end with declaration merging.
# Frontend Patterns
Source: https://docs.mercurjs.com/resources/best-practices/frontend
Build Admin and Vendor panel UI with the shared design system: @medusajs/ui, custom-field extensions, and new pages, with the correct imports.
The Admin and Vendor panels share one design system. You do three things with it: style with `@medusajs/ui`, extend built-in screens with custom fields, and add new pages. Each has an established shape and a set of correct imports. This page is that short list. For the full reference, see the [panel extensions reference](/references/panel-extensions/overview).
## Use @medusajs/ui, and only it
Components come from `@medusajs/ui`, icons from `@medusajs/icons`, and colours, spacing, and type from Medusa UI tokens (`text-ui-fg-*`, `bg-ui-bg-*`, `border-ui-border-*`). Never use hex, `rgb()`, or `text-gray-500`.
```tsx theme={null}
import { Container, Heading, Text, Button, Badge, StatusBadge, toast } from "@medusajs/ui"
import { PencilSquare, Trash, EllipsisHorizontal } from "@medusajs/icons"
```
Never introduce a second UI library, and never restyle Medusa UI components with custom CSS. Build on the primitives. Do not work around them.
A section is a `Container` with the standard shell: a divided card with a header row.
```tsx theme={null}
Details
Body
```
## Extend built-in screens with custom fields
The primary way to customise an existing entity's screens (product, order, customer) is a custom-fields config: one file per model that contributes form fields, table columns, and read-only section fields. See [Custom fields](/rc/resources/best-practices/custom-fields) for the full backend and frontend loop. This section covers the frontend surface with the right imports.
You need two imports, and each lives in a different package.
```tsx theme={null}
import { defineCustomFieldsConfig } from "@mercurjs/dashboard-sdk" // the config helper
import { createFormHelper } from "@mercurjs/dashboard-shared" // typed form fields (zod)
```
`defineCustomFieldsConfig` is build-time config (SDK, zod-free). `createFormHelper` is the runtime form surface (dashboard-shared). Do not cross them over.
### Add form fields (edit / create)
Contribute inputs into a built-in form `zone`. Values submit under `additional_data`.
```tsx apps/vendor/src/custom-fields/product.tsx theme={null}
import { defineCustomFieldsConfig } from "@mercurjs/dashboard-sdk"
import { createFormHelper } from "@mercurjs/dashboard-shared"
const form = createFormHelper<{ custom_fields?: { is_featured?: boolean } }>()
export default defineCustomFieldsConfig({
model: "product",
link: "custom_fields",
forms: [
{
zone: "edit",
fields: {
is_featured: form.define({
validation: form.boolean().optional(),
label: "Featured",
defaultValue: (data) => Boolean(data?.custom_fields?.is_featured),
}),
},
},
],
})
```
### Change the list table
Add or override a column, and add bulk actions, on the model's built-in list.
```tsx theme={null}
list: {
columns: [
{ id: "is_featured", header: "Featured", component: ({ row }) => (row.custom_fields?.is_featured ? "★" : "") },
],
},
```
### Read-only fields in detail sections
Use `displays` to add read-only rows into an existing detail-page section, keyed by `id`. An unknown id adds a row, a built-in id replaces one, and `component: null` hides one. A read-only field can render a `StatusBadge`, and a section `action` can trigger a status change through a mutation.
```tsx apps/vendor/src/custom-fields/product.tsx theme={null}
import { StatusBadge, Button, toast } from "@medusajs/ui"
// inside defineCustomFieldsConfig(...)
displays: [
{
zone: "general",
fields: [
{
id: "review_status",
component: ({ data }) => (
),
},
],
},
],
```
Read-only displays are the idiomatic way to expose an entity's state, such as an approval flag, a moderation status, or an internal tag, and to act on it without rebuilding the detail page. The mutation still goes through the typed SDK and rides `additional_data` into a [workflow hook](/rc/resources/best-practices/custom-fields#the-full-override-flow-additional_data--route--workflow-hook), never a direct write.
## Add a new page
A brand-new screen is one file. Drop a `page.tsx` under the host app's `src/routes/`. The SDK registers the route from the file path and builds the sidebar entry from an exported `config`.
```tsx apps/vendor/src/routes/reviews/page.tsx theme={null}
import { Container, Heading } from "@medusajs/ui"
import { Star } from "@medusajs/icons"
import type { RouteConfig } from "@mercurjs/dashboard-sdk"
export const config: RouteConfig = {
label: "Reviews",
icon: Star,
}
export default function ReviewsPage() {
return (
Reviews
)
}
```
Correct imports for a page: UI from `@medusajs/ui`, icons from `@medusajs/icons`, and the `RouteConfig` type from `@mercurjs/dashboard-sdk`. Dynamic segments use brackets: `src/routes/reviews/[id]/page.tsx` maps to `/reviews/:id`. See [Extending panels](/rc/resources/customization/extending-panels#routing-conventions).
## Compose a full page: layout, table, sections, edit
For a real screen you assemble the same primitives the built-in pages use. They are all re-exported from `@mercurjs/dashboard-shared`, so you import from one place instead of Medusa internals.
```tsx theme={null}
import {
SingleColumnPage,
TwoColumnPage,
DataTable,
useDataTable,
SectionRow,
RouteDrawer,
Form,
ActionMenu,
} from "@mercurjs/dashboard-shared"
import { Container, Heading, Text, Button, Input, toast } from "@medusajs/ui"
import { createColumnHelper } from "@tanstack/react-table"
```
Import these primitives from `@mercurjs/dashboard-shared`, not from deep Medusa dashboard paths like `../../../components/table/data-table`. The shared package is the public, stable surface. Relative Medusa-internal imports are not available to consumer apps and break on upgrade.
### Layout and list table
Pick a layout: `SingleColumnPage` for lists and simple pages, `TwoColumnPage` for a detail with a sidebar. Mount a `DataTable` inside the standard section shell. Build columns with `createColumnHelper`, wire the table with `useDataTable`, use page size 20, and pass `keepPreviousData` for smooth pagination.
```tsx apps/vendor/src/routes/reviews/page.tsx theme={null}
const columnHelper = createColumnHelper()
const columns = [
columnHelper.accessor("title", { header: "Title" }),
columnHelper.accessor("rating", { header: "Rating" }),
columnHelper.display({
id: "actions",
cell: ({ row }) => (
),
}),
]
export default function ReviewsPage() {
const { reviews = [], count = 0, isLoading } = useReviews()
const { table } = useDataTable({ data: reviews, columns, count, pageSize: 20, getRowId: (r) => r.id })
return (
Reviews
row.id} pagination search />
)
}
```
### General section (label / value rows)
On a detail page, a "general" section is a `Container` header row plus `SectionRow` label and value pairs. This is the canonical way Medusa renders read-only entity data.
```tsx theme={null}
{review.title}
```
For a detail page with a sidebar, wrap sections in `TwoColumnPage` and place them under `TwoColumnPage.Main` and `TwoColumnPage.Sidebar`, each stacked with `gap-y-3`.
### Edit page (drawer)
Quick edits live in a routed `RouteDrawer` with `Form` (React Hook Form plus Zod). Gate the form until the entity has loaded, and use `useRouteModal().handleSuccess()` to close on save.
```tsx apps/vendor/src/routes/reviews/[id]/edit/page.tsx theme={null}
export default function EditReviewPage() {
return (
Edit review
{/* , RouteDrawer.Form + KeyboundForm, gated on !isPending && !!review */}
)
}
```
## Data only through the typed SDK
Never call `fetch` directly from a page. All HTTP goes through the typed SDK (`sdk.admin.*` in the admin panel, `sdk.vendor.*` in the vendor panel), wrapped in TanStack Query hooks.
```ts src/hooks/api/reviews.tsx theme={null}
import { useQuery } from "@tanstack/react-query"
import { sdk } from "../../lib/client"
import { queryKeysFactory } from "@mercurjs/dashboard-shared"
const reviewKeys = queryKeysFactory("reviews")
export const useReviews = (query?: Record) =>
useQuery({
queryKey: reviewKeys.list(query),
queryFn: () => sdk.vendor.reviews.query({ ...query }),
})
```
Invalidate `lists()`, `details()`, and `detail(id)` in mutations. Throw on `isError` so the route `ErrorBoundary` catches it. Show a `Skeleton` while loading.
## Checklist for panel work
* **UI primitives:** built only from `@medusajs/ui` and `@medusajs/icons`, Medusa UI tokens only, no custom CSS.
* **Extending a screen:** a `defineCustomFieldsConfig` file (`@mercurjs/dashboard-sdk`) with `createFormHelper` (`@mercurjs/dashboard-shared`). Forms submit under `additional_data`.
* **Read-only state:** status and flags surfaced via `displays`. Changes go through the typed SDK and a workflow hook, not a direct write.
* **New screen:** a `page.tsx` under `src/routes/` with a typed `RouteConfig`. Compose it from `SingleColumnPage` or `TwoColumnPage`, `DataTable`, `SectionRow`, and `RouteDrawer`, all imported from `@mercurjs/dashboard-shared`, never Medusa-internal paths.
* **Data:** via `sdk.admin.*` or `sdk.vendor.*` in TanStack Query hooks. No raw `fetch`. Mutations invalidate the right keys.
* **Strings and test ids:** every visible string translated, every interactive element has a `data-testid`.
## Next steps
The full reference for custom fields, widgets, and new pages.
The full backend and frontend loop, including the workflow hook.
# How to Link Two Modules
Source: https://docs.mercurjs.com/resources/best-practices/module-links
Relate modules without coupling them using defineLink, the link-direction rule, built-in link steps, and cross-link filtering.
Modules stay isolated, so you relate them with links declared outside the modules and read through Query.
A module never imports another module's service, and it never points a foreign key at another module's table (see [Modules](/rc/resources/best-practices/modules)). You declare relationships between modules **outside** the modules, as **links**, and read them through **Query**. This is what keeps each module independently migratable and upgrade-safe.
Links are a [Medusa framework primitive](https://docs.medusajs.com/learn/fundamentals/module-links). The examples below link a custom **Brand** module to Medusa's built-in **Product** module. It is the kind of relationship you would add in your own project.
## Define a link with `defineLink`
A link is a small file that associates two linkable data models. You define it once and sync it to the database with a migration.
```ts src/links/product-brand.ts theme={null}
import { defineLink } from "@medusajs/framework/utils"
import ProductModule from "@medusajs/medusa/product"
import BrandModule from "../modules/brand"
export default defineLink(
ProductModule.linkable.product,
BrandModule.linkable.brand
)
```
After adding or changing a link, generate and run the migration so the link table exists:
```bash Terminal theme={null}
npx medusa db:migrate
```
Once linked, you read across the boundary with Query, never by calling the other module's service:
```ts Read across the link with Query theme={null}
const { data: products } = await query.graph({
entity: "product",
fields: ["id", "title", "brand.*"], // follows the product ↔ brand link
})
```
## The link-direction rule
The order of arguments to `defineLink` is meaningful, and you control cardinality with `isList`. Read it left-to-right as "the left model links to the right model".
* **`defineLink(A.linkable.a, B.linkable.b)`:** one `a` links to one `b`.
* **`isList: true`:** wrap a side in `{ linkable, isList: true }` to make it the "many" side.
If one brand has many products but each product belongs to a single brand, mark the **product** side as the list:
```ts src/links/product-brand.ts theme={null}
import { defineLink } from "@medusajs/framework/utils"
import ProductModule from "@medusajs/medusa/product"
import BrandModule from "../modules/brand"
export default defineLink(
{
linkable: ProductModule.linkable.product,
isList: true,
},
BrandModule.linkable.brand
)
```
For a many-to-many relationship, where a product can carry many brands and a brand spans many products, mark both sides as lists and pin an explicit table name:
```ts src/links/product-brand.ts theme={null}
export default defineLink(
{ linkable: ProductModule.linkable.product, isList: true },
{ linkable: BrandModule.linkable.brand, isList: true },
{
database: {
table: "product_brand",
},
}
)
```
Direction determines the generated relation names and the shape of the link table. Getting it backwards produces a link that "works" but exposes the wrong nesting (`brand.products` vs `product.brands`), and it is painful to migrate away from. Decide the natural reading direction first, then set `isList` on the many side or sides.
## Create links inside workflows
Links are **data**, so creating or removing one is a mutation. It must happen inside a [workflow](/rc/resources/best-practices/workflows) through the built-in link steps. Never write to the link table directly.
* **`createRemoteLinkStep`:** creates links, and compensates by removing them on failure.
* **`dismissRemoteLinkStep`:** removes links.
Build the link definitions with `transform` (never inline logic in the composition function), then pass them to the step. Each entry names the two modules and the ids to associate:
```ts Linking a product to a brand inside a workflow theme={null}
import { createRemoteLinkStep } from "@medusajs/medusa/core-flows"
import { Modules } from "@medusajs/framework/utils"
import { LinkDefinition } from "@medusajs/framework/types"
import { BRAND_MODULE } from "../modules/brand"
const productBrandLinks = transform(
{ productId, brandId },
({ productId, brandId }): LinkDefinition[] => [
{
[Modules.PRODUCT]: { product_id: productId },
[BRAND_MODULE]: { brand_id: brandId },
},
]
)
createRemoteLinkStep(productBrandLinks)
```
Because `createRemoteLinkStep` already knows how to compensate, links created this way are torn down automatically if a later step in the workflow throws. This is the whole reason to link inside a workflow rather than in a route.
## Reading vs filtering across a link
This is the distinction that trips people up:
* **Reading** linked data (fetching `brand.*` alongside a product) works with `query.graph`. Query aggregates the two modules' data to build the result.
* **Filtering** by a linked module's field ("give me products *where* `brand.id = X`") does **not** work with `query.graph`.
`query.graph` cannot filter by a linked (cross-module) field. Because modules are isolated and Query aggregates their data after the fact, there is no join to filter on. Passing `filters: { brand: { id } }` to `query.graph` will not scope products by brand.
You can still filter by a field that lives on the entity's **own** module, a plain column such as `product.status` or `offer.seller_id`. That is a normal `query.graph` filter. Only *linked-module* fields need a different tool.
### Filter by a linked field with the Index Module
Cross-module filtering is what the [Index Module](https://docs.medusajs.com/learn/fundamentals/module-links/index-module) (`@medusajs/index`) exists for. It ingests data models into a single relational store on startup, so you can filter one entity by another's fields. Install it, make sure both models are ingested, and query with `query.index` instead of `query.graph`:
```ts Filter products by their linked brand with query.index theme={null}
const { data: products, metadata } = await query.index({
entity: "product",
fields: ["id", "title", "brand.name"],
filters: {
brand: {
id: brandId, // ✅ cross-module filter, resolved by the Index Module
},
},
})
```
By default Medusa ingests only `Product`, `ProductVariant`, `Price`, `PriceSet`, and `SalesChannel`. To filter products by a **custom** module such as Brand, you must [ingest that model](https://docs.medusajs.com/learn/fundamentals/module-links/index-module#how-to-ingest-custom-data-models) into the Index Module first. The Index Module is still marked experimental, though it powers filtering in the Medusa Admin.
`query.index` takes the same shape as `query.graph` (entity, fields, filters, pagination), so a route handler can forward `req.filterableFields` to it exactly the same way. The only change is `graph` to `index`.
## Checklist for a link
* Declared in its own file under `src/links/`, using `defineLink`.
* Argument order reflects the natural reading direction, with `isList` set on the many side or sides.
* Migration generated and run (`medusa db:migrate`).
* Cross-module **reads** go through `query.graph`, never a service-to-service call.
* Cross-module **filters** go through `query.index` (Index Module, with the model ingested). `query.graph` cannot filter by a linked field.
* Links are created and removed only inside workflows via `createRemoteLinkStep` and `dismissRemoteLinkStep`.
## Next steps
Keep modules isolated so links stay the only boundary between them.
Create and remove links inside compensating workflow steps.
# How to Create a Custom Module
Source: https://docs.mercurjs.com/resources/best-practices/modules
Keep a module thin: it owns one domain's data and its CRUD, with no orchestration, events, or cross-module calls.
A module is the lowest layer of the [architecture](/rc/resources/best-practices/overview). It owns exactly one domain's data and nothing else.
Modules are isolated. They never reach into another module, never orchestrate a business operation, and never react to events. All of that lives one layer up, in [workflows](/rc/resources/best-practices/workflows).
A Mercur module is a standard [Medusa module](https://docs.medusajs.com/learn/fundamentals/modules). The examples below build a small **Brand** module. It is the kind of custom module you add to your own project alongside the built-in ones, so the rules stand on their own rather than relying on Mercur internals.
## Thin CRUD only
A module service exists to read and write its own tables. Extend `MedusaService({ ...models })` and you get typed `list`, `listAndCount`, `retrieve`, `create`, `update`, and `delete` methods for every model for free. Use them.
Define the model with plain columns.
```ts src/modules/brand/models/brand.ts theme={null}
import { model } from "@medusajs/framework/utils"
export const Brand = model.define("brand", {
id: model.id().primaryKey(),
name: model.text(),
})
```
Extend `MedusaService` to get the generated CRUD methods.
```ts src/modules/brand/service.ts theme={null}
import { MedusaService } from "@medusajs/framework/utils"
import { Brand } from "./models/brand"
class BrandModuleService extends MedusaService({
Brand,
}) {
// Generated for you: listBrands, retrieveBrand, createBrands,
// updateBrands, deleteBrands, listAndCountBrands, ...
}
export default BrandModuleService
```
Add a custom method only when the logic is about this module's own data and can't be expressed with the generated methods, such as a specialised query. When you do, use Medusa's DI decorators so the method runs in the ambient context.
```ts src/modules/brand/service.ts theme={null}
class BrandModuleService extends MedusaService({ Brand }) {
@InjectManager()
async listActiveBrands(
filters: FindConfig = {},
@MedusaContext() sharedContext: Context = {}
): Promise {
return this.listBrands({ ...filters, is_active: true }, {}, sharedContext)
}
}
```
Some logic must never live in a module service: business orchestration, calls to another module's service, event emission, HTTP concerns, or anything that mutates data outside this module. If a method needs a second module's data or writes across a boundary, it belongs in a [workflow](/rc/resources/best-practices/workflows), not here. See the [logic-placement cheat sheet](/rc/resources/best-practices/overview#logic-placement-cheat-sheet).
## Naming
Follow four conventions so the module reads like the built-in ones.
* **Register the module by a stable id constant.** Export the module id and register the service against it:
```ts src/modules/brand/index.ts theme={null}
import { Module } from "@medusajs/framework/utils"
import BrandModuleService from "./service"
export const BRAND_MODULE = "brand"
export default Module(BRAND_MODULE, {
service: BrandModuleService,
})
```
Mercur's own modules follow the same pattern but read their id from the shared `MercurModules` enum in `@mercurjs/types` (e.g. `Module(MercurModules.SELLER, …)`). For a project-local module, a single exported constant such as `BRAND_MODULE` is enough. Just never inline the raw string in more than one place.
* **Methods are `camelCase` and model-suffixed.** Medusa generates `listBrands`, `createBrands`, and `retrieveBrand`. Match that casing and pluralisation when you add or override methods. Private helpers end with a trailing underscore (`computeBrandStats_`).
* **Models are lowercase-defined, referenced by their key.** `model.define("brand", { ... })`. The object key you pass to `MedusaService` (`Brand`) is what drives the generated method names.
* **Types live next to the module, or in a shared types package.** Export DTOs such as `BrandDTO` and import them. Never redeclare a model's shape ad hoc. See [Types & augmentation](/rc/resources/best-practices/types).
## Do not call `.linkable()`: links are declared separately
It is tempting to relate two modules by pointing a model at another module's table. Don't. A module model must not reference another module's data, and you should not wire relationships inside the model definition.
Cross-module relationships are declared **outside** the modules, with `defineLink`, and read through **Query**. A module never imports another module's `.linkable` shape to build a foreign key into it. Keeping models link-free is what lets modules stay independently migratable and upgrade-safe.
Define the relationship as its own link file instead. This is covered in full on [Module links](/rc/resources/best-practices/module-links).
```ts Relationship declared as a link, not inside the model theme={null}
import { defineLink } from "@medusajs/framework/utils"
import ProductModule from "@medusajs/medusa/product"
import BrandModule from "../modules/brand"
export default defineLink(
ProductModule.linkable.product,
BrandModule.linkable.brand
)
```
The model itself stays flat: plain columns, no relations pointing at other modules.
```ts src/modules/brand/models/brand.ts theme={null}
export const Brand = model.define("brand", {
id: model.id().primaryKey(),
name: model.text(),
is_active: model.boolean().default(true),
metadata: model.json().nullable(),
})
```
## Decorators
Custom service methods that touch the database use Medusa's dependency-injection decorators so they participate in the ambient transaction and shared context.
| Decorator | Use it on | Purpose |
| ----------------------------- | -------------------------------------- | --------------------------------------------------------------------- |
| `@InjectManager()` | Read methods | Injects the entity manager so the method runs in the current context. |
| `@InjectTransactionManager()` | Write methods | Runs the method inside a transaction, enabling rollback. |
| `@MedusaContext()` | The trailing `sharedContext` parameter | Threads the request/transaction context through the call. |
```ts Decorator pattern for a custom write theme={null}
@InjectTransactionManager()
async deactivateBrand(
id: string,
@MedusaContext() sharedContext: Context = {}
): Promise {
return this.updateBrands({ id, is_active: false }, sharedContext)
}
```
If you don't need a custom method, don't write one. The generated `MedusaService` methods already carry the right decorators and transaction behaviour. Reaching for them first keeps modules thin by default.
## Checklist for a module
* Extends `MedusaService({ ...models })` and leans on generated CRUD.
* Registered with `Module(BRAND_MODULE, { service })` against a stable id.
* No import of, or call into, any other module's service.
* Models are flat: no `.linkable()` wiring, no cross-module foreign keys.
* Custom methods use `@InjectManager` / `@InjectTransactionManager` plus `@MedusaContext`.
* DTOs are exported and imported, never redeclared inline.
* No orchestration, no events, no HTTP. Those live in workflows and routes.
## Next steps
Orchestrate business operations across modules, with compensation on failure.
Relate two modules with `defineLink` and read the relationship through Query.
Export DTOs and share a model's shape instead of redeclaring it inline.
See the layered architecture and the logic-placement cheat sheet.
# How-to Guides
Source: https://docs.mercurjs.com/resources/best-practices/overview
How to build with Mercur: the layered architecture, the non-negotiable rules, and where each piece of logic belongs.
This section is a practical guide for developing on Mercur, written for both **human developers** and **AI coding agents**. It captures the conventions the codebase already follows so that new code reads as if it belongs, stays testable, and survives upgrades of the underlying Medusa framework.
Mercur is a marketplace platform built on Medusa. Every rule here is either a Medusa requirement or a Mercur convention that keeps the marketplace layer consistent. When Medusa's docs and this guide agree, follow both; when in doubt, mirror an existing module, workflow, or route in `packages/core`.
## The layered architecture
Every feature in Mercur flows through the same four layers, top to bottom. Data and requests move **down**; results move back **up**. A layer may only talk to the layer directly beneath it.
```mermaid theme={null}
graph TD
F["Frontend (Admin · Vendor panels, Storefront)"]
A["API Route (/admin/* · /vendor/* · /store/*)"]
W["Workflow (orchestration + compensation)"]
M["Module (thin data access / CRUD)"]
DB[(PostgreSQL)]
F -->|"typed SDK request"| A
A -->|"run(workflow)"| W
W -->|"module service calls (steps)"| M
M --> DB
```
Owns one domain's data. Thin CRUD only, with no orchestration and no cross-module calls.
Orchestrates a business operation across modules, step by step, with automatic rollback (compensation) on failure.
A thin HTTP adapter: validate input, run a workflow (or query for reads), shape the response.
Admin/Vendor panels and storefront. Talks to the API only through the typed SDK, never raw `fetch`.
Why this shape matters:
* **Testability:** business logic lives in workflows, which can be run in isolation without an HTTP request.
* **Reusability:** a workflow can be called from a route, a subscriber, or a scheduled job.
* **Upgrade safety:** modules stay thin, so Medusa framework upgrades rarely touch your logic.
* **Rollback:** because mutations are workflow steps, a failure halfway through automatically undoes the earlier steps.
## The non-negotiables
These are hard rules. Breaking one produces code that looks like it works but silently violates the architecture, with no rollback, broken upgrades, or data written outside a workflow.
**All mutations go through a workflow.** Never write to the database directly from an API route, a subscriber, or a scheduled job. Reads may query directly; writes must run a workflow so they get validation, compensation, and event emission.
**Only `GET`, `POST`, and `DELETE`.** Mercur (following Medusa) does not use `PUT` or `PATCH`. Updates are modeled as `POST` to the resource. Keep every route to these three verbs.
**No cross-module service calls.** A module service must never import or call another module's service. Modules are isolated. Cross-module reads happen through **Query** (the graph); cross-module relationships are declared with **module links**; cross-module writes are coordinated in a **workflow**.
Two more that follow from the above:
* **Modules are thin.** A module service is CRUD plus small, self-contained helpers. If a method touches more than one module's data, it belongs in a workflow, not the service.
* **One mutation per step.** Each workflow step performs a single mutation and defines how to compensate it. This is what makes rollback reliable.
## Logic-placement cheat sheet
When you're about to write a piece of logic, find the concern in this table before you decide where the code goes. The **"Never put it in"** column is the part people get wrong.
| Concern | Put it in | Never put it in |
| ---------------------------------------------------------------------- | ------------------------------------------------- | --------------------------------------- |
| **Input shape / type validation** | API route (Zod schema on the request) | Module service, workflow |
| **Ownership / scoping** (e.g. this seller may only see its own orders) | API middleware (filters) + workflow guard | The frontend alone |
| **Business orchestration** (multi-step, multi-module operations) | Workflow (steps + compensation) | API route handler, module service |
| **A single data mutation** | Workflow step (module service call inside it) | API route, subscriber, job |
| **Cross-module reads** | Query (`query.graph`) | Direct service-to-service imports |
| **Cross-module relationships** | Module link (`defineLink`) | A foreign key inside one module's model |
| **Reacting to something that happened** (emails, sync, indexing) | Subscriber (listens to an event, runs a workflow) | Inline inside the route that caused it |
| **Periodic / time-based work** (polling, daily settlement) | Scheduled job (runs a workflow) | A subscriber, a route |
| **Emitting domain events** | Workflow step (`emitEventStep`) | Module service, subscriber |
| **Response shaping / field selection** | API route `queryConfig` (fields) | The module service |
| **Presentation, composition, UX** | Frontend (panels / storefront) | The API or any backend layer |
A quick mental test: *"Does this change data?"* → it must run inside a workflow. *"Does this react to a change?"* → it's a subscriber. *"Does this run on a schedule?"* → it's a job. *"Is this just reading and shaping data for a screen?"* → it's a route + Query. Everything else is either module CRUD or frontend.
## Guides
Follow these guides to build each layer the Mercur way.
### Server
Thin CRUD, naming, and what must never live in a service.
`defineLink`, link direction, and filtering by links.
Composition constraints, steps, compensation, and the query engine.
Thin adapters, Zod validation, middlewares as filters, `queryConfig`.
Event-driven side effects and scheduled work done safely.
Attach data to an entity end-to-end, from core to the panels.
### Panels
The extension model shared by the Admin and Vendor panels.
Inject a component into a built-in page zone.
### Blocks
Install a feature block into your project.
Package your own feature as a distributable block.
# How to React to Events and Schedule Jobs
Source: https://docs.mercurjs.com/resources/best-practices/subscribers-and-jobs
Run work outside the request cycle: react to events with subscribers, run periodic work with scheduled jobs, and always mutate through workflows.
Subscribers and scheduled jobs are the two ways work happens outside a request. Both follow the same core rule as everything else: they never mutate directly, they run a [workflow](/rc/resources/best-practices/workflows).
## Subscribers: react to events
A subscriber listens for a domain event (emitted by a workflow via `emitEventStep`) and runs an asynchronous side effect, such as sending a notification, syncing a search index, or creating a link. It lives in `src/subscribers/` and exports a handler plus a `config` naming the event.
```ts apps/api/src/subscribers/brand-created.ts theme={null}
import { SubscriberArgs, SubscriberConfig } from "@medusajs/framework"
export default async function brandCreatedHandler({
event,
container,
}: SubscriberArgs<{ id: string }>) {
const { id } = event.data
// ...fetch, then run a workflow
}
export const config: SubscriberConfig = {
event: "brand.created",
}
```
### Fetch full data from `{ id }`
Event payloads carry ids, not entities. A subscriber receives `{ id }` (sometimes a couple of ids) and must fetch the full record it needs via Query. Never rely on a fat event payload. It goes stale and couples the emitter to every consumer's needs.
Fetch what you need through Query, keyed by the id on the event.
```ts apps/api/src/subscribers/brand-created.ts theme={null}
const query = container.resolve(ContainerRegistrationKeys.QUERY)
const { data: [brand] } = await query.graph({
entity: "brand",
fields: ["id", "name", "products.*"],
filters: { id: event.data.id },
})
```
### Mutate via workflows, never directly
If the subscriber needs to change data, it runs a workflow, same as a route would. The subscriber is the trigger. The workflow is the work.
```ts apps/api/src/subscribers/brand-created.ts theme={null}
await createBrandNotificationWorkflow(container).run({
input: { brand_id: brand.id },
})
```
### Log, don't throw
A subscriber runs detached from the request. Throwing does not surface to a user. It just fails silently or spams retries. Catch errors and log them (resolve the `logger`), then decide explicitly whether to rethrow for a retry or swallow.
```ts apps/api/src/subscribers/brand-created.ts theme={null}
const logger = container.resolve("logger")
try {
await doWork()
} catch (e) {
logger.error(`brand-created subscriber failed for ${event.data.id}: ${e}`)
}
```
### Idempotency and loop guards
Events can be delivered more than once, and a subscriber that mutates data can re-trigger the very event it listens to. You have two defences:
* **Idempotency:** make the handler safe to run twice. Check current state before acting (for example, "is this product already linked to a brand?" before creating the link), or clear the marker that triggered the work so a redelivered event finds nothing left to do.
* **Loop guards:** if handling event X causes a mutation that emits X again, gate on a condition that becomes false after the first run, or key off a marker you set. Never emit the same event unconditionally from its own subscriber.
A good idempotency check reads the current state through Query first and returns early if the work is already done. This makes redelivery harmless and removes the need for exactly-once guarantees.
## Scheduled jobs: periodic work
A scheduled job runs on a cron interval to do time-based work, such as polling for records that became ready, reconciling drifted counters, or emitting a "settle now" event. It lives in `src/jobs/`, exports a handler taking the container, and a `config` with a `name` and a cron `schedule`.
```ts apps/api/src/jobs/deactivate-stale-brands.ts theme={null}
import { MedusaContainer } from "@medusajs/framework/types"
export default async function deactivateStaleBrands(container: MedusaContainer) {
const logger = container.resolve("logger")
const query = container.resolve(ContainerRegistrationKeys.QUERY)
const { data: stale } = await query.graph({
entity: "brand",
fields: ["id"],
filters: { is_active: true /* + your staleness condition */ },
})
// pass all ids at once, the workflow handles the batch, not the job
await deactivateBrandsWorkflow(container).run({
input: { ids: stale.map((b) => b.id) },
})
logger.info(`deactivated ${stale.length} stale brands`)
}
export const config = {
name: "deactivate-stale-brands",
schedule: "0 1 * * *", // daily at 01:00 UTC
}
```
### When to use a job vs a subscriber
Pick the trigger that matches how the work starts.
| Trigger | Use |
| --------------------------------------------------- | ----------------- |
| "Something happened" (a workflow emitted an event) | **Subscriber** |
| "It's time" / "poll for anything that became ready" | **Scheduled job** |
A time-based pipeline often combines both: a daily job finds records that became eligible and emits an event (say `brand.review_due`), and a subscriber turns each event into a workflow run. Polling for "what's ready" is the job. Reacting to each item is the subscriber.
### Job best practices
* **Batch and bound result sets:** a job that `SELECT`s an unbounded table will eventually time out. Page through with `LIMIT`/`OFFSET` or a cursor.
* **Idempotent by design:** a job re-runs on every tick, so it must only act on records still needing action (filter on the not-yet-processed state).
* **Mutations run workflows:** reads run Query, same as everywhere.
* **Log a summary:** report how many records processed each run so drift is visible.
## Checklist
* Subscriber `config.event` names a real emitted event; handler fetches full data from `{ id }` via Query.
* Subscriber mutations run a workflow; errors are caught and logged, not thrown blindly.
* Handler is idempotent and can't retrigger its own event without a guard.
* Job exports `{ name, schedule }`; cron is correct (UTC).
* Job filters to records still needing work, batches large sets, and logs a summary.
* Neither a subscriber nor a job writes to the database outside a workflow.
## Next steps
Do all mutations through a workflow so subscribers and jobs stay thin triggers.
# How to Use Shared Types
Source: https://docs.mercurjs.com/resources/best-practices/types
Type the panels against your own backend extensions by augmenting framework DTOs with declaration merging.
The panels are fully typed against the API through `@mercurjs/types` and the typed SDK. When you extend the backend with [custom fields](/rc/resources/best-practices/custom-fields), a [linked module](/rc/resources/best-practices/module-links), or an extra field on a DTO, those additions are not in the shipped types yet. You close the gap in the frontend with a small declaration-merging `.d.ts` file. Write it once, and every SDK call that returns the entity carries your field, typed.
Never use `any` to paper over a missing field. Casting a response to `any`, or to `as { custom_fields: … }` at each call site, throws away type-checking and has to be repeated everywhere. Augment the type once instead.
## The scenario: a custom field, typed end-to-end
Say you added a custom field on the backend, such as `is_featured` on `product` (see [Custom fields](/rc/resources/best-practices/custom-fields)). The value now comes back from the API, but the panel's `ProductDTO` does not know about it, so `product.is_featured` is a type error.
Fix it in the panel with a declaration-merging file.
### Why merging works here
The `ProductDTO` the SDK returns ultimately resolves to Medusa's upstream `ProductDTO`, which is declared as an `interface` in `@medusajs/types`. Interfaces are open, so you can merge into it with `declare module "@medusajs/types"`.
Everything downstream refers back to that same interface: `@mercurjs/types`, the SDK response wrappers such as `AdminProductResponse` and list responses, and the panel hooks. Your added members appear in all of them at once. You augment in one place and every product-returning endpoint is typed.
### Add the `.d.ts` in the panel
Drop a declaration file anywhere under the panel's `src/`. It is picked up by the app's `tsconfig`.
```ts apps/vendor/src/types/custom-fields.d.ts theme={null}
import "@medusajs/types"
declare module "@medusajs/types" {
interface ProductDTO {
custom_fields?: {
is_featured?: boolean
}
}
}
```
Follow two rules, or the augmentation silently does nothing:
* The module name in `declare module "..."` must be the package that declares the interface you are merging into. Here that is `@medusajs/types`, the owner of `UpstreamProductDTO`, not `@mercurjs/types`, which only aliases it.
* The file must be a module. Add an `import "@medusajs/types"`, or a trailing `export {}`, so TypeScript treats it as one.
### Now the whole SDK is typed
With that one file in place, no cast is needed anywhere:
```ts Every product endpoint carries the field theme={null}
const { products } = await sdk.vendor.products.query()
products[0].custom_fields?.is_featured // ✅ typed, no cast
const { product } = await sdk.vendor.products.$id.query({ $id: id })
product.custom_fields?.is_featured // ✅ typed everywhere ProductDTO flows
```
Types and runtime are separate concerns. This `.d.ts` makes the field typed, but it only arrives if the fetch asks for it. Let the [custom-fields `link` / registry merge](/rc/resources/best-practices/custom-fields#the-extension-api-link-property) add the fields to the built-in panel fetches rather than hand-adding `+field.*`. The vendor product query in particular rejects arbitrary `*`-relation overrides.
## Linked data resolves the same way
The augmentation is not limited to a custom field's own value. It is how you make linked-module data typed too. When a [custom-fields config](/rc/resources/best-practices/custom-fields#the-extension-api-link-property) declares a `link`, the panel fetches that module's data alongside the entity (see [panel extensions](/references/panel-extensions/overview)). A `link: "brand"` merges `brand.*` into the built-in product fetch for you, with no hand-written field list.
Pair that one config line with a matching augmentation, and the linked data is both present at runtime and typed everywhere `ProductDTO` is imported.
```ts src/custom-fields/product.tsx: declare the link (runtime) theme={null}
export default defineCustomFieldsConfig({
model: "product",
link: "brand", // brand.* is fetched with every product
list: {
columns: [
{ id: "brand_name", header: "Brand", component: ({ row }) => row.brand?.name },
],
},
})
```
```ts src/types/brand.d.ts: declare the shape (types) theme={null}
import "@medusajs/types"
declare module "@medusajs/types" {
interface ProductDTO {
brand?: { id: string; name: string }
}
}
```
Now any code that imports `ProductDTO`, whether a page, a hook, or a column renderer, sees `product.brand` resolved, with the data already fetched by the `link`:
```ts theme={null}
const { product } = await sdk.vendor.products.$id.query({ $id: id })
product.brand?.name // ✅ present (via link) and typed (via augmentation)
```
The `link` does the fetching, the `.d.ts` does the typing. You write each once, per model, and every product-returning endpoint in the panel is covered. This is the payoff of augmentation: register the relationship in one place, then consume it as a plain typed property everywhere.
## Where each type goes
| You're adding… | Put it in |
| --------------------------------------------------- | ----------------------------------------------------------------------------------- |
| A field your API now returns on an entity | A panel `.d.ts` merging into the framework DTO (`declare module "@medusajs/types"`) |
| A one-off request/response shape for a custom route | Infer it from the Zod validator (`z.infer`) and export it |
| A brand-new Mercur/domain DTO | The domain folder in `@mercurjs/types`, re-exported from `index.ts` |
DTOs and enums the platform already ships (`ProductDTO`, `SellerStatus`, `MercurModules`, `HttpTypes`) are imported from `@mercurjs/types`, never redeclared. In the dashboards, `HttpTypes` comes from `@mercurjs/types` too, which is what keeps request/response types aligned with Mercur's extended routes.
## Checklist
* No `any`, and no per-call-site casts for extended data.
* Backend additions typed in the panel via a `.d.ts` merging into the framework interface that owns the DTO.
* Augmentation files name the correct package and are real modules (`import` / `export {}`).
* Extended fields are requested with `+field.*` so they actually arrive.
* Shipped types imported from `@mercurjs/types`; one-off shapes inferred from Zod.
## Next steps
Add fields, rows, actions, and columns to a built-in model, and declare a `link`.
Link a custom module to a built-in entity and fetch its data alongside.
See how the panel fetches linked module data alongside the entity.
# How to Create a Workflow
Source: https://docs.mercurjs.com/resources/best-practices/workflows
Coordinate a business operation across modules as workflow steps, each with automatic rollback, reusing built-in steps and reading through the query engine.
A workflow is the orchestration layer. It coordinates a business operation across one or more modules as a series of **steps**, with automatic rollback (**compensation**) when any step fails. Every mutation in Mercur runs inside a workflow. This is the single most important rule in the [architecture](/rc/resources/best-practices/overview).
**All mutations go through a workflow.** API routes, subscribers, and scheduled jobs never write to the database directly. They run a workflow. That is what gives every mutation validation, event emission, and rollback.
Workflows are a [Medusa framework primitive](https://docs.medusajs.com/learn/fundamentals/workflows). This page focuses on the constraints and conventions that trip people, and agents, up.
## The composition function is not normal JavaScript
The function you pass to `createWorkflow` is a **composition function**. It runs once at build time to wire steps together. It does **not** execute your business logic at request time. Because of that, it has hard constraints.
Inside a `createWorkflow` composition function you must **not**:
* use `async` / `await`
* use arrow functions for the composition body (use a named `function`)
* use `if` / `else`, `for`, `while`, or `try/catch`
* use `new Date()`, `Math.random()`, or any non-deterministic call
* access properties of a step's output directly (e.g. `result.id`)
These run at composition time, not execution time, so they either do nothing useful or break replay and rollback.
Anything that looks like normal logic goes into a **step** (for side effects) or a **`transform`** (for shaping data between steps):
```ts src/workflows/create-brands.ts theme={null}
import {
createWorkflow,
transform,
WorkflowResponse,
} from "@medusajs/framework/workflows-sdk"
import { createBrandsStep } from "./steps"
export const createBrandsWorkflow = createWorkflow(
"create-brands",
function (input: CreateBrandsWorkflowInput) {
// ✅ shape data with transform, not inline expressions
const toCreate = transform(input, ({ brands }) =>
brands.map((b) => ({ ...b, is_active: b.is_active ?? true }))
)
const brands = createBrandsStep(toCreate)
return new WorkflowResponse(brands)
}
)
```
Need a conditional or a computed value? Use `transform` to derive data, `when` to run a step conditionally, and put date, random, or id generation **inside a step**. Never branch in the composition body itself.
## One mutation per step and compensation
A step is the unit of work and the unit of rollback. The rule: **each step performs a single mutation and defines how to undo it.** `createStep` takes an invoke function and a compensation function. The invoke returns a `StepResponse` whose second argument is the data the compensation needs.
```ts src/workflows/steps/create-brands.ts theme={null}
import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk"
import BrandModuleService from "../../modules/brand/service"
import { BRAND_MODULE } from "../../modules/brand"
export const createBrandsStep = createStep(
"create-brands",
async (data: CreateBrandInput[], { container }) => {
const service = container.resolve(BRAND_MODULE)
const brands = await service.createBrands(data)
// 2nd arg → passed to the compensation function below
return new StepResponse(brands, brands.map((b) => b.id))
},
async (ids: string[] | undefined, { container }) => {
if (!ids?.length) {
return
}
const service = container.resolve(BRAND_MODULE)
await service.deleteBrands(ids)
}
)
```
When a later step in the workflow throws, Medusa runs the compensation functions of the already-completed steps in reverse. The `createBrands` above is undone by `deleteBrands`. Splitting mutations one per step is what makes this reliable: a step that does two writes can only half-compensate.
## Reuse built-in steps
Don't hand-roll what the framework already ships. Medusa's `core-flows` exports composable steps you should reuse instead of writing your own:
| Need | Built-in step |
| ------------------------------- | ------------------------------------------------ |
| Create/remove module links | `createRemoteLinkStep` / `dismissRemoteLinkStep` |
| Emit a domain event | `emitEventStep` |
| Call another workflow as a step | `otherWorkflow.runAsStep({ input })` |
```ts theme={null}
import { emitEventStep } from "@medusajs/medusa/core-flows"
export const createBrandsWorkflow = createWorkflow(
"create-brands",
function (input: CreateBrandsWorkflowInput) {
const brands = createBrandsStep(input.brands)
// reuse a whole workflow as one step
notifyOwnersWorkflow.runAsStep({ input: { brands } })
// reuse the built-in event step
emitEventStep({ eventName: "brand.created", data: { ids: brands } })
return new WorkflowResponse(brands)
}
)
```
Prefer `runAsStep` over duplicating logic. When two workflows need the same sequence, extract it into its own workflow and call it as a step from both. You get one place to maintain, and correct compensation for free.
## Hooks let others extend your workflow
Expose extension points with `createHook` so consumers can inject behaviour such as validation or side effects without forking the workflow. Add a `validate` hook before the mutation and a `brandsCreated` hook after it:
```ts theme={null}
const validate = createHook("validate", { input })
const brands = createBrandsStep(input.brands)
const brandsCreated = createHook("brandsCreated", {
brands,
additional_data: input.additional_data,
})
return new WorkflowResponse(brands, {
hooks: [validate, brandsCreated],
})
```
Consumers register a handler on the hook to run custom logic at that point. This is the sanctioned way to extend a workflow. See [Extend a workflow](/rc/resources/customization/extend-a-workflow).
## The query engine
Reads inside a step, and anywhere else, go through **Query**, the graph engine that resolves data across modules and links. Resolve it from the container and call `query.graph`:
```ts theme={null}
import { ContainerRegistrationKeys } from "@medusajs/framework/utils"
const query = container.resolve(ContainerRegistrationKeys.QUERY)
const { data: brands } = await query.graph({
entity: "brand",
fields: ["id", "name", "products.*"], // follows the product ↔ brand link
filters: { is_active: true },
})
```
Query is for **reads**. Never try to mutate through it, and never resolve another module's service inside a step to read its data. Go through Query so module isolation and links are respected.
## Checklist for a workflow
* Composition function is a named `function`, with no `async`/`await`, `if`, loops, `try/catch`, `new Date()`, or step-output property access.
* Data shaping between steps uses `transform`; conditional steps use `when`.
* Each step does exactly one mutation and defines a compensation function.
* `StepResponse` passes the compensation the data it needs to undo the work.
* Built-in steps (`createRemoteLinkStep`, `emitEventStep`) and `runAsStep` are reused instead of reimplemented.
* Reads use `query.graph`; no cross-module service calls.
* Extension points are exposed as hooks, not by forking.
## Next steps
Register handlers on a workflow's hooks to add behaviour without forking.
See how workflows fit the wider Mercur architecture.
# How to Extend a Workflow
Source: https://docs.mercurjs.com/resources/customization/extend-a-workflow
Inject custom logic into an existing Mercur workflow through hooks, without rewriting it.
This guide is being written. The outline below is the intended structure.
**Workflows are Medusa's extension model, and Mercur keeps it.** Unlike the panels (where Mercur ships [its own customization framework](/rc/resources/customization/extending-panels)), backend logic follows Medusa conventions unchanged: Mercur's workflows expose hooks you inject steps into, with the same compensation/rollback semantics as any Medusa workflow. Skills learned in plain Medusa transfer here one-to-one.
You extend a Mercur workflow by injecting your own step into a hook it exposes, so your logic runs inside the existing flow instead of a fork of it.
## Why hooks
Hooks let you add behavior without forking or rewriting the workflow.
The `completeCartWithSplitOrdersWorkflow` and where its hooks sit.
The shape of a hook and how to register your step.
How your step participates in automatic rollback on failure.
## Verify
Confirm the injected behavior runs and rolls back correctly.
## Next steps
# How to Extend the Panels
Source: https://docs.mercurjs.com/resources/customization/extending-panels
Add pages, widgets, navigation overrides, and form and table fields to the admin and vendor panels without forking.
Both the admin panel and the vendor portal run on the same SDK, `@mercurjs/dashboard-sdk`. You customize them by dropping files under `src/`.
Customization is file-based and convention-driven. You add pages, widgets, and field extensions by placing files under `src/`, and you shape navigation through a single file. Nothing is registered by hand. The SDK crawls your `src/` at build time and wires everything in.
**Coming from Medusa?** The extension model is deliberately Medusa-shaped. The helpers you know, such as `defineWidgetConfig`, `defineRouteConfig`, `defineCustomFieldsConfig`, and `createFormHelper`, all exist here, re-exported from `@mercurjs/dashboard-sdk` (without Medusa's `unstable_` prefix). Two things differ. Mercur ships two panels (admin and vendor) from one framework, so a file only ever targets the panel it lives in (there is no `surface` field), and widget zones carry a `before | after` placement suffix on the zone id. Everything is additive by default: your contribution augments the built-in page instead of replacing it.
## The extension mechanisms
Every panel customization is additive. You augment a built-in page without owning it. Each file handles one concern and is discovered by the build-time crawl:
* **Widgets:** inject a React component at a named zone on a built-in page (`defineWidgetConfig`).
* **Navigation:** reorder, hide, relabel, or re-parent built-in sidebar items (`defineNavigationConfig`).
* **Custom fields:** add validated fields to built-in forms, replace, remove, or add fields in detail sections, and add columns to list tables (`defineCustomFieldsConfig` plus `createFormHelper`).
* **Pages:** add a brand-new screen with a drop-in `page.tsx` route.
Additive tools leave the rest of the built-in page completely intact, including data fetching, filters, pagination, and i18n. A widget, a nav override, or a custom field changes only the spot you target.
## Choose your extension mechanism
Use this rule of thumb. Injecting UI into an existing page? Use a widget. Adding data to a form or section? Use a custom field. Reshaping the sidebar? Use the navigation file. Adding a whole new screen? Drop in a route.
| You want to… | Use | Why it's the right tool |
| -------------------------------------------------------------------------------------- | -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| Show a banner, panel, or CTA on a built-in page | A **widget** (`defineWidgetConfig`) | Renders at a named zone; the rest of the page is untouched. See [Add a widget](/rc/resources/tutorials/add-a-widget) |
| Add a field to a built-in form, or add, replace, or remove a field in a detail section | A **custom field** (`defineCustomFieldsConfig`) | Validated field wired into the existing form or section. See [Extend forms and tables](/rc/resources/tutorials/extend-forms-and-tables) |
| Add or override a column on a built-in list table | The `list` block of a **custom field** file | Model-scoped column extension. See [Extend forms and tables](/rc/resources/tutorials/extend-forms-and-tables) |
| Reorder, hide, relabel, or re-parent sidebar items | The **navigation** file (`defineNavigationConfig`) | One host-owned `_navigation.ts`. See [Customize navigation](/rc/resources/tutorials/customize-navigation) |
| Add a new screen or feature | A drop-in `page.tsx` route | New URL, auto-registered, sidebar entry via `config`. See [Add a page](/rc/resources/tutorials/custom-panel-page) |
| Reuse a feature across projects | A [block](/rc/learn/blocks) | Ships API, admin, and vendor files installable with `mercurjs add` |
## Set up
All configuration lives in the panel app's Vite config. There is no separate `mercur.config.ts`.
Add `mercurDashboardPlugin` to the panel app's `vite.config.ts`. The only required option is `medusaConfigPath`. The plugin reads panel paths and ports from your API's Medusa config.
```typescript vite.config.ts theme={null}
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { mercurDashboardPlugin } from '@mercurjs/dashboard-sdk'
export default defineConfig({
plugins: [
react(),
mercurDashboardPlugin({
medusaConfigPath: '../../packages/api/medusa-config.ts',
name: 'My Marketplace',
logo: 'https://example.com/logo.svg',
}),
],
})
```
Projects created with `create-mercur-app` ship with this already wired for both panels.
The plugin doesn't read `.env` itself. Load environment variables in `vite.config.ts` (for example with Vite's `loadEnv`) and pass them into the plugin options, as the starter template does with `backendUrl`.
Widgets, navigation, and custom fields target typed ids (zone ids, nav item ids, model field ids) that each panel package generates from its own built-in pages and ships as `@mercurjs/{admin,vendor}/extension-targets`. Register them once so the ids resolve in every extension file, with a single ambient reference in your app's `src`.
```typescript apps/vendor/src/extension-targets.d.ts theme={null}
///
```
For the admin app, reference `@mercurjs/admin/extension-targets` instead. With this file present, a typo in a zone, model, field, or nav id fails `tsc` instead of silently doing nothing at runtime. `create-mercur-app` ships this file already.
Options are applied at build time through virtual modules. Adding or removing routes, widgets, and custom-field files hot-reloads automatically, but changes to the plugin options (in `vite.config.ts`) require a dev-server restart.
### Configuration options
| Option | Type | Description |
| -------------------------- | --------- | --------------------------------------------------------------------------------------- |
| `medusaConfigPath` | `string` | **Required.** Path to your API's `medusa-config.ts`, relative to the panel project root |
| `backendUrl` | `string` | Medusa backend URL (default: `http://localhost:9000`) |
| `vendorUrl` | `string` | Absolute vendor portal URL including its path prefix |
| `name` | `string` | Application name shown in the sidebar |
| `logo` | `string` | URL to a logo image |
| `i18n` | `object` | Internationalization settings (`{ defaultLanguage }`) |
| `enableSellerRegistration` | `boolean` | Enable the public seller registration flow (vendor portal) |
| `imageLimit` | `number` | Max upload size for images in bytes (default: 2 MB) |
## Widgets
A widget injects a React component at a named zone on a built-in page. Drop a file under `src/widgets/`, export the component as the default and a `config` built with `defineWidgetConfig`.
```tsx src/widgets/product-list-banner.tsx theme={null}
import { defineWidgetConfig } from "@mercurjs/dashboard-sdk"
import { Container, Text } from "@medusajs/ui"
export const config = defineWidgetConfig({
zone: "product.list.before",
})
export default function ProductListBanner() {
return (
Tip: bulk-import products from the Products menu.
)
}
```
The zone id reads `..`. The placement is the last segment:
* `before` / `after`: stack your widget before or after the built-in content. Multiple widgets stack in registration order.
Zones mounted today (**vendor portal**):
| Zone id | Where it renders |
| --------------------------------------------------- | -------------------------------------------------------- |
| `product.list.before` / `.after` | Vendor product list page |
| `login.logo.*` / `login.before.*` / `login.after.*` | The public login screen (rendered before authentication) |
The full set of valid zones is typed as `WidgetZoneId` and generated into each panel's `extension-targets.d.ts`. Your editor autocompletes them, and an unknown zone fails `tsc`. Walk through it end to end in [Add a widget](/rc/resources/tutorials/add-a-widget).
## Navigation
Custom drop-in routes place their own sidebar item via `defineRouteConfig` (see [Add a page](#add-a-page)). To reshape the built-in sidebar items, author a single host-owned file, `src/_navigation.ts`.
```ts src/_navigation.ts theme={null}
import { defineNavigationConfig } from "@mercurjs/dashboard-sdk"
export default defineNavigationConfig({
items: [
{ id: "orders", rank: 0 }, // pin to the top
{ id: "price-lists", hidden: true }, // hide a built-in item
{ id: "payouts", label: "Settlements" }, // relabel
{ id: "campaigns", nested: "orders" }, // re-parent under Orders
{ id: "categories", nested: null, rank: 1 }, // promote a nested item to top level
],
})
```
* `id` targets any built-in item, top-level routes or nested children, by its stable id, typed as `NavItemId`.
* `rank` orders an item within its parent, `hidden` removes it from the sidebar, `label` and `icon` relabel it, and `nested` re-parents it (`nested: null` promotes to top level, typed against `NavParentId`).
* Navigation is a single host-owned file. Installed blocks cannot reorder the sidebar, so it stays one source of truth. It does not change custom routes, which still place themselves via `defineRouteConfig`.
Walk through it in [Customize navigation](/rc/resources/tutorials/customize-navigation).
## Custom fields
A custom field adds validated fields to a model's built-in create and edit forms, replaces, removes, or adds fields in its detail sections, and adds columns to its list table, all from one model-scoped file. Drop `src/custom-fields/.tsx` and default-export a `defineCustomFieldsConfig`.
```tsx src/custom-fields/product.tsx theme={null}
import { defineCustomFieldsConfig } from "@mercurjs/dashboard-sdk"
import { createFormHelper } from "@mercurjs/dashboard-shared"
import { Text } from "@medusajs/ui"
const form = createFormHelper<{ metadata?: Record }>()
export default defineCustomFieldsConfig({
model: "product",
forms: [
{
zone: "edit",
fields: {
erp_id: form.define({
validation: form.string().optional(), // Zod → input type + validation
label: "ERP ID",
description: "External system identifier",
placeholder: "ERP-000",
}),
},
},
],
displays: [
{
zone: "general",
fields: [
{ id: "erp_id", component: ({ data }) => ERP: {String(data.metadata?.erp_id ?? "-")} }, // ADD
{ id: "subtitle", component: null }, // REMOVE a built-in field
{ id: "handle", component: BrandedHandle }, // REPLACE a built-in field's render
],
},
],
list: {
columns: [{ id: "erp_id", header: "ERP", component: ({ row }) => String(row.metadata?.erp_id ?? "-") }],
viewDefaults: {
columnVisibility: { collection: false }, // hide a built-in column
columnOrder: ["product", "erp_id", "status"],
},
},
})
```
* `forms[]` adds validated fields to a form `zone` (`create`, `edit`, `organize`, and so on). Input type and validation come from a Zod schema via `createFormHelper`. Fields render through the standard `Form.Field` chain and participate in the existing submit and validation flow.
* `displays[]` targets detail-page sections, keyed by field `id`. An entry **adds** a read-only row (unknown id), **replaces** a built-in field's render (matching id plus `component`), or **removes** it (matching id plus `component: null`).
* `list` extends the model's list table: add or override columns by id, hide via `viewDefaults.columnVisibility`, reorder via `viewDefaults.columnOrder`.
* Everything is typed against the panel-generated `CustomFieldsRegistry`. Valid `model`, `zone`, and built-in field ids autocomplete, and an invalid target fails `tsc`.
**Panel custom fields vs. the Custom Fields module.** `defineCustomFieldsConfig` (this section) is a UI surface. It renders, validates, and displays fields in the panels. It does not create database columns. To store extra data on an entity, use the backend [Custom Fields module](/rc/resources/customization/custom-fields), or wire your own API route or workflow. In the MVP, panel custom fields for `product` are submitted under `additional_data` and persisted onto the product's `metadata`.
For the full walkthrough (forms, sections, and list columns), see [Extend forms and tables](/rc/resources/tutorials/extend-forms-and-tables).
## Add a page
Create a `page.tsx` inside `src/routes/` and export a default React component. The route is determined by the file path.
```tsx src/routes/reviews/page.tsx theme={null}
import { Star } from "@medusajs/icons"
import type { RouteConfig } from "@mercurjs/dashboard-sdk"
export const config: RouteConfig = {
label: "Reviews",
icon: Star,
rank: 10,
}
export default function ReviewsPage() {
return
Reviews
}
```
A sidebar item is generated only when the page exports a `config` with a `label`. Pages without one are still routed. They just don't appear in the menu.
| Property | Type | Description |
| --------------- | --------------- | --------------------------------------------------------- |
| `label` | `string` | **Required.** Text shown in the sidebar menu |
| `icon` | `ComponentType` | Icon component (for example from `@medusajs/icons`) |
| `rank` | `number` | Sort order (lower numbers appear first) |
| `nested` | `string` | Parent path for nested menu items |
| `translationNs` | `string` | i18n namespace for the label |
| `public` | `boolean` | If `true`, the route is accessible without authentication |
Route files may also export a `loader` (React Router data loader) and `handle` (route metadata) alongside the default component.
Start the dev server. The SDK picks the file up automatically. This example creates a `/reviews` route with a "Reviews" sidebar item. No route registration, no configuration file.
**Matching paths replace, new paths append.** If your route's path matches a built-in page (for example `src/routes/products/page.tsx` maps to `/products`), your page **replaces** the built-in one. Any other path is added alongside the built-in routes. Delete the file and the built-in page returns. To change part of a built-in page without owning it, prefer a widget or a custom field.
### Routing conventions
File paths map to URL routes automatically.
| File path | Route | Description |
| ---------------------------------------- | --------------- | ------------------------------------------ |
| `src/routes/page.tsx` | `/` | Root page |
| `src/routes/reviews/page.tsx` | `/reviews` | Static segment |
| `src/routes/reviews/[id]/page.tsx` | `/reviews/:id` | Dynamic segment |
| `src/routes/reviews/[[id]]/page.tsx` | `/reviews/:id?` | Optional dynamic segment |
| `src/routes/search/[*]/page.tsx` | `/search/*` | Catch-all |
| `src/routes/(settings)/page.tsx` | Route grouping | Groups routes without adding a URL segment |
| `src/routes/dashboard/@sidebar/page.tsx` | Parallel route | Renders alongside parent |
## Branding
Set `name` and `logo` in the plugin options to customize the sidebar header.
```typescript vite.config.ts theme={null}
mercurDashboardPlugin({
medusaConfigPath: '../../packages/api/medusa-config.ts',
name: 'WeTest',
logo: 'https://ui-avatars.com/api/?name=WeTest&background=18181B&color=fff&size=200&bold=true&format=svg',
})
```
## Internationalization
```typescript src/i18n/index.ts theme={null}
export default {
en: {
reviews: {
title: "Reviews",
description: "Manage product reviews",
},
},
de: {
reviews: {
title: "Bewertungen",
description: "Produktbewertungen verwalten",
},
},
}
```
```tsx src/routes/reviews/page.tsx theme={null}
export const config: RouteConfig = {
label: "reviews.title",
icon: Star,
translationNs: "reviews",
}
```
```typescript vite.config.ts theme={null}
mercurDashboardPlugin({
medusaConfigPath: '../../packages/api/medusa-config.ts',
name: 'My Marketplace',
i18n: {
defaultLanguage: 'en',
},
})
```
## FAQ
Yes. `defineWidgetConfig` is re-exported from `@mercurjs/dashboard-sdk` and drives file-based widgets under `src/widgets/`. The difference from Medusa is the `before | after` placement suffix on the zone id and that each panel ships its own typed zone set. See [Add a widget](/rc/resources/tutorials/add-a-widget).
Use `defineCustomFieldsConfig` in `src/custom-fields/.tsx`. `forms[]` adds validated fields to create and edit forms, and `displays[]` adds, replaces, or removes fields in detail sections. See [Extend forms and tables](/rc/resources/tutorials/extend-forms-and-tables).
For a spot inside the page (a banner, an extra field, a column), use a [widget](#widgets) or a [custom field](#custom-fields). The rest of the page keeps all its behavior.
A sidebar item is only generated when the route file exports a `config` object with a `label`. Also check that the file is named exactly `page.tsx` (or `.ts`/`.jsx`/`.js`) under `src/routes/` and has a **default** export. Files without one are skipped entirely.
Make sure the panel's typed targets are registered with a single `src/extension-targets.d.ts` containing `/// ` (or the admin equivalent). Without it, zone, model, and nav ids aren't known to TypeScript. `create-mercur-app` ships this file.
No. All panel configuration is passed inline to `mercurDashboardPlugin()` in `vite.config.ts`. If you've seen references to a separate config file, they're outdated.
Both panels use the same SDK and conventions, and a file only targets the panel it lives in. Which built-in zones, models, and nav ids exist differs per panel (each ships its own `extension-targets.d.ts`). Today the widget zones and product custom fields are mounted in the **vendor** portal, and navigation overrides work in both.
## Next steps
Inject a component at a built-in zone with `defineWidgetConfig`.
Add fields and columns with `defineCustomFieldsConfig`.
Reorder, hide, and re-parent sidebar items.
Your first drop-in route, end to end.
# Medusa Cloud
Source: https://docs.mercurjs.com/resources/deployment/medusa-cloud
Deploy the Mercur backend, admin panel, and vendor panel on Medusa Cloud.
## Introduction
[Medusa Cloud](https://cloud.medusajs.com) is a dedicated hosting service for Medusa-based applications. It automatically detects changes on your repository, pulls and builds the code, and provisions a database, Redis, and file storage.
Head to the [Medusa Cloud signup page](https://cloud.medusajs.com/signup) to get started. After registration is complete and your account is active, authorize the Medusa Cloud GitHub app in your organization or personal account.
A single Medusa Cloud deployment serves everything: the backend API, the
**admin panel** at `/dashboard`, and the **vendor panel** at `/seller`. The
panels are built together with the backend and served from the same origin.
No separate hosting is needed.
## How it works
The `basic` template wires this up out of the box:
* During `build`, the API package builds both panels and bundles their production
output into the Medusa build artifact (`.medusa/server/dashboards/`), so hosts
that deploy only the artifact, like Medusa Cloud, carry the panels with it
(`packages/api/scripts/bundle-dashboards.mjs`).
* At runtime, the `admin-ui` and `vendor-ui` modules detect the bundled builds and
serve them statically at their configured paths (`/dashboard`, `/seller`).
* The panels bake the backend URL at build time from the `MERCUR_BACKEND_URL`
environment variable. Because they are served from the backend's own origin, all
API calls are same-origin. No cross-site cookie or CORS gymnastics.
Medusa Cloud builds the monorepo with `NODE_ENV=production`. In that mode the build
**fails fast** when `MERCUR_BACKEND_URL` is missing instead of shipping panels that
silently point at `http://localhost:9000`. Make sure to set it as described below.
## Prerequisites
* A Mercur project created with [`@mercurjs/cli`](/learn/introduction) and pushed to a GitHub repository
* A [Medusa Cloud](https://cloud.medusajs.com) account with the GitHub app authorized
* API keys for any third-party services you plan to use (Stripe, Resend, Algolia, etc.)
## Setup
From the Medusa Cloud dashboard, choose **Import your existing repository into the Cloud** (the Mercur project lives in your own GitHub repo, not in the bundled DTC/B2B starters).
On the **Repository** step, make sure the Medusa Cloud GitHub app is installed, pick your Mercur repository from the list, and click **Continue**.
On the **Configure** step, fill in:
* **Project name** and **Custom subdomain**. The subdomain determines your backend URL (`https://.medusajs.app`). You will reference it in the environment variables below.
* **Region** closest to your users.
* **Medusa root directory**: `/packages/api`.
* **Initial user:** the first admin email and password.
Leave **Storefront root directory** empty. The Mercur basic template doesn't ship a storefront.
Scroll down to **Environment variables** and add the **Backend Environment Variables** below. Medusa Cloud provisions the database, Redis, and file storage automatically, so you don't need to set those.
Only the following variables should be set:
```bash theme={null}
# Panels (build-time): the deployed backend origin, baked into the admin and
# vendor panels. Enable the **Build** toggle for this variable. The panels
# read it while they are being built, not at runtime.
MERCUR_BACKEND_URL=https://.medusajs.app
# CORS: the panels are served from the backend's own origin, so it is enough
# to list that origin (plus your storefront URL in STORE_CORS, if you have one)
STORE_CORS=https://.medusajs.app
ADMIN_CORS=https://.medusajs.app
VENDOR_CORS=https://.medusajs.app
AUTH_CORS=https://.medusajs.app
# Secrets
JWT_SECRET=
COOKIE_SECRET=
# Vendor panel URL used in seller emails and onboarding flows
MERCUR_VENDOR_URL=https://.medusajs.app/seller
# Stripe (customer payments + Mercur payout provider)
STRIPE_API_KEY=
STRIPE_WEBHOOK_SECRET=
STRIPE_PAYOUT_WEBHOOK_SECRET=
# Email (optional, e.g. Resend)
RESEND_API_KEY=
RESEND_FROM_EMAIL=
# Search (optional)
ALGOLIA_APP_ID=
ALGOLIA_API_KEY=
```
`MERCUR_BACKEND_URL` must have the **Build** toggle enabled in the Medusa Cloud
environment-variable settings. Without it, the production build stops with a clear
error rather than baking the localhost default into the panels.
For Stripe setup, including which keys go where and how to wire both webhooks,
see the [Stripe Connect integration
guide](/rc/resources/integrations/stripe-connect).
Click **Create**. The build and deploy process will start and can take a few minutes to complete.
You can change these variables later from the project's environment settings, and switch the deployment branch at any time. Medusa Cloud will automatically pull and rebuild your environment whenever a new commit is pushed to the selected branch. Remember that `MERCUR_BACKEND_URL` is baked into the panels at build time. Changing it requires a redeploy.
## After deployment
Once the build succeeds, everything is served from `https://.medusajs.app`:
| Surface | URL |
| ------------ | ------------------------------------------------- |
| Backend API | `https://.medusajs.app` |
| Admin panel | `https://.medusajs.app/dashboard` |
| Vendor panel | `https://.medusajs.app/seller` |
Open the admin panel and sign in with the **Initial user** you configured in the setup step. If a panel shows a "Dashboard not built" page, the bundling step did not run. Check the deployment's build logs for output from `bundle-dashboards`.
Prefer hosting the panels separately (for example on Vercel or Netlify)? That
still works: deploy `apps/admin` and `apps/vendor` as static Vite builds, set
`MERCUR_BACKEND_URL` (or `VITE_MERCUR_BACKEND_URL`) to the backend URL at build
time, and add the panel URLs to the backend's `ADMIN_CORS`, `VENDOR_CORS`, and
`AUTH_CORS` variables.
## FAQ
The bundling step didn't run during the build. Check the deployment's build logs for output from `bundle-dashboards`; the most common cause is a build that failed earlier (often a missing `MERCUR_BACKEND_URL` with the Build toggle off).
The backend URL is **baked into the panels at build time**, not read at runtime. After changing the variable, trigger a redeploy so the panels rebuild with the new value.
No. Medusa Cloud provisions Postgres, Redis, and file storage automatically and injects their connection variables. Only set the variables listed in the setup step.
## Next steps
Wire payments and payouts on the deployed environment.
Set up Mercur and run a marketplace.
# Self-host Mercur
Source: https://docs.mercurjs.com/resources/deployment/self-host
Deploy a Mercur marketplace to your own infrastructure: the API, the panels, and the databases behind them.
This guide covers the general steps to self-host a Mercur marketplace. Mercur is a
Medusa plugin, so deployment follows Medusa's model, with one addition: the Vendor
panel is a separate app. Apply these steps to the hosting provider of your choice.
## What you'll deploy
A Mercur marketplace has several parts.
* **PostgreSQL:** the primary database.
* **Redis:** session storage, the event bus, the workflow engine, and caching.
* **Mercur API:** a Medusa server running the Mercur plugin. You deploy it twice, one instance in server mode and one in worker mode.
* **Admin panel:** served by the API server.
* **Vendor panel:** a separate static app that talks to the Vendor API.
* **Storefront (optional):** your own frontend on the Store API.
Server mode handles API requests and serves the Admin panel. Worker mode runs
background work such as scheduled jobs and subscribers. Choose a host with at
least 2GB of RAM per instance.
## 1. Configure the API for production
Set three values in `medusa-config.ts` so the same build can run as either a
server or a worker.
```ts medusa-config.ts theme={null}
module.exports = defineConfig({
projectConfig: {
// ...
redisUrl: process.env.REDIS_URL,
workerMode: process.env.MEDUSA_WORKER_MODE as "shared" | "worker" | "server",
},
admin: {
disable: process.env.DISABLE_MEDUSA_ADMIN === "true",
},
})
```
The Admin panel is served by the server instance, so you disable it on the worker
instance. `redisUrl` moves sessions, events, and the workflow engine onto Redis.
## 2. Add a predeploy script
Run migrations before the app starts in production. Add a `predeploy` script to
`package.json`.
```json package.json theme={null}
{
"scripts": {
"predeploy": "medusa db:migrate"
}
}
```
## 3. Use production modules
The default project ships modules meant for development, such as the local file
provider. Swap them for production-ready ones and register them alongside
`withMercur` in `medusa-config.ts`.
* **Redis cache, event bus, and workflow engine:** move caching, events, and workflow state off the local process.
* **Redis locking provider:** coordinate work safely across instances.
* **S3 file provider:** store uploads durably.
* **A notification provider** such as SendGrid or Resend, for transactional email.
## 4. Set environment variables
Set these on each API instance.
| Variable | Description |
| ----------------------------------------- | ---------------------------------------------------------------- |
| `DATABASE_URL` | PostgreSQL connection string |
| `REDIS_URL` | Redis connection string |
| `JWT_SECRET` | Secret for signing auth tokens |
| `COOKIE_SECRET` | Secret for signing session cookies |
| `MEDUSA_WORKER_MODE` | `server` on the server instance, `worker` on the worker instance |
| `DISABLE_MEDUSA_ADMIN` | `false` on the server, `true` on the worker |
| `STORE_CORS` / `ADMIN_CORS` / `AUTH_CORS` | Allowed origins for the storefront, panels, and auth |
## 5. Deploy the API
Deploy the same build as two instances.
Run `bun run build` to compile the server and the Admin panel.
The `predeploy` script runs `medusa db:migrate`. Run it once before starting.
Set `MEDUSA_WORKER_MODE=server` and `DISABLE_MEDUSA_ADMIN=false`. This instance serves the API and the Admin panel.
Set `MEDUSA_WORKER_MODE=worker` and `DISABLE_MEDUSA_ADMIN=true`. This instance runs jobs and subscribers.
## 6. Deploy the Vendor panel
The Vendor panel is a separate Vite app. Build it with the API URL configured,
then host the static output on any static host or CDN.
The Admin panel ships with the API server. The Vendor panel deploys on its own,
the same way a storefront does.
## Next steps
Deploy without managing infrastructure yourself.
Wire up payments and payouts for production.
# Integrations
Source: https://docs.mercurjs.com/resources/integrations/overview
Extend Mercur with third-party providers and installable blocks.
Mercur integrates with third-party providers to extend your marketplace. Payout
providers settle seller earnings, and installable blocks add extra capabilities on
top of the core platform.
## Payout providers
A payout provider settles seller earnings to their connected accounts. Stripe
Connect ships out of the box. You can add your own provider against the same
interface.
Configure the Stripe Connect payout provider.
Accounts, onboarding, and the transfer pipeline.
## Blocks
Other integrations ship as installable blocks. You add the block, own its source
in your project, and update it explicitly.
Install a feature block into your project.
Package your own feature as a distributable block.
# Stripe Connect
Source: https://docs.mercurjs.com/resources/integrations/stripe-connect
Set up Stripe Connect for marketplace payments and seller payouts, from Stripe Dashboard configuration to the full end-to-end payment lifecycle.
A Mercur marketplace needs **two** Stripe integrations working together. The first is the standard Medusa payment provider, which charges customers at checkout. The second is the Mercur payout provider, which transfers funds from the platform to sellers after orders are fulfilled.
This guide covers both. It walks through setting up Stripe, configuring both providers, wiring up webhooks, and understanding how money flows from customer to seller.
This page is the configuration reference. For how payout accounts, onboarding, and the transfer pipeline work, see the [Payout](/platform/payout/overview) module.
Mercur uses Stripe's [Separate Charges and Transfers](https://docs.stripe.com/connect/separate-charges-and-transfers) model. The platform collects the full payment from the customer, then creates transfers to each seller's connected account. This makes the platform the **Merchant of Record**, responsible for VAT, disputes, and chargebacks.
## Prerequisites
* A [Stripe account](https://dashboard.stripe.com) with Connect enabled
* Stripe Secret API key and Publishable API key
* A running Mercur project ([installation guide](/learn/introduction))
* Node.js 20+
## Architecture overview
The payment lifecycle in a Mercur marketplace flows through two distinct Stripe integrations:
```mermaid theme={null}
flowchart LR
C[Customer] -->|PaymentIntent| P[Platform Stripe Account]
P -->|Transfer| S1[Seller A Connected Account]
P -->|Transfer| S2[Seller B Connected Account]
S1 -->|Payout| B1[Seller A Bank]
S2 -->|Payout| B2[Seller B Bank]
```
The two integrations serve different purposes:
| Integration | Package | Purpose | Webhook endpoint |
| ----------------- | --------------------------------- | ----------------------------- | ------------------------------ |
| Customer payments | `@medusajs/medusa/payment-stripe` | Charges customers at checkout | `/hooks/payment/stripe_stripe` |
| Seller payouts | `@mercurjs/payout-stripe-connect` | Transfers funds to sellers | `/hooks/payout` |
## Stripe Dashboard setup
In your Stripe Dashboard, go to **Settings → Connect settings** and enable Connect. If you're starting fresh, Stripe will walk you through an onboarding flow to activate your platform account.
Go to **Developers → API keys**. You'll need:
* **Secret key:** starts with `sk_test_` (test mode) or `sk_live_` (production)
* **Publishable key:** starts with `pk_test_` or `pk_live_`
Add the following to your `.env` file:
```bash theme={null}
STRIPE_API_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PAYOUT_WEBHOOK_SECRET=whsec_...
```
## Configure Medusa payment provider
The Medusa payment provider handles customer-facing charges. Add it to your `medusa-config.ts`:
```ts theme={null}
module.exports = defineConfig({
// ...
modules: [
{
resolve: "@medusajs/medusa/payment",
options: {
providers: [
{
resolve: "@medusajs/medusa/payment-stripe",
id: "stripe",
options: {
apiKey: process.env.STRIPE_API_KEY,
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
capture: false, // Use manual capture for marketplace flow
automatic_payment_methods: true,
},
},
],
},
},
],
})
```
### Provider options
| Option | Type | Description |
| --------------------------- | --------- | ----------------------------------------------------------------------- |
| `apiKey` | `string` | Stripe secret API key |
| `webhookSecret` | `string` | Signing secret for the payment webhook endpoint |
| `capture` | `boolean` | Set to `false` for manual capture (required for marketplace split flow) |
| `automatic_payment_methods` | `boolean` | Enable Stripe's automatic payment method detection |
| `paymentDescription` | `string` | Optional description shown on customer's bank statement |
After configuring the provider, enable Stripe as a payment method in your Admin Panel: **Settings → Regions → Edit region → Payment providers → Stripe**.
## Configure Mercur payout provider
The payout provider handles transfers from the platform to seller connected accounts. Add it alongside the payment provider in `medusa-config.ts`:
```ts theme={null}
module.exports = defineConfig({
// ...
modules: [
// ... payment provider above
{
resolve: "@mercurjs/core/modules/payout",
options: {
providers: [
{
resolve: "@mercurjs/payout-stripe-connect",
id: "stripe-connect",
options: {
apiKey: process.env.STRIPE_API_KEY,
webhookSecret: process.env.STRIPE_PAYOUT_WEBHOOK_SECRET,
accountValidation: {
detailsSubmitted: true,
chargesEnabled: true,
payoutsEnabled: true,
noOutstandingRequirements: true,
requiredCapabilities: [],
},
},
},
],
},
},
],
})
```
### Payout provider options
| Option | Type | Default | Description |
| ------------------- | -------- | --------- | -------------------------------------------------------- |
| `apiKey` | `string` | - | Stripe secret API key |
| `webhookSecret` | `string` | - | Signing secret for the payout webhook endpoint |
| `accountValidation` | `object` | See below | Controls when a connected account is considered `ACTIVE` |
### Account validation options
These options determine what conditions a Stripe connected account must meet before Mercur marks it as `ACTIVE` and allows payouts:
| Option | Type | Default | Description |
| --------------------------- | ---------- | ------- | ----------------------------------------------------------------------------------------- |
| `detailsSubmitted` | `boolean` | `true` | Require Stripe to mark onboarding details as submitted |
| `chargesEnabled` | `boolean` | `true` | Require the account to be enabled for charges |
| `payoutsEnabled` | `boolean` | `true` | Require the account to be enabled for payouts |
| `noOutstandingRequirements` | `boolean` | `true` | Treat any pending Stripe requirements as a restricted account |
| `requiredCapabilities` | `string[]` | `[]` | Require specific Stripe capabilities to be active (e.g. `["card_payments", "transfers"]`) |
### Payout module options
The payout module itself accepts timing options that control the capture and payout pipeline:
| Option | Type | Default | Description |
| --------------------------- | --------- | ---------------------- | ------------------------------------------------------------------------------------------------- |
| `disabled` | `boolean` | `false` | Disable automatic capture checks and daily payout jobs |
| `authorizationWindowMs` | `number` | `604800000` (7 days) | How long a payment authorization remains valid |
| `sellerActionWindowMs` | `number` | `259200000` (72 hours) | How long sellers have to accept/fulfill before cancellation |
| `captureSafetyBufferMs` | `number` | `86400000` (24 hours) | Safety margin before authorization expiry. Capture happens before `authorization expiry - buffer` |
| `requiredFulfillmentStatus` | `string` | `"fulfilled"` | Minimum fulfillment status before an order is eligible for capture |
## Set up webhooks
You need **two separate webhook endpoints** in Stripe: one for payment events, one for payout events.
These are two distinct webhook endpoints, each with its own signing secret. Do not combine them into a single endpoint.
In the Stripe Dashboard, go to **Developers → Webhooks → Add endpoint**.
* **Endpoint URL**: `https://{your-backend-url}/hooks/payment/stripe_stripe`
* **Events to subscribe to**:
* `payment_intent.succeeded`
* `payment_intent.amount_capturable_updated`
* `payment_intent.payment_failed`
* `charge.refunded`
Copy the signing secret and set it as `STRIPE_WEBHOOK_SECRET` in your `.env`.
Add a second webhook endpoint in the Stripe Dashboard.
* **Endpoint URL**: `https://{your-backend-url}/hooks/payout`
* **Events to subscribe to**:
* `account.updated`
Copy the signing secret and set it as `STRIPE_PAYOUT_WEBHOOK_SECRET` in your `.env`.
## How the payment flow works
Here's a concrete example. A customer buys items from two sellers:
* **Seller A**: €40 (product)
* **Seller B**: €30 (product)
* **Shipping**: €10
* **Cart total**: €80
### Step 1: Authorize payment
At checkout, a single `PaymentIntent` is created for €80 with `capture_method: "manual"`. The customer authenticates once (SCA-compliant). No money moves yet. The funds are held on the customer's card.
### Step 2: Split orders
Mercur's `completeCartWithSplitOrdersWorkflow` groups items by seller and creates separate orders: one for Seller A (€40) and one for Seller B (€30), plus shipping allocation.
### Step 3: Seller acceptance and fulfillment
Each seller reviews and fulfills their order through the Vendor Portal. The payout module's `sellerActionWindowMs` (default: 72 hours) defines how long sellers have to act.
### Step 4: Capture payment
Once orders meet the `requiredFulfillmentStatus` (default: `"fulfilled"`), the platform captures the authorized payment. The capture-check job runs automatically and respects the `captureSafetyBufferMs` to ensure capture happens before authorization expiry.
Card authorizations typically expire after **7 days**. The default configuration gives sellers 72 hours to fulfill, with a 24-hour safety buffer before capture. If your business requires longer seller action windows, consider whether the 7-day authorization window is sufficient.
### Step 5: Commission calculation and transfers
After capture, Mercur calculates commission for each order and creates Stripe Transfers for the net amounts:
```
Seller A net = €40 - commission
Seller B net = €30 - commission
```
Each transfer is linked to the original charge via `source_transaction` and grouped by `transfer_group` (the order ID).
### Step 6: Bank payouts
Stripe automatically pays out connected account balances to sellers' bank accounts on the configured payout schedule. Mercur tracks payout status changes via the `account.updated` webhook.
## Seller onboarding
When a seller creates a payout account, the following lifecycle begins:
```
PENDING → (Stripe onboarding) → ACTIVE
↓
RESTRICTED → ACTIVE (remediation)
↓
REJECTED (permanent)
```
1. **Account creation.** Mercur calls `stripe.accounts.create({ type: "express" })`, creating a Stripe Express connected account. The payout account starts in `PENDING` status.
2. **Onboarding link.** The seller receives a Stripe-hosted onboarding URL via `stripe.accountLinks.create()`. They complete identity verification, bank account setup, and any required compliance steps directly on Stripe.
3. **Webhook activation.** When the seller completes onboarding, Stripe sends an `account.updated` webhook. The provider evaluates the account against the `accountValidation` options and transitions the status:
* All validation checks pass → `ACTIVE`
* Missing requirements or disabled reason → `RESTRICTED`
* Disabled reason starts with `rejected.` → `REJECTED`
4. **Ongoing monitoring.** Stripe may send additional `account.updated` events if requirements change. The provider re-evaluates and updates the status accordingly.
For more details on payout accounts, balances, and transactions, see [Payout](/rc/learn/payouts).
## Transfers vs payouts
Stripe uses two distinct concepts for moving money, and it's important to understand the difference:
| | Transfer | Payout |
| -------------------- | -------------------------------------------------------------- | ------------------------------------------------------------------- |
| **What it does** | Moves funds from platform balance to connected account balance | Moves funds from connected account balance to seller's bank account |
| **Speed** | Instant ledger movement | 1–3 business days (varies by country) |
| **Status lifecycle** | None. Transfers are immediate | `pending` → `in_transit` → `paid` / `failed` |
| **Who triggers it** | Mercur (via `stripe.transfers.create()`) | Stripe (on the connected account's payout schedule) |
| **Mercur tracking** | Transfer created with status `PAID` immediately | Status tracked via webhooks |
When Mercur's `createPayout` method is called, the Stripe Connect provider creates a **Transfer** (platform → connected account). The actual bank payout (connected account → bank) happens automatically on Stripe's schedule.
## Refunds and reversals
Refunding a charge does **not** automatically reverse the associated transfers. These are two separate operations:
1. **Refund the PaymentIntent.** Returns funds to the customer's payment method
2. **Reverse the Transfer(s).** Claws back funds from the connected account(s)
For a full refund of a multi-seller order, you would need to reverse each seller's transfer individually. For partial refunds, you need to calculate how much to reverse from each seller based on which items are being refunded.
Refund and transfer reversal orchestration is an area where custom workflow logic may be needed depending on your business rules. See Stripe's documentation on [reversing transfers](https://docs.stripe.com/connect/separate-charges-and-transfers#reverse-transfers) for the API details.
## EU/EEA considerations
### Merchant of Record
Using Separate Charges and Transfers makes the platform the Merchant of Record. This means:
* The platform collects and remits VAT
* The platform handles consumer disputes and chargebacks
* The platform is responsible for PSD2 compliance
### Strong Customer Authentication (SCA)
SCA is enforced at **authorization time**, not capture time. Since the customer authenticates once during checkout, no additional SCA step is needed when the platform captures later. The single authorization at checkout satisfies the SCA requirement for the entire order.
### Authorization windows
Card authorizations in Europe typically last **7 days**. Design your seller acceptance workflow to complete well within this window. The payout module options give you control over the timing:
```ts theme={null}
{
resolve: "@mercurjs/core/modules/payout",
options: {
authorizationWindowMs: 7 * 24 * 60 * 60 * 1000, // 7 days
sellerActionWindowMs: 72 * 60 * 60 * 1000, // 72 hours
captureSafetyBufferMs: 24 * 60 * 60 * 1000, // 24 hours
// ...providers
},
}
```
## Testing
### Test mode
Use Stripe test mode keys (`sk_test_`, `pk_test_`) during development. All connected accounts and payments created in test mode are isolated from production.
**Test card number**: `4242 4242 4242 4242` (any future expiry, any CVC)
### Webhook forwarding with Stripe CLI
Since webhooks need to reach your local machine during development, use the [Stripe CLI](https://docs.stripe.com/stripe-cli) to forward events. You need **two separate listeners**, one for each webhook endpoint:
```bash theme={null}
# Terminal 1: Payment webhooks
stripe listen --forward-to localhost:9000/hooks/payment/stripe_stripe
# Terminal 2: Payout webhooks
stripe listen --forward-to localhost:9000/hooks/payout
```
Each `stripe listen` process outputs its own webhook signing secret. Use these as your `STRIPE_WEBHOOK_SECRET` and `STRIPE_PAYOUT_WEBHOOK_SECRET` respectively during local development.
### Testing the full flow
1. Create a seller and complete Stripe's test onboarding flow
2. Add products from the seller to a cart
3. Complete checkout using the test card
4. Verify orders are split by seller in the Admin Panel
5. Fulfill the orders through the Vendor Portal
6. Confirm transfers appear in the Stripe Dashboard under the connected account
## FAQ
They belong to two different integrations: the payment webhook feeds Medusa's payment provider (charges, captures, refunds), while the payout webhook feeds Mercur's payout provider (connected-account status, transfer status). Each endpoint verifies its own signing secret. Combining them silently breaks whichever side's signature doesn't match.
The marketplace flow authorizes at checkout and captures later, after sellers fulfill. Automatic capture would take the money before the split-order pipeline (fulfillment checks, commission, payouts) has run. `capture: false` hands that timing to the payout module's capture job.
Check the `accountValidation` options: by default the account must have details submitted, charges and payouts enabled, and **no outstanding requirements**. Stripe often adds follow-up requirements (e.g. extra KYC) after initial onboarding. The account shows as `RESTRICTED` until they're cleared.
The platform. Separate Charges and Transfers makes the platform the Merchant of Record. See [EU/EEA considerations](#eueea-considerations) for what that entails.
## Next steps
Payout accounts, onboarding, and the transfer pipeline.
Statuses, jobs, and webhook events.
# How to Add a Block
Source: https://docs.mercurjs.com/resources/tutorials/add-a-block
Install the reviews block end-to-end and follow it live across the admin, vendor, and storefront surfaces.
Install a block and see the feature running on every surface it touches.
Blocks are complete features installed as **source code** into your project. A block bundles the backend module, workflows, API routes, and panel UI. This tutorial installs the `reviews` block and follows it across each surface it appears on.
Blocks are copied, not installed as dependencies. `add` writes the block's source files into your project through the aliases in `blocks.json`. You own and can edit every file afterwards. Updates are opt-in through `diff` and `add --overwrite`. When you outgrow the catalog, [build your own block](/rc/resources/tutorials/build-a-block).
## Goal
Install reviews as a block and confirm it runs everywhere it appears.
## Install the block
Search the registry and inspect what the block ships before you install it.
```bash Terminal theme={null}
bunx @mercurjs/cli@latest search --query reviews
bunx @mercurjs/cli@latest view reviews
```
`view` lists the block's files by target (API, admin, vendor) and its dependencies.
Run `add` to copy the block into your project.
```bash Terminal theme={null}
bunx @mercurjs/cli@latest add reviews
```
The CLI copies the source into the directories mapped by your `blocks.json` aliases and prints the block's post-install instructions: module registration, middlewares, and migrations.
The block introduced a reviews module. Generate and run its migrations, then refresh the typed route map.
```bash Terminal theme={null}
cd packages/api
bunx medusa db:generate reviews
bunx medusa db:migrate
bunx @mercurjs/cli@latest codegen
```
Start the project. Reviews now appear in the admin panel (moderation), the vendor portal (per-seller reviews), and the Store API (customer-facing review routes).
## Verify
Check that the install landed cleanly:
* **Files:** the block's files exist in your repo under the alias-mapped paths.
* **API:** migrations ran cleanly and the API boots.
* **Panels and store:** the admin and vendor panels show their reviews pages, and the store review endpoints respond.
* **No drift:** `bunx @mercurjs/cli@latest diff reviews` reports no drift from the registry.
## FAQ
The CLI asks before overwriting existing files, or you can force it with `--overwrite`. If you have customized a page the block also ships, merge by hand. You are merging source, not resolving package versions.
`bunx @mercurjs/cli@latest diff reviews` shows what changed in the registry since you installed. Take updates with `add reviews --overwrite`, then re-apply any local edits afterwards.
## Next steps
# How to Add a Widget
Source: https://docs.mercurjs.com/resources/tutorials/add-a-widget
Render your own React component in a named zone on a built-in panel page without forking it.
A widget is a React component attached to a named zone on a built-in page. You drop one file under `src/widgets/`, and the SDK renders it at that zone while the rest of the page stays exactly as shipped, including its data fetching, filters, and pagination.
This is the lightest way to add UI to a page you don't own. Reach for it first when you want to add something to an existing screen.
A widget is additive, not a replacement. Unlike a drop-in route, which owns the whole page, a widget layers your component onto the built-in page at a documented zone.
## What you'll build
A tip banner above the vendor product list, rendered from a single file, with the list itself untouched.
## Register the typed targets
Widget zones are typed ids that the vendor panel generates from its own pages and ships as `@mercurjs/vendor/extension-targets`. Register them once so the ids resolve everywhere, with a single ambient reference in your app's `src`.
```typescript apps/vendor/src/extension-targets.d.ts theme={null}
///
```
Projects from `create-mercur-app` already ship this file. With it present, an invalid zone fails `tsc` instead of silently doing nothing.
## Add the widget
Drop a file under `src/widgets/`. Export the component as the default and a `config` built with `defineWidgetConfig`. The `zone` names where it renders.
```tsx apps/vendor/src/widgets/product-list-banner.tsx theme={null}
import { defineWidgetConfig } from "@mercurjs/dashboard-sdk"
import { Container, Text } from "@medusajs/ui"
export const config = defineWidgetConfig({
zone: "product.list.before",
})
export default function ProductListBanner() {
return (
Tip: bulk-import products from the Products menu.
)
}
```
A zone id reads `..`. The last segment is the placement.
| Placement | Effect |
| --------- | ----------------------------------------------- |
| `before` | Renders before the built-in content of the zone |
| `after` | Renders after the built-in content |
Multiple `before` or `after` widgets on the same zone stack in registration order.
Start the project and open the vendor portal. Widget files hot-reload. The banner appears above the product list, and the table below it works exactly as before.
```bash Terminal theme={null}
bun run dev
```
## Available zones
These zones are mounted today in the vendor portal.
| Zone | Where it renders |
| ---------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| `product.list.before` / `.after` | Vendor product list page |
| `seller.setup.before` / `.after` | The store-setup and onboarding surface (dashboard home plus store settings), passed the `seller` as `data` |
| `login.logo.*` | The logo slot on the public login screen |
| `login.before.*` / `login.after.*` | Around the login form, rendered before authentication |
The full, valid set is typed as `WidgetZoneId` and generated into `@mercurjs/vendor/extension-targets` from the panel's own zone hosts. Let your editor autocomplete `zone:` to see every option. A zone no page renders can't be targeted and won't type-check.
## Verify
1. The tip banner renders above the product list.
2. Search, filter, and paginate the list. All built-in behavior still works.
3. Change the zone to `product.list.after` and reload. The banner moves below the list.
4. Set `zone: "not.a.zone"`. `tsc` (`bun run lint`) fails with a "not assignable to `WidgetZoneId`" error.
5. Delete the file. The banner disappears, and nothing else changes.
## FAQ
Yes. `zone` accepts an array (`zone: ["product.list.before", "login.after.before"]`), and the same component renders at each.
Yes. A [block](/rc/learn/blocks) can include `src/widgets/` files in its `vendor_ui` or `admin_ui` entry, and they're aggregated just like the host app's. Installing the block adds the widget with no wiring.
The zone set is per panel and generated from each panel's pages. Today the mounted zones live in the vendor portal (`product.list.*`, `login.*`). The admin panel exposes navigation and product custom fields. Check `@mercurjs/admin/extension-targets` for its current zones.
## Next steps
Add validated fields and columns with defineCustomFieldsConfig.
Add a store-setup field and persist it through a workflow hook.
# How to Add an Action Button
Source: https://docs.mercurjs.com/resources/tutorials/add-order-detail-button
Add a Copy link button to the vendor order detail page with a widget, without forking or overriding the page.
Add a small piece of UI to a built-in panel screen without copying it.
The order detail page is a panel screen you don't own. To add a button, a badge, or a note to it, you drop a **widget** at one of its zones. The SDK renders your component there, and the rest of the page keeps working exactly as shipped. This tutorial adds a **Copy link** button to the order summary section that copies a link to the order.
A widget is additive, not a replacement. It layers your component onto a built-in page at a documented zone. Reach for it first whenever you just want to add something to an existing screen.
## Add the button
Drop a file under `src/widgets/`. Export the component as the **default** and a `config` built with `defineWidgetConfig`. Target `orders.detail.summary.after`. Your component renders in the order summary section footer and receives the loaded order as `data`.
```tsx apps/vendor/src/widgets/order-copy-link.tsx theme={null}
import { defineWidgetConfig } from "@mercurjs/dashboard-sdk"
import type { HttpTypes } from "@medusajs/types"
import { Button, Container, toast } from "@medusajs/ui"
export const config = defineWidgetConfig({
zone: "orders.detail.summary.after",
})
const OrderCopyLink = ({ data: order }: { data?: HttpTypes.AdminOrder }) => {
if (!order) return null
const orderLink = `${window.location.origin}/orders/${order.id}`
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(orderLink)
toast.success("Link copied")
} catch {
toast.error("Couldn't copy the link")
}
}
return (
)
}
export default OrderCopyLink
```
A zone id is `...`. The last segment is the placement.
| Placement | Effect |
| --------- | ----------------------------------------------- |
| `before` | Renders before the built-in content of the zone |
| `after` | Renders after the built-in content |
Multiple `before` or `after` widgets on the same zone stack in registration order.
Start the project and open any order in the vendor portal. Widget files hot-reload. The Copy link button appears in the summary section footer, and the rest of the page is untouched.
```bash Terminal theme={null}
bun run dev
```
## Order detail zones
These zones are mounted on the vendor order detail page.
| Zone | Where it renders |
| ----------------------------------------- | ------------------------------------------------------ |
| `orders.detail.summary.before` / `.after` | Inside the order summary section (around its footer) |
| `orders.detail.main.before` / `.after` | Around the main column (summary, payment, fulfillment) |
| `orders.detail.side.before` / `.after` | Around the sidebar (customer, activity) |
Each zone is passed the loaded `order` as `data`. The full, valid set is typed as `WidgetZoneId` and generated from the panel's own zone hosts. Let your editor autocomplete `zone:` to see every option.
A zone that no page renders can't be targeted and won't type-check. Set `zone: "not.a.zone"` and `tsc` (`bun run lint`) fails with a "not assignable to `WidgetZoneId`" error.
## Verify
Confirm the widget works end to end.
1. Open an order. The Copy link button renders in the summary section footer.
2. Click it. The link is copied and a toast appears.
3. Change the zone to `orders.detail.side.before` and reload. The button moves to the top of the sidebar.
4. Set `zone: "not.a.zone"`. `tsc` (`bun run lint`) fails with a "not assignable to `WidgetZoneId`" error.
5. Delete the file. The button disappears, and nothing else changed.
## FAQ
The zone passes the loaded order as `data` (`HttpTypes.AdminOrder`): id, display id, totals, items, `payment_collections`, customer, and more. Build the link (or any UI) from it.
Yes. Put the same file in a [block](/rc/learn/blocks)'s `vendor_ui` entry under `src/widgets/`. Installing the block adds the button with no wiring.
The zone renders any React component, such as a badge, an action menu, or a whole section. You have the full order in `data`.
## Next steps
The general widget model and the full list of zones.
Add validated fields and columns with defineCustomFieldsConfig.
# How to Create Variant-Axis Attributes
Source: https://docs.mercurjs.com/resources/tutorials/attributes-and-variant-axes
Build an attribute catalog with a filterable attribute, a global variant axis, and an inline product-scoped axis.
Attributes give the shared catalog structured, typed data. For `multi_select` attributes, they can also drive variant generation.
This tutorial sets up the two kinds that matter most: a plain filterable attribute and a variant axis. A variant axis is a native Medusa global product option under the hood.
**A variant axis IS a product option.** Attributes marked `is_variant_axis` are not a parallel system bolted onto products. Each one mirrors one-to-one onto a Medusa `ProductOption` and its values. Variants are then built with Medusa's standard machinery (`variants[].options`), exactly as in a plain Medusa project. Non-axis attributes never become options. They attach as plain value links. This is why only `multi_select` attributes can be axes: an axis needs an enumerable set of values to combine into variants.
## What you'll build
* **Material:** a global `multi_select`, filterable attribute used for storefront filtering.
* **Color:** a global variant axis shared across the catalog.
* **Fit:** an inline, product-scoped axis created on the fly from a product form.
## Global vs product-scoped
| Kind | Backed by | Appears in the global catalog? | Use for |
| ------------------------ | ------------------------------------------------- | ------------------------------ | ----------------------------------------------------------------- |
| Global attribute | Shared `ProductOption` (when axis) or value links | Yes | Data every product can use, such as Material, Color, or Condition |
| Product-scoped attribute | Exclusive, product-owned option (when axis) | No | One-off fields or axes for a single product |
## Build the attribute catalog
In the Admin Panel, the operator owns the attribute catalog. Create **Material** as a global `multi_select` attribute with values like Cotton, Wool, or Linen, and turn on `is_filterable`.
Global attributes (`product_id = null`) can be attached to any product and linked to categories, so the right attributes surface for the right product types.
Since it is not a variant axis, Material describes the product. It never generates variants.
Create **Color** the same way, but enable `is_variant_axis`. This is only allowed for `multi_select` attributes. The platform rejects the flag on any other type.
Because Color is a **global** axis, it is backed by one shared product option. Every product that uses it links to that option, restricted to the subset of values the product actually offers. So "Color" means the same thing across the whole catalog, while one product can offer only Red and Blue.
Products manage attributes through one **batch** endpoint that adds, removes, and updates in a single request.
```bash Terminal theme={null}
curl -X POST "http://localhost:9000/vendor/products/prod_123/attributes/batch" \
-H "Authorization: Bearer " \
-H "Content-Type: application/json" \
-d '{
"add": [
{ "id": "pattr_material", "value_ids": ["pattrval_cotton"] },
{ "id": "pattr_color", "value_ids": ["pattrval_red", "pattrval_blue"] }
]
}'
```
At product create time, the same entry shape is passed as a unified `attributes[]` array. Because Color is an axis, selecting Red and Blue makes them available as variant options. Variants are then defined with standard Medusa `variants[].options` mapping `"Color"` to `"Red"` or `"Blue"`.
Sometimes one product needs an axis that does not belong in the shared catalog. Define it **inline** by `title` instead of referencing an `id`.
```bash Terminal theme={null}
curl -X POST "http://localhost:9000/vendor/products/prod_123/attributes/batch" \
-H "Authorization: Bearer " \
-H "Content-Type: application/json" \
-d '{
"add": [
{ "title": "Fit", "values": ["Slim", "Regular"], "is_variant_axis": true }
]
}'
```
This creates a product-scoped attribute on the fly, backed by an **exclusive**, product-owned option. It does not appear in the global attribute list. It belongs to this product alone.
Attribute changes on products submitted by vendors flow through the same [change-request pipeline](/rc/learn/product-requests) as other product edits: `ATTRIBUTE_ADD`, `ATTRIBUTE_UPDATE`, or `ATTRIBUTE_REMOVE` actions the operator reviews.
## Verify
1. **Material** and **Color** appear in the Admin Panel's attribute catalog. **Fit** does not, because it is product-scoped.
2. The product detail shows Material as descriptive data and Color and Fit as variant axes.
3. The product's variants combine the selected Color and Fit values, built from real product options.
4. The Store API exposes Material for filtering on product listings (`is_filterable`).
## FAQ
An axis needs an enumerable, finite value set to combine into variants, for example Red/Blue times Slim/Regular. Free text has no enumerable values, and a toggle's two fixed values rarely describe purchasable variations. Only `multi_select` qualifies, and the platform enforces it.
Non-axis attributes attach to a product as plain **value links**: descriptive data for display and filtering. Axis attributes are mirrored onto real Medusa **product options**, which participate in variant generation. Same authoring UI, structurally different underneath.
Not automatically. A product-scoped axis is backed by an exclusive option owned by that product. Create the global attribute in the catalog and re-attach products to it. Treat inline attributes as intentionally local.
## Next steps
Types, fields, and the full attribute-to-option mapping.
How sellers list against the variants your axes generate.
# How to Build a Block
Source: https://docs.mercurjs.com/resources/tutorials/build-a-block
Author a reusable feature as a block covering backend, panel UI, and docs, then build it into a registry and install it into any Mercur project.
Blocks are how features travel between Mercur projects. You ship them as source code copied into the target project, not as npm packages you depend on.
Anything you build once, such as a module, workflows, routes, or panel pages, can be packaged as a block, published through a registry, and installed with `mercurjs add`. This tutorial builds a minimal "announcements" block and ships it through your own registry.
**Blocks are source, not dependencies.** When someone installs your block, they get the files: editable, diffable, theirs. Updates are opt-in via `mercurjs diff` and `add --overwrite`, never forced through a lockfile. That is the trade: you give up automatic upgrades, and users gain full ownership. Design blocks so they read cleanly after install.
## What you'll build
You build an `announcements` block containing a module (data model and service), a vendor API route, and a vendor portal page. Then you build it into registry JSON and install it into a Mercur project.
## File types and where they land
Each file in a block carries a `type` that maps to an alias in the consumer's `blocks.json`:
| Type | Lands at (default aliases) |
| ------------------- | ------------------------------------- |
| `registry:module` | the API package's modules directory |
| `registry:workflow` | the API package's workflows directory |
| `registry:api` | `packages/api/src` |
| `registry:link` | the API package's links directory |
| `registry:vendor` | `apps/vendor/src` |
| `registry:admin` | `apps/admin/src` |
| `registry:lib` | shared lib directory |
## Author and ship the block
A registry is a project with a `registry.json` and block sources under `src/`. Each block follows the standard directory convention, one folder per concern:
```
my-registry/
├── registry.json
└── src/
└── announcements/
├── modules/announcements/ # data model, service, index
├── api/vendor/announcements/ # route.ts, validators.ts
└── vendor/routes/announcements/
└── page.tsx # vendor portal page
```
Write the files exactly as they should land in a consumer's project: real imports, real Medusa module definitions. The build step resolves imports and rewrites them to the consumer's path aliases at install time. For the panel page, use the same conventions as any [custom panel page](/rc/resources/tutorials/custom-panel-page): a default export plus a `config` for the sidebar entry.
Add one entry per block to the `items` array.
```json registry.json theme={null}
{
"$schema": "https://registry.mercurjs.com/registry.json",
"name": "@my-org",
"homepage": "https://my-org.com",
"items": [
{
"name": "announcements",
"description": "Marketplace announcements with a vendor portal feed.",
"dependencies": [],
"registryDependencies": [],
"docs": "## Setup\n\nRegister the module in `medusa-config.ts`, then run `bunx medusa db:generate announcements && bunx medusa db:migrate`, and finally `bunx @mercurjs/cli@latest codegen`.",
"categories": ["module", "api", "vendor"],
"files": [
{ "path": "announcements/modules/announcements/index.ts", "type": "registry:module" },
{ "path": "announcements/api/vendor/announcements/route.ts", "type": "registry:api" },
{ "path": "announcements/vendor/routes/announcements/page.tsx", "type": "registry:vendor" }
]
}
]
}
```
Two fields do the heavy lifting. **`type`** on each file decides where the file lands, and **`docs`** is the markdown shown after install. Put every manual step in `docs`: module registration, migrations, middleware, codegen. It is the only instruction the installer sees.
Run the CLI build from the registry root.
```bash Terminal theme={null}
bunx @mercurjs/cli@latest build
```
This reads `registry.json`, resolves each block's imports, embeds file contents, and writes one JSON per block into `r/`: `r/announcements.json`, plus an index `r/registry.json`.
Serve the `r/` directory from any static host (GitHub Pages, Vercel, S3, or anything that makes `{name}.json` publicly reachable).
In a consumer project, register your registry in `blocks.json`.
```json blocks.json theme={null}
{
"registries": {
"@my-org": "https://my-registry.example.com/r/{name}.json"
}
}
```
Then install the block.
```bash Terminal theme={null}
bunx @mercurjs/cli@latest add @my-org/announcements
```
The CLI fetches the JSON, maps each file's `type` to the consumer's aliases, rewrites imports, and prints your `docs` instructions.
## Verify
Confirm the block built and installed correctly:
1. `r/announcements.json` exists after the build and embeds every file's content.
2. In the consumer project, the files landed under the alias-mapped paths and imports resolve.
3. After following your own `docs` steps (module registration, migrations, codegen), `bun run build` passes and the vendor portal shows the Announcements page.
4. `bunx @mercurjs/cli@latest diff @my-org/announcements` reports no changes. The installed copy matches the registry.
## FAQ
Other blocks go in `registryDependencies` (for example `@my-org/reviews`). The CLI installs them in order automatically. NPM packages go in `dependencies`. The build also auto-detects them from your imports, so you rarely list transitive ones by hand.
Yes. Use the object form with headers in the consumer's `blocks.json`: `{ "url": "…/{name}.json", "headers": { "Authorization": "Bearer ${REGISTRY_TOKEN}" } }`. The env var is resolved from the installer's environment. See [Registry](/rc/learn/registry).
They run `mercurjs diff ` to compare their local copy against your registry, then `add --overwrite` to take the new version. Because blocks are source, consumers with local edits merge deliberately rather than being force-upgraded.
## Next steps
The full registry.json schema, auth, and block dependencies.
What blocks can contain and how consumers manage them.
# How to Add a Custom API Route
Source: https://docs.mercurjs.com/resources/tutorials/custom-api-route
Add a backend endpoint, regenerate the route map, and call it from a panel page with full type safety.
Mercur's typed API client is generated from your actual route files, not hand-maintained. A custom endpoint you add to the API package becomes a first-class, fully typed client call after one codegen run.
This tutorial walks the whole loop: route, then codegen, then a typed call from a custom panel page.
The contract is generated, not declared. You never write an interface for your endpoint. `mercurjs codegen` reads the route's handler and validators and emits the `Routes` type the client consumes, so the panel call site breaks at compile time the moment the backend changes. This loop is also what makes Mercur projects reliable targets for AI agents. See [Building with AI](/rc/resources/ai/overview).
## What you'll build
A `GET /vendor/sales-summary` endpoint returns the seller's order count. You call it from a custom vendor portal page via `client.vendor.salesSummary.query()` with inferred types.
## Build the loop
API routes follow Medusa's file conventions inside your API package. The URL path mirrors the directory path.
```typescript packages/api/src/api/vendor/sales-summary/route.ts theme={null}
import { AuthenticatedMedusaRequest, MedusaResponse } from "@medusajs/framework/http"
import { ContainerRegistrationKeys } from "@medusajs/framework/utils"
export async function GET(
req: AuthenticatedMedusaRequest,
res: MedusaResponse
) {
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)
const { data: orders } = await query.graph({
entity: "order",
fields: ["id"],
filters: { seller_id: req.auth_context.actor_id },
})
res.json({ order_count: orders.length })
}
```
Routes under `src/api/vendor/*` run behind the vendor authentication middleware, so `req.auth_context` identifies the calling seller. Use `src/api/admin/*` for operator endpoints and `src/api/store/*` for public storefront endpoints.
Run codegen to scan your route files.
```bash Terminal theme={null}
bunx @mercurjs/cli@latest codegen
```
Codegen rewrites the generated `Routes` type that your panel apps already import:
```typescript apps/vendor/src/lib/client.ts theme={null}
import { createClient, type InferClient } from "@mercurjs/client"
import type { Routes } from "@acme/api/_generated"
declare const __BACKEND_URL__: string
export const client: InferClient = createClient({
baseUrl: __BACKEND_URL__,
fetchOptions: { credentials: "include" },
})
```
This file ships with the starter template, so you don't need to touch it. After codegen, `client.vendor.salesSummary` simply exists, typed.
Run `bunx @mercurjs/cli@latest codegen --watch` during development so the route map regenerates as you edit route files.
Drop a page into the vendor app and call the endpoint through the client. Route segments map to camelCase properties, and the terminal call chooses the HTTP method: `query` (GET), `mutate` (POST), or `delete` (DELETE).
```tsx apps/vendor/src/routes/sales-summary/page.tsx theme={null}
import { useQuery } from "@tanstack/react-query"
import { Container, Heading, Text } from "@medusajs/ui"
import { ChartBar } from "@medusajs/icons"
import type { RouteConfig } from "@mercurjs/dashboard-sdk"
import type { InferClientOutput } from "@mercurjs/client"
import { client } from "../../lib/client"
export const config: RouteConfig = {
label: "Sales summary",
icon: ChartBar,
}
type Summary = InferClientOutput
export default function SalesSummaryPage() {
const { data } = useQuery({
queryKey: ["sales-summary"],
queryFn: () => client.vendor.salesSummary.query(),
})
return (
Sales summary
Orders: {data?.order_count ?? "-"}
)
}
```
`InferClientOutput` extracts the response type straight from the client method. Change the route's response shape, rerun codegen, and this component stops compiling until you update it.
## Verify
Start the project and log into the vendor portal.
```bash Terminal theme={null}
bun run dev
```
Confirm each of the following:
1. **Sidebar entry:** Sales summary appears in the sidebar (the `config` export registered it), and the page shows the order count.
2. **Auth guard:** `curl http://localhost:9000/vendor/sales-summary` without a token returns an authentication error. The vendor middleware guards your route.
3. **Generated contract:** change the route to return `{ count: ... }` instead of `{ order_count: ... }`, rerun codegen, and confirm the page fails to type-check. That is the generated contract doing its job. Revert after.
## FAQ
Use `$`-prefixed segments: a route at `src/api/vendor/things/[id]/route.ts` is called as `client.vendor.things.$id.query({ $id: "thing_123" })`. The `$id` key is threaded into the URL path, and everything else in the object becomes query params (GET) or the JSON body (POST).
Failed requests throw `ClientError` from `@mercurjs/client`, carrying `status`, `statusText`, and the backend's `message`. Wrap calls in try/catch or let TanStack Query surface the error.
Follow Medusa conventions: a `validators.ts` next to the route with a Zod schema, wired through the route's middleware. Codegen reads validators too, so the client's input type reflects them.
## Next steps
Authentication, pagination, field selection, and error shapes.
Put multi-step business logic behind your endpoint with rollback support.
# How to Create a Custom Page
Source: https://docs.mercurjs.com/resources/tutorials/custom-panel-page
Add a page to the vendor portal or admin panel with file-based routing and let the dashboard SDK wire it into the sidebar.
Add a page to the vendor portal or admin panel from a single file.
The dashboard SDK scans `src/routes/` at build time, registers the route, and, if you export a `config`, adds it to the sidebar with a label and icon. There is no route table and no registration call.
**Adding vs changing.** This tutorial *adds* a brand-new page, the right move for new features. To *change* an existing page, don't rebuild it. Add a [widget](/rc/resources/tutorials/add-a-widget) or a [custom field](/rc/resources/tutorials/extend-forms-and-tables) to inject into it. The [decision guide](/rc/resources/customization/extending-panels#choosing-your-extension-mechanism) compares every extension mechanism.
## What you'll build
A `/reviews` page in the vendor portal with a sidebar entry, from a single file.
## Add the page
Drop a `page.tsx` under `src/routes/` in your vendor app. The file path becomes the URL, and the default export is the page.
```tsx apps/vendor/src/routes/reviews/page.tsx theme={null}
import { Container, Heading } from "@medusajs/ui"
import { Star } from "@medusajs/icons"
import type { RouteConfig } from "@mercurjs/dashboard-sdk"
export const config: RouteConfig = {
label: "Reviews",
icon: Star,
rank: 10,
}
export default function ReviewsPage() {
return (
Reviews
)
}
```
At build time the SDK registers the `/reviews` route, generates the sidebar item from `config` (`label`, `icon`, `rank`), and hot-reloads the route tree when you add or remove page files. Dynamic segments use brackets: `src/routes/reviews/[id]/page.tsx` becomes `/reviews/:id`. The full path-to-route table is in [Extending Panels](/rc/resources/customization/extending-panels#routing-conventions).
Start the project, then open the vendor portal.
```bash Terminal theme={null}
bun run dev
```
**Reviews** appears in the sidebar at the position set by `rank`, and `/reviews` renders your component.
## Verify
1. The sidebar shows **Reviews** with the star icon.
2. Navigating to `/reviews` renders the page inside the standard panel layout, with the sidebar and topbar intact.
3. Removing the `config` export keeps the route working but drops the sidebar item.
4. Deleting the file removes the route entirely.
## FAQ
Use the typed API client with TanStack Query. The panels already ship both. For a full loop including a custom backend endpoint, follow [Add a custom API route](/rc/resources/tutorials/custom-api-route).
Yes. Place the file under a `settings/` route segment and set `nested: "/settings"` in the config to group its sidebar item under Settings.
Identically. Drop the file in the admin app's `src/routes/` instead. Both panels use the same SDK and conventions.
## Next steps
# How to Customize Navigation
Source: https://docs.mercurjs.com/resources/tutorials/customize-navigation
Reorder, hide, relabel, and re-parent the panel's built-in sidebar items from a single _navigation.ts file.
Reshape the built-in sidebar without replacing it. You author one host-owned file, `src/_navigation.ts`, and it reorders, hides, relabels, and re-parents the built-in items.
The sidebar ships a fixed set of items such as Orders, Products, and Customers. `_navigation.ts` is the single source of truth for their shape. It overrides existing items only, so a new item still comes from a page you add.
**When to use this vs. a `config` export.** New pages you add via [drop-in routes](/rc/resources/tutorials/custom-panel-page) place their own sidebar item through `defineRouteConfig({ label, rank, nested })`. `_navigation.ts` is for the items you *didn't* create, the built-in ones. The two layer cleanly: custom routes place themselves, and `_navigation.ts` reshapes the built-ins.
## What you'll build
A vendor sidebar with Orders pinned to the top, Price Lists hidden, and Campaigns moved under Orders.
## Register the typed targets
Nav item ids are typed and generated per panel. Register them once with a single ambient reference in your app's `src`. The `create-mercur-app` scaffold already ships this file.
```typescript apps/vendor/src/extension-targets.d.ts theme={null}
///
```
With it present, `id` and `nested` autocomplete and an unknown id fails `tsc`.
## Author the navigation file
The file is host-owned and underscore-prefixed. Default-export a `defineNavigationConfig` with an `items` array of overrides.
```ts apps/vendor/src/_navigation.ts theme={null}
import { defineNavigationConfig } from "@mercurjs/dashboard-sdk"
export default defineNavigationConfig({
items: [
{ id: "orders", rank: 0 }, // pin to the top
{ id: "price-lists", hidden: true }, // hide from the sidebar
{ id: "campaigns", nested: "orders" }, // re-parent under Orders
],
})
```
Each entry targets one built-in item by its stable `id`.
| Field | Type | Effect |
| -------- | --------------------- | ----------------------------------------------------------------------------------- |
| `id` | `NavItemId` | **Required.** The built-in item to override, top-level or nested. |
| `rank` | `number` | Order within its parent, lower first. |
| `hidden` | `boolean` | Remove it from the sidebar. |
| `label` | `string` | Relabel with an i18n key or literal. |
| `icon` | `ComponentType` | Replace its icon. |
| `nested` | `NavParentId \| null` | Re-parent under another top-level item. `null` promotes a nested item to top level. |
Both `id` and `nested` are checked against the panel's generated `NavItemRegistry` and `NavParentRegistry`.
Open the vendor portal. Orders sits at the top, Price Lists is gone from the menu, and Campaigns now appears under Orders.
The route for a hidden item stays reachable directly by URL unless you also remove it.
## Common recipes
```ts theme={null}
export default defineNavigationConfig({
items: [
{ id: "payouts", label: "Settlements" }, // relabel
{ id: "categories", nested: null, rank: 1 }, // promote a nested item to top level
{ id: "collections", nested: "orders" }, // move a nested item under a different parent
{ id: "inventory", hidden: true }, // hide a built-in
],
})
```
## Verify
1. The top-level order reflects your `rank` values, with `orders` first.
2. `price-lists` no longer appears in the sidebar.
3. `campaigns` renders as a child under Orders.
4. Set `id: "not-an-item"`. `bun run lint` (tsc) fails against `NavItemRegistry`.
5. Delete `_navigation.ts`. The default sidebar returns.
## FAQ
No. Navigation is deliberately host-only. Blocks can ship pages, widgets, and custom fields, but the sidebar order stays a single source of truth in your app's `_navigation.ts`.
Any built-in item, top-level or nested, by its own id, such as `orders`, `products`, `categories`, `collections`, `campaigns`, or `customer-groups`. Let your editor autocomplete `id:` against `NavItemId`. The full set is generated into your panel's `extension-targets.d.ts`.
Yes. Drop `src/_navigation.ts` in the admin app and reference `@mercurjs/admin/extension-targets`. Each panel ships its own nav id set.
That's a [drop-in route](/rc/resources/tutorials/custom-panel-page) with a `config` export. `_navigation.ts` only reshapes built-in items, it doesn't create routes.
## Next steps
Add a new screen with its own sidebar item.
Inject a component into a built-in page.
# How to Extend Forms and Tables
Source: https://docs.mercurjs.com/resources/tutorials/extend-forms-and-tables
Add validated fields, detail-section displays, and list columns to a built-in model from a single file with defineCustomFieldsConfig.
`defineCustomFieldsConfig` is Mercur's model-scoped extension surface. From one file per model you add validated fields to built-in create and edit forms, replace, remove, or add fields in detail sections, and add columns to the list table. Everything wires into the built-in page and stays typed against the model's generated registry.
**UI, not schema.** This helper is a panel surface. It renders, validates, and displays fields. It does not create database columns. To store extra data, use the backend [Custom Fields module](/rc/resources/customization/custom-fields) or your own API route or workflow. In the MVP, panel custom fields for `product` are submitted under `additional_data` and persisted onto the product's `metadata`.
## What you'll build
An `ERP ID` field on the vendor product edit form, shown in the product's detail section and as a list-table column, all from a single `src/custom-fields/product.tsx`.
## Register the typed targets
Models, form zones, display zones, and built-in field ids are typed per panel. You register them once, and `create-mercur-app` ships this reference for you.
```typescript apps/vendor/src/extension-targets.d.ts theme={null}
///
```
## Build the config
Drop `src/custom-fields/.tsx` and default-export a `defineCustomFieldsConfig`. `createFormHelper` from `@mercurjs/dashboard-shared` turns a Zod schema into an input type plus validation.
```tsx apps/vendor/src/custom-fields/product.tsx theme={null}
import { defineCustomFieldsConfig } from "@mercurjs/dashboard-sdk"
import { createFormHelper } from "@mercurjs/dashboard-shared"
type ProductWithMeta = { metadata?: Record }
const form = createFormHelper()
export default defineCustomFieldsConfig({
model: "product",
forms: [
{
zone: "edit",
fields: {
erp_id: form.define({
validation: form.string().optional(),
label: "ERP ID",
description: "External system identifier",
placeholder: "ERP-000",
defaultValue: (data) => (data?.metadata?.erp_id as string) ?? "",
}),
},
},
],
})
```
`displays[]` targets a detail-page section by its `zone` id. Keyed by field `id`, an entry adds, replaces, or removes a field.
```tsx apps/vendor/src/custom-fields/product.tsx theme={null}
import { Text } from "@medusajs/ui"
// ...inside defineCustomFieldsConfig:
displays: [
{
zone: "general",
fields: [
// ADD: an unknown id appends a new read-only row
{
id: "erp_id",
component: ({ data }) => (
ERP ID: {String(data?.metadata?.erp_id ?? "-")}
),
},
// REMOVE: a built-in id + null hides the field
{ id: "subtitle", component: null },
// REPLACE: a built-in id + component overrides its render
{
id: "handle",
component: ({ data }) => (
/{(data as { handle?: string })?.handle}
),
},
],
},
],
```
Built-in field ids such as `subtitle`, `handle`, `status`, and `title` autocomplete from the panel's generated `CustomFieldsRegistry`. An unknown id is treated as an added row.
The `list` block extends the model's list table. Add or override columns by id, hide built-in columns, and reorder.
```tsx apps/vendor/src/custom-fields/product.tsx theme={null}
// ...inside defineCustomFieldsConfig:
list: {
columns: [
{
id: "erp_id",
header: "ERP",
component: ({ row }) => String(row.metadata?.erp_id ?? "-"),
},
],
viewDefaults: {
columnVisibility: { collection: false }, // hide a built-in column
columnOrder: ["product", "erp_id", "status"], // reorder
},
},
```
Open the vendor portal. The product edit drawer shows the ERP ID field, validated on submit and persisted via `additional_data`. The detail general section shows the ERP ID row, with `subtitle` removed and `handle` re-rendered. The product list shows the ERP column.
## The createFormHelper surface
`createFormHelper()` exposes a Zod-based surface that drives both the input type and its validation.
```ts theme={null}
const form = createFormHelper()
form.define({
validation: form.string().min(1), // string | number | boolean | date | array | object | null | nullable | coerce
label: "…",
description: "…",
placeholder: "…",
defaultValue: "" | ((data) => /* derive from the entity */),
component, // optional custom render component
})
```
Fields render through the standard `Form.Field → Form.Item` chain, never a raw `Controller`. They participate in the existing `TabbedForm` and `RouteDrawer` submit and validation flow.
## Linked-module data
To read data from a linked module alongside the entity, declare it with `link`. Those relations are fetched with the entity and become available to columns and displays.
```ts theme={null}
export default defineCustomFieldsConfig({
model: "product",
link: "brand", // or ["brand", "warranty"]
list: {
columns: [{ id: "brand_name", header: "Brand", component: ({ row }) => row.brand?.name }],
},
})
```
The SDK derives the fetch query from `link` and merges it into the built-in query with the `+` and `-` convention. You never hand-write the field list.
## Verify
1. The ERP ID field renders in the product edit drawer and validates on submit.
2. Saving persists the value, visible on reload, via `additional_data` to `metadata`.
3. The detail general section shows the ERP row, hides `subtitle`, and re-renders `handle`.
4. The product list shows the ERP column, hides `collection`, and reorders columns.
5. Set `zone: "nope"` in `forms`. `bun run lint` (tsc) fails against the model's registry.
## FAQ
Today: the `product` model in the vendor portal, with form zone `edit` and display zone `general`. The valid set per panel is generated into `CustomFieldsRegistry` in `extension-targets.d.ts`. Autocomplete `model` and `zone` to see what's mounted.
In the MVP, product custom fields are submitted under `additional_data` and persisted onto `product.metadata`. `defineCustomFieldsConfig` itself doesn't create a column. For durable, queryable storage, model it with the backend [Custom Fields module](/rc/resources/customization/custom-fields) or a custom route or workflow.
That's the same helper with `zone: "onboarding"` and `tab` set to a wizard step id (vendor only). It's designed but not mounted in the current MVP. The runtime host exists. The wizard mount is a follow-up.
Yes. A [block](/rc/learn/blocks) can include `src/custom-fields/` files in its `vendor_ui` or `admin_ui` entry, aggregated like the host app's.
## Next steps
Persist extra data on an entity with a generated side table.
Inject a component at a built-in zone.
# How to Extend Onboarding
Source: https://docs.mercurjs.com/resources/tutorials/extend-onboarding
Add a field to the vendor store-setup surface, carry it through additional_data, and persist it from a workflow hook without forking core.
Add a custom field to the vendor onboarding flow and store its value durably, end to end.
The vendor store-setup and onboarding surface is a widget zone, `seller.setup`, that renders the full `seller` object as its `data`. That makes onboarding a full extension seam. You drop a widget to add UI, carry the new value to the API on the built-in seller routes through `additional_data`, and persist it from a workflow hook. These are the same three layers you wire in plain Medusa, kept intact by Mercur.
**Three layers, one flow.** The panel, a `seller.setup` widget, renders and collects. The vendor seller route carries the value through `additional_data` with no core schema change. A `sellersUpdated` workflow hook persists it. Each layer is additive: nothing built-in is replaced.
## What you build
A "Tax ID" prompt on the vendor store-setup surface. The vendor types a VAT number. It rides `additional_data` to `POST /vendor/sellers/:id`, and a workflow hook stores it durably through the [Custom Fields module](/rc/resources/customization/custom-fields).
## Register the typed targets
Widget zones are typed ids the vendor panel generates from its own pages and ships as `@mercurjs/vendor/extension-targets`. Register them once. `create-mercur-app` ships this file for you.
```typescript apps/vendor/src/extension-targets.d.ts theme={null}
///
```
With it present, `seller.setup` autocompletes and an invalid zone fails `tsc` instead of silently doing nothing.
## Render on the onboarding surface
Drop a file under `src/widgets/`. Export the component as the **default** and a `config` with `zone: "seller.setup.before"`. The zone hands your component the `seller` as `data`.
```tsx apps/vendor/src/widgets/tax-id-setup.tsx theme={null}
import "@mercurjs/vendor/extension-targets"
import { defineWidgetConfig } from "@mercurjs/dashboard-sdk"
import { SellerDTO } from "@mercurjs/types"
import { Container, Heading, Input, Button, Text, toast } from "@medusajs/ui"
import { useMutation } from "@tanstack/react-query"
import { useState } from "react"
import { client } from "../lib/client"
export const config = defineWidgetConfig({
zone: "seller.setup.before",
})
const TaxIdSetup = ({ data: seller }: { data?: SellerDTO }) => {
const [taxId, setTaxId] = useState("")
const { mutate, isPending } = useMutation({
mutationFn: () =>
client.vendor.sellers.$id.mutate({
$id: seller!.id,
// additional_data is accepted on every vendor seller route.
// it never touches the built-in seller columns.
additional_data: { tax_id: taxId },
}),
onSuccess: () => toast.success("Tax ID saved"),
onError: () => toast.error("Could not save Tax ID"),
})
if (!seller) {
return null
}
return (
Complete your tax details
Add your VAT / Tax ID to finish store setup.
setTaxId(e.target.value)}
data-testid="tax-id-setup-input"
/>
)
}
export default TaxIdSetup
```
`seller.setup` is hosted in two places, both passing the same `seller` as `data`.
| Host | When it shows |
| ---------------------------------------- | ------------------------------------------------------------ |
| The vendor shell (above the page outlet) | On top-level routes (the dashboard "home" onboarding banner) |
| The store settings detail page | Always, above the store status banner |
A single widget file covers both. Multiple `seller.setup.before` and `.after` widgets stack in registration order.
**`client` is your app's typed SDK.** `create-mercur-app` ships `apps/vendor/src/lib/client.ts`, a `createClient()` instance. `client.vendor.sellers.$id.mutate(...)` is the typed `POST /vendor/sellers/:id`, so the request and response types match the backend route.
## Carry the value through additional\_data
You don't touch the seller route or its validator. Every vendor and admin seller route already wraps its body with `WithAdditionalData`, so an unknown `additional_data` object is accepted and forwarded into the workflow untouched.
```ts packages/core/src/api/vendor/sellers/[id]/route.ts theme={null}
const { additional_data, ...update } = req.validatedBody
await updateSellersWorkflow(req.scope).run({
input: {
selector: { id: req.params.id },
update,
additional_data, // ← forwarded to the workflow's hooks
},
})
```
That is the whole wiring step. Your `{ tax_id }` payload arrives in the workflow as `additional_data` without a schema change.
## Persist it from a workflow hook
`updateSellersWorkflow` exposes a `sellersUpdated` hook that runs after the update with `{ sellers, additional_data }`. Subscribe to it in your Medusa app and persist the value.
Register a `Seller` custom field so the value gets a real, queryable column. No migration to hand-write.
```ts apps/api/medusa-config.ts theme={null}
module.exports = defineConfig({
// ...
modules: [
{
resolve: "@mercurjs/core/modules/custom-fields",
options: {
customFields: {
Seller: {
tax_id: { type: "string", nullable: true },
},
},
},
},
],
})
```
```bash Terminal theme={null}
bunx medusa db:migrate
```
Drop a file under `src/workflows/` in your Medusa app. Medusa imports everything under `src/workflows` at boot, so registering the hook is just defining it. Read `additional_data` and upsert through the Custom Fields service.
```ts apps/api/src/workflows/hooks/seller-tax-id.ts theme={null}
import { updateSellersWorkflow } from "@mercurjs/core/workflows"
import { MercurModules } from "@mercurjs/types"
updateSellersWorkflow.hooks.sellersUpdated(
async ({ sellers, additional_data }, { container }) => {
const taxId = additional_data?.tax_id
if (typeof taxId !== "string") {
return
}
const customFields = container.resolve(MercurModules.CUSTOM_FIELDS)
await customFields.upsert(
"seller",
sellers.map((seller) => ({ id: seller.id, tax_id: taxId })),
)
},
)
```
The hook fires for **every** seller update, not only your widget's. Always guard on the field being present (`typeof taxId !== "string"`) so unrelated edits, such as name, address, or status, pass through untouched.
The value is now linked to the seller and queryable through Medusa's remote query.
```ts theme={null}
const { data: [seller] } = await query.graph({
entity: "seller",
fields: ["id", "name", "custom_fields.tax_id"],
filters: { id: sellerId },
})
```
Add `custom_fields.*` to the `/vendor/sellers/me` query config if you want the widget to reflect the saved value on reload.
## How the layers connect
```
seller.setup widget → client.vendor.sellers.$id.mutate({ additional_data })
│
▼
POST /vendor/sellers/:id → updateSellersWorkflow({ update, additional_data })
│
▼
hook: sellersUpdated({ sellers, additional_data }) → customFields.upsert("seller", …)
│
▼
seller.custom_fields.tax_id (durable, queryable)
```
## Verify
1. Open the vendor portal. The Tax ID prompt renders on the dashboard home and on **Settings → Store**.
2. Enter a value and save. The mutation succeeds (`toast.success`) and hits `POST /vendor/sellers/:id`.
3. `query.graph({ entity: "seller", fields: ["custom_fields.tax_id"] })` returns the saved value.
4. Edit an unrelated field, such as store name. The seller update still works and the guard skips the upsert.
5. Set `zone: "not.a.zone"` on the widget. `bun run lint` (tsc) fails against `WidgetZoneId`.
6. Delete the widget file. The prompt disappears. The seller route and hook are unaffected.
## FAQ
The seller routes' validators are core-owned. `additional_data` is the sanctioned escape hatch. Every vendor and admin route wraps its body with `WithAdditionalData`, so you carry extra context to the workflow hooks without patching the request schema or forking the route.
`updateSellersWorkflow` exposes `sellersUpdated`, and `createSellerAccountWorkflow` (the `POST /vendor/sellers` onboarding submit) exposes `sellerAccountCreated`. Both carry `{ additional_data }`. Use `sellerAccountCreated` to capture data at first registration and `sellersUpdated` for later edits. See the [Store workflows](/platform/store/reference/workflows).
Yes. For a quick, non-queryable value, resolve the seller module in the hook and write to `seller.metadata`. Reach for the [Custom Fields module](/rc/resources/customization/custom-fields) when you want a typed, queryable column, which is what most onboarding data such as tax IDs or compliance flags needs.
Yes. Workflow hooks run as steps of the workflow the route invokes, with the same compensation and rollback semantics. If your hook throws, the seller update rolls back. Keep slow or best-effort work, such as external syncs, in a subscriber on the emitted `seller.updated` event instead.
## Next steps
The full hook and compensation model for Mercur workflows.
Durable, queryable storage for the data your hook writes.
# How to Create Master Products and Offers
Source: https://docs.mercurjs.com/resources/tutorials/master-products-and-offers
Publish one master product, add two competing seller offers, and read per-offer prices from the Store API.
Mercur runs a master catalog. One canonical product record holds many sellers listing against it. The product defines what the item is. Each seller's offer defines how they sell it: their SKU, price, stock, and shipping. This tutorial builds the classic buy-box scenario: two sellers, one product, two competing offers.
No seller owns a product. There is no owner column. Creating a product adds a candidate to the shared catalog. The creator gets attribution, so their unpublished submissions appear in their own list, but no rights over the record after publication. What looks like ownership elsewhere is split into three things: selling eligibility, creator attribution, and the status lifecycle. See [Products](/rc/learn/products).
## What you'll build
You end with a published master product carrying offers from two sellers at different prices. Both offers are visible side by side through the Store API, each with its own calculated price.
## Product vs offer
Three concerns live in three places. Keep them separate as you build.
| Concern | Lives on | Example |
| --------------------- | -------------------------- | --------------------------------------------------- |
| What the item is | Master product | "Classic White T-Shirt", variants, attributes |
| Who may sell it | `product_seller` allowlist | Empty = every seller; assigned = only those sellers |
| How a seller sells it | Offer | SKU, price, stock, shipping profile |
## Build the buy box
As Seller A, create a product in the Vendor Portal, such as "Classic White T-Shirt" with a size variant. It enters as `proposed`. As the operator, confirm it in the Admin Panel so it becomes `published`. The review flow is covered by the [Product Edit](/platform/product-edit/overview) module.
The published product now belongs to the shared catalog. Note what it does not have: a seller price or seller stock.
In Seller A's Vendor Portal, create an offer against the product's variant. Give it their own SKU, a price of \$25, stock of 100, and one of their shipping profiles.
Offer inventory links to the offer, not the variant. Each seller manages stock for their own listing independently. A variant has no marketplace-wide stock figure, and its own `inventory_items` relation is empty for offer-based listings. Always read stock through the offer.
As Seller B, find the same published product in the catalog and create a competing offer on the same variant: a different SKU, \$23, stock of 40. The `(seller_id, sku)` pair is unique per seller, so both sellers can use whatever SKU scheme they like.
Nothing about the master product changed. Two commercial listings now point at one catalog entry.
The Store API exposes offers directly, each with its own calculated price. Fetch them by product id:
```bash Terminal theme={null}
curl "http://localhost:9000/store/offers?product_id=prod_123" \
-H "x-publishable-api-key: "
```
Offers share the variant's price set scoped by an offer rule. Each offer carries its own prices without duplicating the variant, and the storefront computes a `calculated_price` per offer. A buy-box storefront fetches the product once, then renders every seller's offer against it.
Add Seller B's offer to a cart and place the order. The cart line item links to the specific offer purchased, and that link is preserved onto the order line item. Fulfillment, [commission calculation](/rc/learn/commissions), and [payouts](/rc/learn/payouts) all resolve to Seller B, even though both sellers list the same variant.
## Verify
Confirm the buy box behaves as expected.
1. Both offers appear in `GET /store/offers?product_id=...` with different `calculated_price` values.
2. Each seller's Vendor Portal shows only their own offer. The Admin Panel lists both with store, pricing, and inventory.
3. After the checkout in step 5, the order lands with Seller B. Seller A's offer and stock are untouched.
4. Reducing Seller B's offer stock does not affect Seller A's availability. Inventory is per offer.
## FAQ
The operator manages the `product_seller` allowlist. A product with assignments is visible and sellable only for those sellers, while a product with no assignments is open to every seller. Eligibility limits selling. It does not limit who may propose edits to the shared record.
Yes. SKU uniqueness is per seller (`seller_id` + `sku`). Seller A and Seller B can both use `TSHIRT-WHITE-M`. A single seller cannot list the same SKU twice.
Always the seller whose offer was purchased. The offer link travels from cart line to order line, so fulfillment, returns, commission, and payout all resolve through it. The variant alone is never enough to identify the seller.
## Next steps
The offer data model, relationships, and checkout links.
What happens when a cart spans both sellers: the multi-vendor split.
# Telemetry
Source: https://docs.mercurjs.com/telemetry
Learn about the anonymous usage data collected by the Mercur CLI
## Overview
The Mercur CLI collects **anonymous usage data** to help us understand how the CLI is used and improve the developer experience. This data is collected by default but can be easily disabled.
## What We Collect
When you run CLI commands (`create`, `init`, `add`, `registry:build`), the following data is sent:
| Data | Description |
| ----------------- | --------------------------------------------------------------------- |
| Event type | Which command was run and its outcome |
| Node.js version | Your Node.js runtime version |
| Node environment | The `NODE_ENV` value |
| Mercur version | Version of `@mercurjs/cli` in your project |
| Medusa version | Version of `@medusajs/framework` in your project |
| Package manager | npm, yarn, pnpm, or bun |
| Project structure | Whether `src/` directory is used, alias prefix |
| System info | OS, platform, architecture, CPU model/speed/count, RAM |
| Environment | Whether running in WSL, Docker, or a TTY |
| Deployment vendor | Detected hosting platform (Railway, Fly.io, Heroku, etc.) |
| Project ID | SHA-256 hash (base64) of your git remote URL — **not the URL itself** |
| Project config | Contents of your `blocks.json` configuration |
| Email | **Only** if you voluntarily provided it during `create-mercur-app` |
## What We Do NOT Collect
* Source code or file contents
* File names or directory structure
* Git history or commit messages
* Personal data (beyond the optional email above)
## How to Opt Out
### Using the CLI
```bash theme={null}
bunx @mercurjs/cli@latest telemetry --disable
```
### Using an environment variable
```bash theme={null}
export MERCUR_DISABLE_TELEMETRY=true
```
This is useful for CI/CD environments or shared configurations.
### Check current status
```bash theme={null}
bunx @mercurjs/cli@latest telemetry
```
## Where Data Goes
Telemetry events are sent to `https://telemetry.mercurjs.com`. The data is used exclusively by the Mercur team to prioritize features and fix issues.
## Re-enabling Telemetry
```bash theme={null}
bunx @mercurjs/cli@latest telemetry --enable
```
# Create a Product Attribute
Source: https://docs.mercurjs.com/user-guide/admin/attributes/how-tos/create-an-attribute
Add a new attribute and choose how it stores its value.
> Define a reusable property that sellers apply to their products.
## Overview
Creating an attribute is a two-step form. The "Details" step names the attribute and sets how it behaves. The "Type" step chooses how the value is stored, such as a single choice, multiple choices, a number with a unit, a toggle, or free text.
## Step 1: Start a new attribute
1. Go to "Settings" in the sidebar, then "Attributes".
2. Click "Create".
## Step 2: Enter the details
In the "Details" step:
1. Enter a "Name", for example Material.
2. Optionally enter a "Handle". If left blank, Mercur generates one from the name.
3. Optionally enter a "Description".
4. Set the switches as needed:
* "Required attribute". If checked, a value must be set for this attribute on every product.
* "Filterable attribute". If checked, customers can filter products by this attribute on the storefront.
* "Global attribute". If checked, the attribute applies to all products across all categories. If unchecked, choose the "Categories" it applies to.
5. Click "Continue".
## Step 3: Choose the type
In the "Type" step:
1. Select a "Type":
* "Single Select". The seller picks one value from a list.
* "Multi Select". The seller picks one or more values from a list.
* "Unit". A number with a unit of measurement.
* "Toggle". A simple on or off value.
* "Text Area". Free text.
2. For "Single Select" and "Multi Select", add the possible values sellers can choose from.
3. For "Multi Select" only, you can turn on "Use for variants". When checked, the attribute defines product variants, for example size or color.
## Step 4: Create
Click "Create". The new attribute appears in the list and is available to sellers.
Only a "Multi Select" attribute can be used as a variant axis. Choose "Use for variants" when the attribute should split a product into separately purchasable variants.
## Next steps
Add or reorder the values for a select attribute.
# Manage an Attribute's Possible Values
Source: https://docs.mercurjs.com/user-guide/admin/attributes/how-tos/manage-possible-values
Add or reorder the values sellers can choose from for a select attribute.
> Keep the choices behind a Single Select or Multi Select attribute up to date.
## Overview
For select attributes ("Single Select" and "Multi Select"), the possible values are the choices sellers pick from. You can add new values or change their display order at any time.
## Step 1: Open the attribute
1. Go to "Settings" in the sidebar, then "Attributes".
2. Choose the attribute from the list.
## Step 2: Add or reorder values
Use the section actions to:
* Create a new possible value.
* Edit an existing value.
* Edit the ranking that controls the order values appear in.
A possible value cannot be deleted while a seller is using it on a product variant. Ask the seller to update their products first.
## Next steps
Add another attribute to your catalog.
# Product Attributes
Source: https://docs.mercurjs.com/user-guide/admin/attributes/overview
Create the typed attributes sellers use to describe products and customers use to filter them.
> Reusable product properties like Size, Color, or Material, defined once and used everywhere.
An attribute is a reusable property that describes products. You define attributes once, and sellers apply them to their products. Some attributes can also generate product variants or act as storefront filters.
You manage attributes from "Settings", then "Attributes".
## How-tos
Add a new attribute and choose how it stores its value.
Add or reorder the values sellers can choose from.
# Create a Commission Rule
Source: https://docs.mercurjs.com/user-guide/admin/commissions/how-tos/create-a-commission-rule
Override the global commission for a specific store, product type, or category.
> Charge a different fee for certain stores or products than your global commission.
## Overview
A commission rule overrides the global commission for a specific scope, such as a single store or a product category. When several rules could apply to a sale, the most specific rule wins. If no rule matches, the global commission is used.
Creating a rule is a two-step form: "Details" defines what the rule applies to, and "Commission" sets the fee.
## Step 1: Start a new rule
1. Go to "Settings" in the sidebar, then "Commissions".
2. Click "Create" in the "Commission Rules" section.
## Step 2: Define the scope
In the "Details" step:
1. Enter a "Title" so you can recognise the rule later.
2. Enter a "Code". This is a unique identifier for the rule.
3. Select the scope "Type":
* "Store". Apply to specific stores.
* "Product Type". Apply to specific product types.
* "Category". Apply to specific categories.
* "Store + Product Type". Apply to product types within specific stores.
* "Store + Category". Apply to categories within specific stores.
4. Depending on the scope, select the specific stores, product types, or categories the rule applies to.
5. Click "Continue".
## Step 3: Set the fee
In the "Commission" step:
1. Select the "Type". Choose "Percentage" to charge a percentage of the order total, or "Fixed" to charge a set amount per order.
2. Enter the "Value". For a percentage, enter a number between 0 and 100. For a fixed fee, enter an amount for each store currency.
3. Set "Tax included" and "Shipping included" as needed. These work the same way as for the global commission.
## Step 4: Save
Click "Save". The new rule appears in the "Commission Rules" list and applies immediately to matching sales.
When several rules could apply to the same sale, the most specific rule wins.
## Next steps
Edit, enable, disable, or delete the rule later.
# Edit the Global Commission
Source: https://docs.mercurjs.com/user-guide/admin/commissions/how-tos/edit-the-global-commission
Change the default fee your marketplace takes on every sale.
> Set the commission that applies whenever no specific rule matches a sale.
## Overview
The global commission is the default fee applied to every sale across the marketplace. When no commission rule matches a sale, this is the fee that is used. You can charge either a percentage of the order total or a fixed amount.
## Step 1: Open the commissions settings
1. Go to "Settings" in the sidebar.
2. Select "Commissions".
The "Global Commission" section shows the current default fee.
## Step 2: Open the edit window
1. Click the icon in the "Global Commission" section header.
2. Choose "Edit" from the dropdown.
## Step 3: Set the fee
In the side window that opens, set:
* "Type". Choose "Percentage" to charge a percentage of the order total, or "Fixed" to charge a set amount per order.
* "Value". For a percentage, enter a number between 0 and 100. For a fixed fee, enter an amount for each store currency.
* "Tax included". If checked, commission is calculated on the total including tax. If unchecked, tax is excluded and goes entirely to the store.
* "Shipping included". If checked, commission is calculated on the total including shipping. If unchecked, shipping fees go entirely to the store.
## Step 4: Save
Click "Save". The new global commission applies immediately to any sale that no rule overrides.
## Next steps
Override the global commission for specific stores or products.
# Manage a Commission Rule
Source: https://docs.mercurjs.com/user-guide/admin/commissions/how-tos/manage-a-commission-rule
Edit, enable, disable, or delete an existing commission rule.
> Review a rule and change its scope, fee, or status.
## Overview
Once a commission rule exists, you can review it, change its scope or fee, or remove it entirely. Each rule shows a status badge: "Active" rules apply to matching sales, "Inactive" rules do not.
## Step 1: Open the rule
1. Go to "Settings" in the sidebar, then "Commissions".
2. Choose the rule from the "Commission Rules" list.
This opens the rule detail page, showing its scope, status, and commission.
## Step 2: Edit the scope or the fee
* To change the title, code, or scope, click the icon in the scope section header and choose "Edit".
* To change the fee, click the icon in the "Commission" section header and choose "Edit".
Make your changes in the side window, then click "Save".
## Step 3: Delete the rule
If you no longer need the rule, click the icon in the scope section header and choose "Delete", then confirm.
Deleting a rule cannot be undone. Sales that the rule covered fall back to a more general rule, or to the global commission.
## Next steps
Change the default fee used when no rule matches.
# Commissions
Source: https://docs.mercurjs.com/user-guide/admin/commissions/overview
Set the fee your marketplace takes on each seller's sales.
> How the marketplace charges commission, and how to override it for specific stores or products.
A commission is the fee your marketplace keeps on every sale. You set one global commission that applies to all sales, and optionally add rules that override it for specific stores, product types, or categories.
You manage commissions from "Settings", then "Commissions". The page has two parts: the "Global Commission" that applies by default, and the "Commission Rules" that override it.
## How-tos
Change the default fee applied to every sale.
Override the global commission for a store, type, or category.
Edit, enable, disable, or delete an existing rule.
# Admin Panel
Source: https://docs.mercurjs.com/user-guide/admin/overview
What marketplace operators do in the Admin Panel, with no technical knowledge required.
The Admin Panel is where you run the marketplace. You approve sellers, set commissions, review the products sellers submit, and configure how the catalog works.
## Areas
Create, approve, and manage seller stores.
Set the fee your marketplace takes on each sale.
Approve or reject the products and edits sellers submit.
Create the attributes sellers use to describe products.
## Next steps
See the marketplace from a seller's point of view.
# Review a New Product Request
Source: https://docs.mercurjs.com/user-guide/admin/product-requests/how-tos/review-a-new-product
Confirm, request changes to, or reject a product a seller submitted.
> Decide whether a seller's newly submitted product goes live on the storefront.
## Overview
When a seller submits a new product, it is created with a "Proposed" status and waits for your review. On the product detail page, a "Product request" panel shows the submitting store and the product details. You have three choices: confirm and publish, request an update, or reject.
## Step 1: Open the product
1. Go to "Products" in the sidebar.
2. Open a product with the "Proposed" status.
3. Review the product details and the "Product request" panel at the top of the page.
## Step 2: Choose an outcome
### Confirm and publish
1. Click "Confirm" in the "Product request" panel.
2. Optionally add a "Note for store", then confirm.
The product is published. Any offers created for it appear on the storefront, as long as the seller has set stock levels and prices.
### Request an update
1. Click "Request update".
2. Add a "Note for store" describing what needs to change, for example missing images or an incorrect price.
3. Click "Send".
The seller is notified and can revise the product and resubmit it.
### Reject the request
1. Click "Reject".
2. Optionally add a "Note for store" explaining why.
3. Click "Reject" to confirm.
Every decision can carry a note for the store, and the seller is notified of the outcome.
## Next steps
Handle changes to products that are already published.
# Review a Product Update Request
Source: https://docs.mercurjs.com/user-guide/admin/product-requests/how-tos/review-a-product-edit
Approve or reject the changes a seller made to a published product.
> Decide whether a seller's edits to a live product take effect on the storefront.
## Overview
When a seller edits a product that is already published, the change does not go live immediately. On the product detail page, a "Product update request" panel shows the requested changes so you can compare them against the current product, then confirm or reject them.
## Step 1: Open the product
1. Go to "Products" in the sidebar.
2. Open the product that has a pending update.
3. Review the requested changes in the "Product update request" panel.
## Step 2: Approve or reject
### Confirm the changes
1. Click "Confirm".
2. Optionally add a "Note for store", then confirm.
The product is updated on the storefront.
### Reject the changes
1. Click "Reject".
2. Optionally add a "Note for store" explaining why.
3. Click "Reject" to confirm.
The product keeps its current details.
The seller is notified of your decision. Use the note to explain what you changed or why a request was declined.
## Next steps
Handle brand-new products awaiting approval.
# Product Requests
Source: https://docs.mercurjs.com/user-guide/admin/product-requests/overview
Review, approve, or reject the products and edits sellers submit.
> How new products and product edits reach you for review before they go live.
When a seller submits a new product or edits an existing one, it does not go live immediately. It waits for your review on the product detail page. There are two kinds of request:
* A **new product request**, when a seller submits a brand-new product. Its status is "Proposed".
* A **product update request**, when a seller edits a product that is already published.
To review either, go to "Products" in the sidebar and open the product. The request appears in a panel at the top of the product detail page.
## How-tos
Confirm, request changes, or reject a proposed product.
Approve or reject changes to a published product.
# Stores in Mercur Admin Panel
Source: https://docs.mercurjs.com/user-guide/admin/stores
This guide explains how to manage stores in the Mercur Admin Panel.
## View Stores
To view all stores, select “Stores” in the sidebar menu in your panel.
The table shows store details like name, email, status, date and and whether the admin featured the store. Use search, filter, and sort to find a specific store.
One vendor can have multiple stores, each displayed separately in the list.
## Create Store
To create a store:
1. Go to “Stores” in the sidebar.
2. Click on the “Create” button.
This opens a form with two steps: “Details” and “Admin”
### 1. Details Step
In the first “Details” step, you can enter the store general information:
1. Enter “Name” and “Email”
2. Optionally enter “Handle” and “Phone”. The “Handle” field value appears in the store storefront URL. It creates a human-readable URL and must be unique across all stores, containing only lowercase letters, numbers, and hyphens. If left blank, Mercur generates a handle from the name.
3. Select “Currency.” Each store can have only one currency.
4. Once you're done, click the "Continue" button.
### 2. Admin Step
In the second “Admin” step, you assign an admin vendor to manage this store. You can select an existing user or invite a new one. As you type, suggestions will appear: select one or add a new email. Once the store is created, the selected user will receive an email invitation to join.
Once you create the store, it appears on the list with pending status. The store details entered by the admin during creation can be overwritten by the vendor during onboarding. You can block this depending on the onboarding flow you want.
## Store Details
To view store details:
1. Go to “Stores” in the sidebar.
2. Choose the store from the list.
This opens the store details page. Here, you can view and edit store details, address, company details, payment details, and access their orders, products, users, and time off.
### Edit Store Details
To edit store details:
1. Go to “Stores” in the sidebar.
2. Choose the store from the list.
3. Click the icon in the section’s header.
4. Choose “Edit” from the dropdown.
5. In the side window that opens, you can edit the store:
* Status. There are three statuses: Active, Pending, Inactive. When a store is “Inactive” or “Pending”, their products won’t be visible on the storefront.
* Name
* Description
* Handle
* Email
* Phone
* Website
* Featured Store Settings. If checked, the store will be highlighted with a special badge for internal use or promotional purposes.
* Media. You can add logo and banner. This media will be visible on the storefront.
6. Once you’re done, click the “Save” button.
Once the currency is set, it cannot be changed.
Once the admin edits store details, the vendor is notified in their panel.
### Edit Store Address
To edit store address:
1. Go to “Stores” in the sidebar.
2. Choose the store from the list.
3. Click the icon in the “Address” header.
4. Choose “Edit” from the dropdown.
5. In the side window that opens, you can edit:
* Name
* Address
* Apartment, suite, etc.
* Postal Code
* City
* Country
* State
6. Once you're done, click the “Save” button.
### Edit Store Company Details
To edit company details:
1. Go to “Stores” in the sidebar.
2. Choose the store from the list.
3. Click the icon in the “Company Details” header.
4. Choose “Edit” from the dropdown.
5. In the side window that opens, you can edit:
* Company
* Registration number
* Tax ID
6. Once you're done, click the “Save” button.
### Edit Store Payment Details
To edit payment details:
1. Go to “Stores” in the sidebar.
2. Choose the store from the list.
3. Click the icon in the “Payment Details” header.
4. Choose “Edit” from the dropdown.
5. In the side window, you can edit payment details:
1. For all countries except the United States, provide the following details based on your needs:
* Account name
* IBAN
* Account number
* SWIFT/BIC
2. For United States:
* Account name
* Account number
* ACH routing number
6. Once you're done, click the “Save” button.
## Users
In the “Users” tab, view all store team members who manage the store. You can add new users by selecting existing ones from your base or inviting new members. You can also remove users from the store.
The user added during store creation is the main admin and cannot be removed. This user is marked with an “Admin” badge.
### Add New User
To add a new user:
1. Go to “Stores” in the sidebar.
2. Choose the store from the list.
3. Select the “Users” tab
4. Click the “Add” button in the section's header.
5. In the form that opens add:
1. Email. As you type, suggestions will appear; select one or add a new email.
2. Role. There are five roles:
* Store Administration
* Inventory Management
* Order Management
* Accounting
* Support
6. Once you're done, click the “Save” button.
## Time Off
In the “Time off” tab, view the store's time off. It specifies the period when the store will be unavailable to receive orders. The vendor can specify one time off period and add a note. During this period, the store’s products will not be visible on the storefront.
## Store Request
The vendor can submit a request to create a new store. Once submitted, the store appears on the list with “Pending” status. On the store details page, a block appears at the top with two actions:
* Confirm
* Reject
You can review the request, make necessary updates, and choose to confirm and publish the store or reject the request. Once confirmed, the store status changes to Active.
Once the admin confirms or rejects the store creation request, the vendor receives an email.
# Create an Offer
Source: https://docs.mercurjs.com/user-guide/vendor/offers/how-tos/create-an-offer
List one or more catalog products for sale with your SKU, price, and stock.
> Sell a catalog product by adding your SKU, price, stock, and shipping profile.
## Overview
Creating an offer is a two-step form. In "Products" you pick which catalog products you want to sell. In "Stock Levels & Prices" you set your SKU, shipping profile, price, and inventory for each variant. You can list several products in one flow.
## Step 1: Start a new offer
1. Go to "Products" in the sidebar, then "Offers".
2. Click "Create".
## Step 2: Choose products
In the "Products" step:
1. Browse or search the product list.
2. Select the checkbox next to every product you want to list.
3. Click "Continue".
Select all the products that match your inventory in one go, then set stock and prices for all of them in the next step.
## Step 3: Set stock and prices
In the "Stock Levels & Prices" step, for every variant row:
1. Enter a "SKU". Your SKU must be unique across your store.
2. Select a "Shipping Profile".
3. Enter a "Price" for each currency.
4. Enter the stocked quantity for each of your stock locations.
## Step 4: Publish
Click "Publish". Your offers are created.
An offer only appears on the storefront once it has both stock levels and a price set. Offers missing either stay hidden until you complete them.
## Next steps
Adjust an offer's prices or inventory later.
# Update Offer Prices and Stock
Source: https://docs.mercurjs.com/user-guide/vendor/offers/how-tos/update-prices-and-stock
Change an existing offer's prices or inventory without recreating it.
> Adjust what you charge and how much you have in stock for an offer you already listed.
## Overview
After an offer exists, you can update its prices and inventory at any time from the "Offers" list, without going through the create flow again.
## Step 1: Open your offers
Go to "Products" in the sidebar, then "Offers".
## Step 2: Manage prices or inventory
Use the row actions on the offer to:
* "Manage prices" to change the price for each currency.
* "Manage inventory" to update stocked quantities at your locations.
Make your changes and save.
Keep both a price and stock set, or the offer will stop showing on the storefront.
## Next steps
List more products for sale.
# Offers
Source: https://docs.mercurjs.com/user-guide/vendor/offers/overview
List a product for sale with your SKU, price, and stock.
> How you sell a product: an offer carries your SKU, price, stock, and shipping profile.
Products in Mercur live in a shared catalog. To sell one, you create an offer against it. The offer carries your own SKU, price, stock levels, and shipping profile. An offer only appears on the storefront once it has both stock levels and a price set.
You manage offers from "Products", then "Offers".
## How-tos
List one or more catalog products for sale.
Change an existing offer's prices or inventory.
# Complete Your Onboarding
Source: https://docs.mercurjs.com/user-guide/vendor/onboarding
Set up your store step by step when you first join the marketplace.
> Run the setup wizard the first time you create a store.
## Overview
Onboarding is the short setup wizard you run the first time you create a store. It has four steps: "Store Details", "Address", "Company Details", and "Payment Details". The address, company, and payment steps can be skipped and completed later from your store settings.
You reach onboarding after accepting an invitation or creating a new store. You can also start it for an additional store by clicking your store name in the upper left corner and choosing "Add new store".
## Step 1: Store Details
Enter the essentials for your store:
1. Enter a "Name".
2. Enter an "Email". This is the public contact email for your store.
3. Optionally enter a "Handle". The handle becomes the URL slug for your store. Leave it empty to generate one from the name.
4. Select a "Currency".
5. Click "Continue".
The currency cannot be changed after the store is created. Choose carefully.
## Step 2: Address
Enter your business address:
1. Enter a "Name" for the address, for example Headquarters.
2. Enter the "Address", and optionally "Apartment, suite, etc.", "Postal Code", and "City".
3. Select a "Country", and optionally enter a "State".
4. Click "Continue", or click "Skip" to add this later.
## Step 3: Company Details
Enter your business details if you are registering as a company:
1. Optionally enter "Company", "Registration number", and "Tax ID".
2. Click "Continue", or click "Skip".
## Step 4: Payment Details
Tell the marketplace how you want to receive payouts:
1. Select a "Country".
2. Enter the "Account name".
3. Enter your bank details. These depend on your country:
* For the United States: "Account number" and "ACH routing number".
* For other countries: "IBAN", "Account number", and "SWIFT / BIC".
4. Click "Complete setup", or click "Skip".
## After onboarding
Your store account is created with a "Pending" status while the marketplace reviews it. You are notified by email once the review is done. In the meantime, you can select your store and continue setting it up. If any required information is still missing, a "Complete profile" block shows what is needed.
## Next steps
Edit your store details, address, and time off later.
Add your first product to the catalog.
# Fulfill an Order
Source: https://docs.mercurjs.com/user-guide/vendor/orders/how-tos/fulfill-an-order
Prepare the items in an order to be sent to the customer.
> Reserve and prepare an order's items from one of your stock locations.
## Overview
Fulfilling an order prepares its items to be sent. You choose which location to fulfill from, which items and quantities to include, and whether to notify the customer.
## Step 1: Open the order
1. Go to "Orders" in the sidebar.
2. Open the order you want to fulfill.
## Step 2: Start fulfillment
1. In the "Unfulfilled Items" section, open the actions menu.
2. Choose "Fulfill items".
## Step 3: Choose location and items
In the window that opens:
1. Choose the location you are fulfilling the items from.
2. Choose the items and quantities to fulfill.
3. Optionally choose a different shipping method, and choose whether to notify the customer.
## Step 4: Create the fulfillment
Click "Create Fulfillment".
The location you choose must have stock for the selected items. If it does not, add an inventory level for that location first.
## Next steps
Mark the fulfilled items as shipped and add tracking.
# Mark an Order as Delivered
Source: https://docs.mercurjs.com/user-guide/vendor/orders/how-tos/mark-an-order-as-delivered
Record that the customer has received a shipped order.
> Close the loop on a shipment once it reaches the customer.
## Overview
When the customer has received a shipped order, you can record it as delivered. This keeps the order's status accurate for you and the customer.
## Step 1: Open the order
1. Go to "Orders" in the sidebar.
2. Open the order and find the shipped fulfillment.
## Step 2: Mark as delivered
1. Click "Mark as delivered".
2. Confirm the action.
## Next steps
Handle a return if the customer sends items back.
# Process a Return
Source: https://docs.mercurjs.com/user-guide/vendor/orders/how-tos/process-a-return
Create a return for an order and receive the items back into stock.
> Take items back from a customer and adjust your inventory.
## Overview
Processing a return has two parts: you create the return for the items the customer is sending back, then you receive those items when they arrive. Receiving lets you record how many came back and how many were damaged, and adjusts your inventory automatically.
## Step 1: Create the return
1. Go to "Orders" in the sidebar and open the order.
2. In the order summary, open the actions menu and choose to create a return.
3. Select the items and quantities being returned, then confirm.
## Step 2: Receive the items
When the items arrive back:
1. Open the order and find the return.
2. Choose "Receive items".
3. Enter how many items you received, and how many of them are damaged.
4. Choose whether to notify the customer, then confirm.
Inventory levels are adjusted automatically based on the quantities you enter when receiving.
## Next steps
Return money to the customer for the returned items.
# Refund an Order
Source: https://docs.mercurjs.com/user-guide/vendor/orders/how-tos/refund-an-order
Return money to the customer for a captured payment.
> Give the customer some or all of their money back.
## Overview
You can refund a customer from the order's payment section once the payment has been captured. A refund returns money to the customer's original payment method.
## Step 1: Open the order
1. Go to "Orders" in the sidebar.
2. Open the order you want to refund.
## Step 2: Create a refund
1. In the payment section, open the actions menu and choose "Create refund".
2. In the side window, enter the amount to refund and an optional reason.
3. Save to issue the refund.
Refunds are available once a payment has been captured and the order is not already fully refunded.
## Next steps
Refunds often go together with taking items back.
# Ship an Order
Source: https://docs.mercurjs.com/user-guide/vendor/orders/how-tos/ship-an-order
Mark fulfilled items as shipped and add tracking for the customer.
> Record that a fulfillment has been sent, and give the customer a tracking number.
## Overview
Once items are fulfilled, mark them as shipped and add tracking so the customer can follow the delivery. This creates a shipment against the fulfillment.
## Step 1: Open the order
1. Go to "Orders" in the sidebar.
2. Open the order and find the fulfillment you want to ship.
## Step 2: Mark as shipped
Click "Mark as shipped" on the fulfillment.
## Step 3: Add tracking
In the "Mark Fulfillment as Shipped" window:
1. Optionally add a "Tracking number" and a "Tracking URL".
2. Choose whether to "Send notification" to the customer.
3. Confirm to create the shipment.
## Next steps
Record delivery once the customer receives the order.
# Orders
Source: https://docs.mercurjs.com/user-guide/vendor/orders/overview
Fulfill, ship, refund, and handle returns for the orders you receive.
> Everything you do after a customer buys one of your offers.
When a customer buys one of your offers, the order appears in your "Orders" list. From an order's detail page you prepare and ship the items, keep the customer informed, and handle refunds or returns.
You manage orders from "Orders" in the sidebar. Click an order to open its detail page.
## How-tos
Prepare the items to be sent.
Mark items as shipped and add tracking.
Record that the customer received the order.
Return money to the customer.
Create a return and receive the items back.
# Vendor Panel
Source: https://docs.mercurjs.com/user-guide/vendor/overview
What sellers do in the Vendor Panel, with no technical knowledge required.
The Vendor Panel is where you run your store. You set up your store, list products, sell them through offers, and process the orders that come in.
## Areas
Set up your store step by step when you first join.
Manage your store details, address, and time off.
Submit products to the marketplace catalog.
List a product for sale with your SKU, price, and stock.
Fulfill, ship, refund, and handle returns.
## Next steps
See the marketplace from an operator's point of view.
# Edit a Product
Source: https://docs.mercurjs.com/user-guide/vendor/products/how-tos/edit-a-product
Change a published product and track the update request.
> Update a product's details. Changes to a published product go live once the operator approves them.
## Overview
You edit a product from its detail page. Editing a published product creates an update request that the marketplace operator reviews. Your changes take effect on the storefront once approved.
## Step 1: Open the product
1. Go to "Products" in the sidebar.
2. Choose the product from the list.
## Step 2: Edit a section
1. Click the icon in the section header you want to change, for example the general details.
2. Choose "Edit".
3. Update the fields in the side window, then click "Save".
## Step 3: Track the request
After you submit an edit to a published product, the product detail page shows a panel with the pending update. While it is pending, you can click "Cancel" to withdraw the request. Once the operator approves it, the changes appear on the storefront.
You are notified of the operator's decision. If changes are requested, revise the product and submit again.
## Next steps
Add a brand-new product to the catalog.
# Submit a Product
Source: https://docs.mercurjs.com/user-guide/vendor/products/how-tos/submit-a-product
Create a new product and send it to the marketplace for approval.
> Add a new product to the catalog. It goes live once the operator approves it.
## Overview
Creating a product is a four-step form: "Details", "Organize", "Attributes", and "Variants". When you submit it, the product is created with a "Proposed" status and sent to the marketplace operator for review.
## Step 1: Start a new product
1. Go to "Products" in the sidebar.
2. Click "Create".
## Step 2: Enter the details
In the "Details" step, enter the core information for the product, such as its title, handle, and description.
## Step 3: Organize the product
In the "Organize" step, assign the product to a category, and set any collection, type, or tags that apply.
## Step 4: Fill in attributes
In the "Attributes" step, fill in the marketplace attributes. Attributes marked as required by the marketplace must have a value before you can submit.
## Step 5: Add variants and submit
In the "Variants" step, define any variations such as size or color. If the product has none, a single default variant is created for you.
When you are done, click "Publish" to submit the product. You can also click "Save draft" to keep working on it later.
## What happens next
The product is created with a "Proposed" status and sent for review. You can already create offers for it, but they only appear on the storefront once the product is approved and the offers have stock levels and prices set. You are notified of the outcome.
If the operator requests changes, the product shows what needs updating so you can revise and resubmit it.
## Next steps
List the product for sale once it is approved.
Change a product after it is published.
# Products
Source: https://docs.mercurjs.com/user-guide/vendor/products/overview
Submit products to the shared marketplace catalog and track their approval.
> How products reach the catalog: you submit them, the operator reviews them, then you sell them through offers.
Products live in a shared marketplace catalog. When you create or edit a product, it is submitted for the marketplace operator to review. Once approved, you sell it by creating an offer against it.
You manage products from "Products" in the sidebar. Each product shows a status, such as "Draft", "Proposed", "Published", or "Rejected".
## How-tos
Create a new product and send it for approval.
Change a product and track the update request.
# Store in Mercur Vendor Panel
Source: https://docs.mercurjs.com/user-guide/vendor/stores
This guide explains how to manage store in the Mercur Vendor Panel.
## Store Set Up
You can set up a store in Mercur in three ways:
1. Admin Invitation: The admin invites you to join the platform and set up your first store.
2. Vendor Request: You create a new store in your panel, which the admin then confirms.
3. Vendor Self-Registration. When enabled, you can apply to join the platform through the storefront or other available entry points.
### Admin Invitation
Once the admin creates the store and invites you, you receive an email with a link to join as the store's admin. After accepting the invitation, you are redirected to the setup page, where you enter your details and set a password. Once completed, click “Continue” to start onboarding.
The onboarding setup is customizable depending on marketplace needs and may include:
* Store Details
* Address
* Company Details
* Payment Details
Depending on the marketplace configuration, you need to complete all required steps during onboarding or later in the store profile. The “Complete profile” block shows any missing information required by the marketplace. Once all required information is added, the block disappears.
### Vendor Request
A vendor can have multiple stores in Mercur. If you already have one, you can create another.
To create a store:
1. Click the store name in the upper left corner.
2. A menu appears showing your available stores, with the active one selected.
3. Click the “Add new store” button.
4. This opens a new page for store setup. The setup is customizable based on marketplace needs and may include:
* Store Details
* Address
* Company Details
* Payment Details
After creating the store, you are redirected to the store details page with a "Pending" status.
Depending on the marketplace setup, a “Complete profile” block may appear showing any missing information required by the marketplace. Once all required information is added, the block disappears.
### Vendor Self-Registration
When the marketplace admin enables self-registration, you can apply to join the platform through the storefront or other entry points. You are then redirected to the registration page to set up an admin account and create the first store.
After creating the store, you are redirected to the store details page with a "Pending" status.
Depending on the marketplace setup, a “Complete profile” block may appear showing any missing information required by the marketplace. Once all required information is added, the block disappears.
## Store Details
To view store details:
1. Go to “Settings” in the sidebar.
2. Go to “Store” in the sidebar.
This opens the store details page. Here, you can view and edit store details, address, company details, payment details, time off.
### Edit Store Details
To edit store details:
1. Go to “Settings” in the sidebar.
2. Go to “Store” in the sidebar.
3. Click the icon in the section's header.
4. Choose “Edit” from the dropdown.
5. In the side window that opens, you can edit the store:
* Name
* Description
* Handle
* Email
* Phone
* Website
* Media. You can add logo and banner. This media will be visible on the storefront.
6. Once you’re done, click the “Save” button.
A vendor can have only one currency per store. Once set, the vendor cannot change the currency.
### Edit Store Address
To edit store address:
1. Go to “Settings” in the sidebar.
2. Go to “Store” in the sidebar.
3. Click the icon in the “Address” header.
4. Choose “Edit” from the dropdown.
5. In the side window that opens, you can edit:
* Name
* Address
* Apartment, suite, etc.
* Postal Code
* City
* Country
* State
6. Once you're done, click the “Save” button.
### Edit Store Company Details
To edit company details:
1. Go to “Settings” in the sidebar.
2. Go to “Store” in the sidebar.
3. Click the icon in the “Company Details” header.
4. Choose “Edit” from the dropdown.
5. In the side window that opens, you can edit:
1. Company
2. Registration number
3. Tax ID
6. Once you're done, click the “Save” button.
### Edit Store Payment Details
To edit payment details:
1. Go to “Settings” in the sidebar.
2. Go to “Store” in the sidebar.
3. Click the icon in the “Payment Details” header.
4. Choose “Edit” from the dropdown.
5. In the side window, you can edit payment details:
1. For all countries except the United States, provide the following details based on your needs:
* Account name
* IBAN
* Account number
* SWIFT/BIC
2. For United States:
* Account name
* Account number
* ACH routing number
6. Once you're done, click the “Save” button.
## Time Off
In the “Time off” section, you can manage your store's time off. It specifies when the store will be unavailable to receive orders. You can specify one time off period and add a note. During this period, the store’s products will not be visible on the storefront.
### Create Time Off
To create a time off:
1. Go to “Settings” in the sidebar.
2. Go to “Store” in the sidebar.
3. Click the “Create” button in the “Time off” section.
4. In the side window, you can enter:
* First day
* Last day. If you leave this field empty, the store will be closed indefinitely.
* Note
5. Once you're done, click the “Save” button.
Once time off expires, it will disappear.
### Edit Time Off
To edit a time off:
1. Go to “Settings” in the sidebar.
2. Go to “Store” in the sidebar.
3. Click the icon in the time off row.
4. Choose “Edit” from the dropdown.
5. In the side window, you can edit:
* First day
* Last day. If you leave this field empty, the store will be closed indefinitely.
* Note
6. Once you're done, click the “Save” button.
### Delete Time Off
To delete a time off:
1. Go to “Settings” in the sidebar.
2. Go to “Store” in the sidebar.
3. Click the icon in the time off row.
4. Choose “Delete” from the dropdown.