> ## 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 groups

> List, create, read, update, and delete product groups with a Bearer API key.

Base URL: `https://dash.joinmarkt.com`

Paths use the prefix `/api/merchant/v1`. Every method calls `requireMerchantAuth` before the handler.

```http theme={null}
Authorization: Bearer mk_live_...
```

The token must start with `mk_live_`.

| Status | `error` | `code` | When |
| - | - | - | - |
| 401 | `Unauthorized` | `UNAUTHORIZED` | Missing header, token is not `Bearer`, token does not start with `mk_live_`, unknown key, revoked key, or the account is deleted |
| 401 | `API key expired` | `UNAUTHORIZED` | Key `expiresAt` is in the past |
| 403 | `Forbidden` | `FORBIDDEN` | The user cannot access `{storeId}`, a `SELECTED` key does not list that store, the key lacks the method scope, or the user lacks dashboard permission `manageGroups` |
| 429 | `Too many requests` | `RATE_LIMITED` | More than 120 requests in a 60-second window for this client IP |

The rate-limit IP is `cf-connecting-ip`, then `x-real-ip`, then the last address in `x-forwarded-for`. When none of those headers is present, the bucket id is `unknown`.

A full-access key skips the scope-list check and still requires `manageGroups`. A restricted key needs the method scope on the key and `manageGroups`.

Success bodies are the JSON objects shown on each method. Failures look like this:

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

An unexpected failure returns 500 with `code` `INTERNAL_ERROR`, `error` set to the method fallback below, and an `errorId`.

`storeId` inside a JSON body is discarded before validation. The store is the `{storeId}` path parameter. The body must be a JSON object. Invalid JSON returns 400 `Invalid JSON`. A body that is not an object returns 400 `JSON body must be an object`. Both use `code` `BAD_REQUEST`.

`{groupId}` is the group `id`, or a public id shaped `GRP-XXXX-XXXX-XXXX-XXXX-XXXX` (matched case-insensitively). The group object does not include `publicId`. A missing group, or a group in another store, returns 404 `Group not found` with `code` `NOT_FOUND`.

Validation failures return 400 `BAD_REQUEST`. The `error` string is Zod issues joined as `field: message`, separated by `; `.

## GET `/api/merchant/v1/stores/{storeId}/groups`

Scope: `group:read`

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

Query (`merchantListQuerySchema`). Other query keys are ignored.

| Field | Type | Rules |
| - | - | - |
| `page` | integer | Coerced. Minimum 1. Default 1 when omitted |
| `limit` | integer | Coerced. Minimum 1, maximum 100. Default 50 when omitted |

200:

```json theme={null}
{
  "groups": [
    {
      "id": "group_id",
      "name": "Starter bundle",
      "image": null,
      "visibility": "public",
      "storeId": "store_id",
      "productCount": 2,
      "products": [],
      "createdAt": "2026-09-21T00:00:00.000Z",
      "updatedAt": "2026-09-21T00:00:00.000Z"
    }
  ],
  "page": 1,
  "limit": 50,
  "total": 1
}
```

| Field | Type |
| - | - |
| `groups` | Array of group objects |
| `page` | integer |
| `limit` | integer |
| `total` | integer. Count of groups in the store |

Each list row is a group object with `products` set to `[]`. `productCount` is the number of linked products. Rows are ordered by `sortOrder` ascending, then `createdAt` descending. `sortOrder` is not in the response. A page past the end returns `"groups": []` with that `page`, `limit`, and `total`.

Group object:

| Field | Type |
| - | - |
| `id` | string |
| `name` | string |
| `image` | string or null. Create stores an empty string as null. Update stores an empty string as an empty string, and null clears the image. Get, create, and update may rewrite a scoped media path to a public URL. List returns the stored value |
| `visibility` | string. Created values are `public`, `private`, or `unlisted` |
| `storeId` | string |
| `productCount` | number |
| `products` | array. Empty on list |
| `createdAt` | ISO 8601 string |
| `updatedAt` | ISO 8601 string |

500 fallback: `Failed to list groups`.

## POST `/api/merchant/v1/stores/{storeId}/groups`

Scope: `group:create`

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

Body (`merchantCreateGroupSchema`):

| Field | Required | Type | Rules |
| - | - | - | - |
| `name` | yes | string | Trimmed. Length 1–200. Unique per store |
| `image` | no | string or null | Maximum 500 characters. Empty string is stored as null |
| `visibility` | no | enum | `public`, `private`, or `unlisted`. Omitted values are stored as `public` |
| `products` | no | array of numbers | Each value is coerced to a positive integer. Duplicate numbers are collapsed before the store check |

```json theme={null}
{
  "name": "Starter bundle",
  "visibility": "public",
  "image": null,
  "products": [123456]
}
```

201 returns the group object from [GET one group](#get-apimerchantv1storesstoreidgroupsgroupid), including linked products.

`products` lists numeric product ids in this store. An id that is not in the store returns 400 `One or more products were not found in this store` (`BAD_REQUEST`). `products: []` or an omitted `products` creates the group with no members. A duplicate name returns 400 `Group name already exists for this store` (`BAD_REQUEST`).

500 fallback: `Failed to create group`.

## GET `/api/merchant/v1/stores/{storeId}/groups/{groupId}`

Scope: `group:read`

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

200 returns one group object. `products` lists members:

```json theme={null}
{
  "id": "group_id",
  "name": "Starter bundle",
  "image": null,
  "visibility": "public",
  "storeId": "store_id",
  "productCount": 1,
  "products": [
    { "id": "product_id", "productId": 123456, "name": "Premium key" }
  ],
  "createdAt": "2026-09-21T00:00:00.000Z",
  "updatedAt": "2026-09-21T00:00:00.000Z"
}
```

Each product in `products`:

| Field | Type |
| - | - |
| `id` | string |
| `productId` | number |
| `name` | string |

`productCount` is `_count.products` when the row has it, otherwise the length of `products`.

500 fallback: `Failed to load group`.

## PATCH `/api/merchant/v1/stores/{storeId}/groups/{groupId}`

Scope: `group:update`

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

Body (`merchantPatchGroupSchema`, every create field optional):

| Field | Type | Rules |
| - | - | - |
| `name` | string | Trimmed. Length 1–200. Unique per store |
| `image` | string or null | Maximum 500 characters. `null` clears the image. An empty string is stored as an empty string |
| `visibility` | enum | `public`, `private`, or `unlisted` |
| `products` | array of numbers | Each value is coerced to a positive integer |

```json theme={null}
{ "name": "Starter bundle", "visibility": "unlisted" }
```

200 returns the group object from GET one.

Omitted fields stay as stored. Omitting `products` leaves membership unchanged. Sending `products` unlinks every current member, then links the given numeric product ids. `products: []` leaves the group with no members. Product ids must belong to this store (same 400 as create). A duplicate name returns 400 `Group name already exists for this store` (`BAD_REQUEST`).

500 fallback: `Failed to update group`.

## PUT `/api/merchant/v1/stores/{storeId}/groups/{groupId}`

Scope: `group:update`

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

PUT uses the same auth scope, the same partial body, and the same update as PATCH. 200 returns the group object from GET one. The 500 fallback is `Failed to update group`.

## DELETE `/api/merchant/v1/stores/{storeId}/groups/{groupId}`

Scope: `group:delete`

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

No body.

200:

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

The handler unlinks products from the group, then deletes the group.

500 fallback: `Failed to delete group`.

## Related

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


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