> ## Documentation Index
> Fetch the complete documentation index at: https://docs.joinmarkt.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Merchant products

> List, create, read, update, and delete store products, and read or change license-key stock, with a Bearer API key.

Send every request to `https://dash.joinmarkt.com` with:

```txt theme={null}
Authorization: Bearer mk_live_<secret>
```

The key must be allowed to access `{storeId}`. Product scopes also require the key owner to have `manageProducts` on that store.

Successful list, get, create, and update responses are the JSON objects below. They are not wrapped in a `data` envelope. Errors use this shape:

```json theme={null}
{ "success": false, "error": "Message", "code": "BAD_REQUEST" }
```

These apply to every method on this page:

* **401** `Unauthorized`, code `UNAUTHORIZED` — missing bearer token, unknown key, revoked key, or the account deletion has completed.
* **401** `API key expired`, code `UNAUTHORIZED`.
* **403** `Forbidden`, code `FORBIDDEN` — the key cannot access this store, the scope is missing, or the owner lacks `manageProducts`.
* **429** `Too many requests`, code `RATE_LIMITED` — more than 120 requests in 60 seconds from the same IP.
* **500** code `INTERNAL_ERROR`, plus `errorId`. `error` is the fallback named on that method.

`{productId}` in a path is the product `id`, or a public id `PRD-` plus 3 to 5 groups of 4 letters or digits (for example `PRD-2345-6789-ABCD-EFGH-JKMN`). Lookup is case-insensitive and the stored id is uppercased.

A JSON body must be an object. `storeId` inside the body is removed before validation. The store in the path is the one that is written. Top-level fields other than the ones listed for that method are dropped. Keys inside a variant object are not dropped on create.

## List products

```http theme={null}
GET https://dash.joinmarkt.com/api/merchant/v1/stores/{storeId}/products?page=1&limit=50
```

Scope: `product:read`

Query:

* `page` — integer, minimum 1, default 1
* `limit` — integer, 1–100, default 50

An invalid `page` or `limit` returns **400** with code `BAD_REQUEST`. The `error` text is each Zod issue as `page: …` or `limit: …`, joined with `; `.

Products are ordered by `sortOrder` ascending, then `createdAt` descending. A page past the end returns an empty `products` array and the real `total`.

```json theme={null}
{
  "products": [
    {
      "id": "cm123product",
      "productId": 482913,
      "publicId": "PRD-2345-6789-ABCD-EFGH-JKMN",
      "name": "Premium key",
      "slug": "premium-key",
      "price": 9.99,
      "slashedPrice": null,
      "images": [],
      "visibility": "public",
      "categoryId": null,
      "groupId": null,
      "variants": [],
      "stockAvailable": 12,
      "storeId": "cm123store",
      "createdAt": "2026-09-21T00:00:00.000Z",
      "updatedAt": "2026-09-21T00:00:00.000Z"
    }
  ],
  "page": 1,
  "limit": 50,
  "total": 1
}
```

`publicId` is null when the product has none. `stockAvailable` here is the number of license keys with `isUsed` false. Each object in `variants` is returned with `stockKeys` removed. Read keys from the stock routes below.

Unexpected failures return **500** `Failed to list products`.

## Create a product

```http theme={null}
POST https://dash.joinmarkt.com/api/merchant/v1/stores/{storeId}/products
```

Scope: `product:create` · **201**

Required body fields:

* `name` — string, trimmed, 1–200 characters
* `description` — string, 1–50000 characters. Create trims it, so a whitespace-only value is stored as `""`
* `price` — number, 0–1000000

Optional body fields:

* `slashedPrice` — number, 0–1000000, or `null`
* `images` — array of strings, max 20 items, each max 500 characters. Omitted images are stored as `[]`
* `visibility` — `public`, `private`, `unlisted`, or `on-hold`. Omitted values are stored as `public`
* `categoryId` — string, max 100 characters, or `null`. The category must belong to this store. On create, a blank string is stored as `null`
* `variants` — array of objects. See [Variant objects](#variant-objects)
* `slug` — string, trimmed, 1–200 characters. It is lowercased, runs of other characters become one hyphen, and hyphens at the ends are removed. Omitted slugs are built from `name` the same way. A taken generated slug gets a `-1`, `-2`, … suffix. A slug you send is stored only when that normalized handle is free
* `metaTitle` — string, trimmed, max 200, or `null`. A blank string is stored as `null`
* `metaDescription` — string, trimmed, max 500, or `null`. A blank string is stored as `null`
* `metaImage` — string, trimmed, max 500, or `null`. A blank string is stored as `null`
* `embedCardType` — `small` or `large`, or `null`. Omitted and `null` are stored as `large`
* `deliveryConfig` — see below. Omitted config is stored as license-key delivery
* `displayConfig` — see below. Omitted config stores every flag as `false`

```json theme={null}
{
  "name": "Premium key",
  "description": "Delivered as a license key.",
  "price": 9.99,
  "slashedPrice": 14.99,
  "images": [],
  "visibility": "public",
  "categoryId": null,
  "slug": "premium-key",
  "metaTitle": "Premium key",
  "metaDescription": "License key delivered after checkout.",
  "metaImage": null,
  "embedCardType": "large",
  "deliveryConfig": {
    "methods": ["license_key"]
  },
  "displayConfig": {
    "hideStockCount": false,
    "liveViewsEnabled": false,
    "showViewsCount": false,
    "liveSalesEnabled": false,
    "showSalesCount": false,
    "liveSalesNotifications": false,
    "showSalesNotifications": false
  }
}
```

`deliveryConfig` fields:

* `methods` — required array, at least one of `license_key`, `download_file`, `delivery_instructions`, `custom_fields`, `manual_delivery`
* `downloadFiles` — optional, max 20. Each file has `id` (1–100), `name` (1–200), `url` (1–500), and optional integer `size` ≥ 0. Required when `methods` includes `download_file`
* `manualDelivery` — `{ "message": "..." }` or `null`. `message` max 2000. A non-empty message is required when `methods` includes `manual_delivery`
* `deliveryInstructions` — `{ "content": "..." }` or `null`. `content` is 1–50000 characters. Required when `methods` includes `delivery_instructions`
* `customFields` — optional, max 20. Required when `methods` includes `custom_fields`. Each field has `id`, `label` (1–120), `identifier` (1–64, lowercase letter then letters, numbers, or underscores), `type` (`text`, `textarea`, `dropdown`, `checkbox`, `radio`, `number`), optional `placeholder` (max 200), optional `helpText` (max 500), `required` (boolean, default `false`), and optional `options` (max 20 strings, each 1–120)
* `discordRole` — `{ "guildId": "12345", "roleId": "67890" }` or `null`. Both ids are 5–32 digits
* `instructions` — string, max 50000, or `null`. A non-empty value is merged into `deliveryInstructions` before save

`displayConfig` booleans, each defaulting to `false` when the object is present: `hideStockCount`, `liveViewsEnabled`, `showViewsCount`, `liveSalesEnabled`, `showSalesCount`, `liveSalesNotifications`, `showSalesNotifications`.

## Variant objects

On create, each variant is stored with the keys you sent, plus `id` and `publicId`. `id` is kept when it is 8–128 characters after trim; otherwise it is a new UUID. `publicId` is kept when it is `VAR-` plus five groups of four characters from `23456789ABCDEFGHJKMNPQRSTUVWXYZ`; otherwise a new one is issued. Duplicate `publicId` values in the same array are re-issued. `stockKeys` is not returned by list or get. On create it remains in the saved object. It is not inserted as license keys.

Any update rewrites every saved variant, including when `variants` is omitted and the product already has variants. Sending `[]` clears them. Each saved object then has only `id`, `publicId`, `name`, `description`, `price`, `slashedPrice`, `stock`, `minQuantity`, `maxQuantity`, `volumeDiscounts`, `disableVolumeDiscountsOnCoupon`, and `deliveryConfig` when the variant had one. Missing `name`, `description`, `minQuantity`, or `maxQuantity` becomes `""`. `price` and `slashedPrice` become `""` when missing or numeric `0`. `stock` is a number, parsed from a numeric string, or `0` when missing. `volumeDiscounts` is the array you sent, or `[]`. `disableVolumeDiscountsOnCoupon` is true only for `true` or `"true"`.

The response is the full product from [Get a product](#get-a-product). `stockAvailable` on that object counts keys that are unused and not attached to an order.

Errors:

* **400** code `BAD_REQUEST` — invalid JSON (`Invalid JSON`), a body that is not an object (`JSON body must be an object`), Zod validation (`name: …` style, joined with `; `), or `Category not found in this store`
* **400** code `product_validation` — `Slashed price must be greater than regular price`
* **400** code `product_slug_duplicate` — `URL handle must use lowercase letters, numbers, and hyphens only.` when the normalized handle is empty (including a name that produces no handle), or `This URL handle is already used by another product.` when the slug you sent is already taken
* **403** code `plan_product_limit`, with `requiredPlan` and `upgradePath` `/billing` — the store is at its product limit. `error` is `Your {plan} plan allows up to {limit} products. Upgrade to add more.`
* **403** code `plan_file_delivery_limit`, with `requiredPlan` and `upgradePath` `/billing` — `error` is `File "{name}" exceeds the {megabytes} MB delivery limit on your {plan} plan.` `{name}` is the file `name` when that value is a string, including `""`. Otherwise it is `upload`. Product files and variant files are both checked
* **500** `Failed to create product`

## Get a product

```http theme={null}
GET https://dash.joinmarkt.com/api/merchant/v1/stores/{storeId}/products/{productId}
```

Scope: `product:read`

```json theme={null}
{
  "id": "cm123product",
  "productId": 482913,
  "publicId": "PRD-2345-6789-ABCD-EFGH-JKMN",
  "name": "Premium key",
  "slug": "premium-key",
  "price": 9.99,
  "slashedPrice": null,
  "images": [],
  "visibility": "public",
  "categoryId": null,
  "groupId": null,
  "variants": [],
  "stockAvailable": 12,
  "storeId": "cm123store",
  "createdAt": "2026-09-21T00:00:00.000Z",
  "updatedAt": "2026-09-21T00:00:00.000Z",
  "description": "Delivered as a license key.",
  "deliveryConfig": {
    "methods": ["license_key"],
    "downloadFiles": [],
    "manualDelivery": null,
    "deliveryInstructions": null,
    "customFields": [],
    "discordRole": null,
    "instructions": null
  },
  "displayConfig": {
    "hideStockCount": false,
    "liveViewsEnabled": false,
    "showViewsCount": false,
    "liveSalesEnabled": false,
    "showSalesCount": false,
    "liveSalesNotifications": false,
    "showSalesNotifications": false
  },
  "metaTitle": null,
  "metaDescription": null,
  "metaImage": null,
  "embedCardType": "large"
}
```

`deliveryConfig` and `displayConfig` are always objects here. Empty or invalid stored JSON is returned as the create defaults: `methods` is `["license_key"]`, the delivery arrays are empty, the delivery objects are `null`, and every display flag is `false`. A non-empty stored `instructions` string is copied into `deliveryInstructions` when that object was empty, `delivery_instructions` is added to `methods`, and `instructions` is `null`. `stockAvailable` counts keys with `isUsed` false and no order, across variants. `variants` omit `stockKeys`. `groupId` is included here; change group membership with [Merchant groups](https://docs.joinmarkt.com/api-reference/merchant-groups.md).

Errors:

* **404** `Product not found`, code `product_not_found` — no product matches that id
* **404** `Product not found`, code `NOT_FOUND` — the product belongs to another store
* **500** `Failed to load product`

## Update a product (PATCH)

```http theme={null}
PATCH https://dash.joinmarkt.com/api/merchant/v1/stores/{storeId}/products/{productId}
```

Scope: `product:update` · **200**

Same fields as create. Every field is optional. Omitted fields stay as they are, except variants, which are rewritten as described in [Variant objects](#variant-objects). `null` clears `slashedPrice`, `categoryId`, `metaTitle`, `metaDescription`, `metaImage`, and `embedCardType` (`embedCardType` `null` is stored as `large`). A blank `metaTitle`, `metaDescription`, or `metaImage` is stored as `null`. `description` is stored as sent.

```json theme={null}
{ "price": 12.5, "visibility": "unlisted" }
```

The response is the full product from [Get a product](#get-a-product).

Errors:

* **400** code `BAD_REQUEST` — invalid JSON, a non-object body, Zod validation (`name: …` style, joined with `; `), or `Category not found in this store`
* **400** code `product_validation` — the merged product fails validation. `error` is the Zod messages joined with `; `, without field paths
* **400** code `product_slug_duplicate` — the same URL-handle messages as create
* **400** code `product_license_keys_required` — when the variants array that is saved is empty and delivery includes `license_key`, with no unused keys that have no order: `At least one license key must be added before customers can purchase this product.` When that array is not empty, the product-level check is skipped. A variant is checked only when its name is non-empty and its `deliveryConfig.methods` includes `license_key`. The message is `{variant name}: add at least one license key before customers can purchase this variant.` Non-blank lines in that variant’s `stockKeys` string satisfy the check. When there are no such lines, unused keys with no order for that variant name are counted instead. `stockKeys` is removed before the variant is saved and those lines are not inserted as license keys. A variant `deliveryConfig` that fails validation is **500** `Failed to update product`, not this error
* **403** code `plan_file_delivery_limit`, with `requiredPlan` and `upgradePath` `/billing` — same `error` text as create
* **404** — same product-not-found cases as get
* **500** `Failed to update product`

## Update a product (PUT)

```http theme={null}
PUT https://dash.joinmarkt.com/api/merchant/v1/stores/{storeId}/products/{productId}
```

Scope: `product:update` · **200**

PUT calls the same update as PATCH. Send the same partial object.

```json theme={null}
{ "name": "Premium key (renewal)", "price": 12.5 }
```

The response and errors match [Update a product (PATCH)](#update-a-product-patch), including **500** `Failed to update product`.

## Delete a product

```http theme={null}
DELETE https://dash.joinmarkt.com/api/merchant/v1/stores/{storeId}/products/{productId}
```

Scope: `product:delete` · **200**

No body. When every license key is unused and not attached to an order, the product and those keys are deleted.

```json theme={null}
{ "success": true }
```

Errors:

* **400** code `product_has_fulfilled_keys` — `Cannot delete a product that has delivered license keys. Hide it instead.` This is returned when any key has `isUsed` true or an order id
* **404** — same product-not-found cases as get
* **500** `Failed to delete product`

## List stock

```http theme={null}
GET https://dash.joinmarkt.com/api/merchant/v1/stores/{storeId}/products/{productId}/stock
```

Scope: `product:stock:read`

Optional query `variantName`. Omit it, or send a blank value, to read keys whose variant name is null. A non-blank value filters to that variant. This does not list keys that are already attached to an order.

```json theme={null}
{
  "productId": "cm123product",
  "variantName": null,
  "count": 1,
  "keys": [
    {
      "id": "cm123key",
      "key": "AAAA-BBBB-CCCC",
      "variantName": null,
      "isUsed": false
    }
  ]
}
```

`productId` is the path value you sent. `count` is `keys.length`. A key can be listed with `isUsed` true when it has no order.

Errors:

* **404** `Product not found`, code `NOT_FOUND`
* **500** `Failed to load stock keys`

## Replace stock

```http theme={null}
PUT https://dash.joinmarkt.com/api/merchant/v1/stores/{storeId}/products/{productId}/stock
```

Scope: `product:stock:write` · **200**

Deletes keys for that variant that are not attached to an order, then stores the keys you send. Keys that are only whitespace are dropped. Kept keys are trimmed. `variantName` null or omitted targets keys whose variant name is null.

Body:

* `keys` — required array of strings
* `variantName` — optional string or `null`

```json theme={null}
{ "variantName": null, "keys": ["AAAA-BBBB-CCCC", "DDDD-EEEE-FFFF"] }
```

```json theme={null}
{ "success": true, "count": 2 }
```

`count` is how many non-blank keys were stored.

Errors:

* **400** code `BAD_REQUEST` — invalid JSON, a non-object body, or Zod validation of `keys` / `variantName` (issue messages joined with `; `)
* **404** `Product not found`, code `NOT_FOUND`
* **500** `Failed to replace stock keys`

## Append stock

```http theme={null}
POST https://dash.joinmarkt.com/api/merchant/v1/stores/{storeId}/products/{productId}/stock
```

Scope: `product:stock:write` · **200**

Adds keys. Existing keys stay. Whitespace-only strings are dropped. Kept keys are trimmed. Same body as replace.

```json theme={null}
{ "variantName": "Pro", "keys": ["GGGG-HHHH-IIII"] }
```

```json theme={null}
{ "success": true, "added": 1, "count": 4 }
```

`added` is how many non-blank keys were inserted. `count` is how many unused keys with no order remain for that variant afterward.

Errors:

* **400** code `BAD_REQUEST` — invalid JSON, a non-object body, or Zod validation
* **404** `Product not found`, code `NOT_FOUND`
* **500** `Failed to append stock keys`

## Related

* [Merchant categories](https://docs.joinmarkt.com/api-reference/merchant-categories.md)
* [Merchant groups](https://docs.joinmarkt.com/api-reference/merchant-groups.md)
* [API keys](https://docs.joinmarkt.com/developers/api-keys.md)
* [Authentication](https://docs.joinmarkt.com/api-reference/authentication.md)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.