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

> List, read, reply to, close, and reopen support tickets with a Bearer API key.

Prefix: `/api/merchant/v1`. Send:

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

The key must be allowed to access `{storeId}`. When its store scope is `SELECTED`, `{storeId}` must be in that list.

`RESTRICTED` keys must satisfy the scope on the method. `ticket:read` is satisfied by `ticket:read` or `ticket:write`. `ticket:write` is satisfied only by `ticket:write`. `FULL` keys skip that scope list. Either way the user must still have dashboard permission `manageTickets`.

Success JSON is the payload below. It is not wrapped in `data`. Errors are `{ "success": false, "error", "code" }`. A `500` also includes `errorId`.

A `storeId` property in a JSON body is ignored. The path store id is the one that is used. Unknown body keys are dropped.

`GET` `{ticketId}` may be the internal id or the public id (`TKT-XXXX-XXXX-XXXX-XXXX-XXXX`). Reply, close, and reopen write using the path value as the internal id, so send `id` on those methods. A ticket that belongs to another shop returns `404` with error `Ticket not found`.

```http theme={null}
GET  /api/merchant/v1/stores/{storeId}/tickets
GET  /api/merchant/v1/stores/{storeId}/tickets/{ticketId}
POST /api/merchant/v1/stores/{storeId}/tickets/{ticketId}/reply
POST /api/merchant/v1/stores/{storeId}/tickets/{ticketId}/close
POST /api/merchant/v1/stores/{storeId}/tickets/{ticketId}/reopen
```

## List tickets

```http theme={null}
GET /api/merchant/v1/stores/{storeId}/tickets
```

Scope: `ticket:read` (`ticket:write` also satisfies it)

If support tickets are turned off for the store, the response is `403` and error `Support tickets are not available for this store`. If the store row is missing, the response is `404` and error `Store not found`. Get, reply, close, and reopen do not check whether tickets are enabled.

When tickets are enabled and `autoCloseInactiveDays` is greater than 0, this call first closes up to 50 tickets in `open`, `in_progress`, `pending`, or `on_hold` whose `updatedAt` is earlier than now minus that many 24-hour periods. Each of those gets status `closed` and a system reply (`isAdmin` true, `seenAt` set): `This ticket was automatically closed after N day of inactivity.` when N is 1, and `days` when N is not 1.

The handler then loads at most 200 tickets for the store, newest `createdAt` first, and paginates that array. `total` is the size of that loaded set (200 at most), not a count of every ticket when the store has more.

| Query | Rules |
| - | - |
| `page` | Integer ≥ 1. Default `1`. |
| `limit` | Integer from 1 to 100. Default `50`. |

Other query parameters are ignored.

```json theme={null}
{
  "tickets": [
    {
      "id": "clxyz",
      "publicId": "TKT-XXXX-XXXX-XXXX-XXXX-XXXX",
      "ticketNumber": "TKT-XXXX-XXXX-XXXX-XXXX-XXXX",
      "storeId": "cmstore",
      "customerEmail": "buyer@example.com",
      "subject": "Missing key",
      "message": "The key did not arrive",
      "productName": "Premium key",
      "orderId": null,
      "status": "open",
      "createdAt": "2026-09-21T10:00:00.000Z",
      "updatedAt": "2026-09-21T10:00:00.000Z",
      "replies": [
        {
          "id": "clreply",
          "message": "The key did not arrive",
          "imageUrl": null,
          "audioUrl": null,
          "isAdmin": false,
          "seenAt": null,
          "createdAt": "2026-09-21T10:00:00.000Z"
        }
      ],
      "_count": { "replies": 1 },
      "unreadCount": 1
    }
  ],
  "page": 1,
  "limit": 50,
  "total": 1
}
```

Ticket fields on the list:

| Field | Type |
| - | - |
| `id` | string. Internal id. |
| `publicId` | string |
| `ticketNumber` | string or null |
| `storeId` | string |
| `customerEmail` | string |
| `subject` | string |
| `message` | string |
| `productName` | string or null |
| `orderId` | string or null |
| `status` | string. Known values: `open`, `in_progress`, `pending`, `on_hold`, `resolved`, `closed`. |
| `createdAt` | ISO 8601 string |
| `updatedAt` | ISO 8601 string |
| `replies` | The latest reply only (zero or one object), ordered by `createdAt` descending before the take. Fields: `id`, `message`, `imageUrl`, `audioUrl`, `isAdmin`, `seenAt`, `createdAt`. |
| `_count.replies` | number. All replies on the ticket. |
| `unreadCount` | number. Customer replies (`isAdmin` false) with `seenAt` null. |

```bash theme={null}
curl "https://dash.joinmarkt.com/api/merchant/v1/stores/{storeId}/tickets?page=1&limit=50" \
  -H "Authorization: Bearer mk_live_<your_key>" \
  -H "Accept: application/json"
```

## Get a ticket

```http theme={null}
GET /api/merchant/v1/stores/{storeId}/tickets/{ticketId}
```

Scope: `ticket:read` (`ticket:write` also satisfies it)

Status `200`. Returns the ticket row plus the full reply thread and a small store object.

Ticket fields are the list fields except `_count`, `unreadCount`, and the one-reply preview. `replies` is every reply, `createdAt` ascending. `store` is `{ "subdomain", "name" }`.

Each reply:

| Field | Type |
| - | - |
| `id` | string |
| `ticketId` | string. Internal ticket id. |
| `message` | string |
| `imageUrl` | string or null |
| `audioUrl` | string or null |
| `audioDuration` | number or null |
| `isAdmin` | boolean |
| `seenAt` | ISO 8601 string or null |
| `replyToId` | string or null |
| `replyToMessage` | string or null |
| `replyToIsAdmin` | boolean or null |
| `reactions` | JSON or null |
| `editedAt` | ISO 8601 string or null |
| `createdAt` | ISO 8601 string |
| `updatedAt` | ISO 8601 string |

This method does not check whether tickets are enabled on the store. Reply, close, and reopen do not either.

## Reply

```http theme={null}
POST /api/merchant/v1/stores/{storeId}/tickets/{ticketId}/reply
```

Scope: `ticket:write`

Status `201`. Returns the created reply (the fields in the reply table). The reply is stored with `isAdmin: true`. A body `isAdmin` value is ignored. `audioDuration` is not accepted and is stored as null.

After a successful reply the ticket status is set to `pending`.

Body must be a JSON object:

| Field | Rules |
| - | - |
| `message` | String, trimmed, 1–10000 characters. Required. |
| `imageUrl` | Optional string or null, max 500 characters. |
| `audioUrl` | Optional string or null, max 500 characters. |
| `replyToId` | Optional string or null, max 64 characters. Must be a reply on this ticket. Otherwise `400` and error `Quoted reply not found`. |

`imageUrl` and `audioUrl` may be omitted, null, or empty. A non-empty value must be a path starting with `/uploads/`, or an `http`/`https` URL that matches one of these: the origin (scheme, host, and port) equals `R2_PUBLIC_URL` and the path contains `/uploads/` or `/stores/`; the host ends with `.r2.dev` and the path contains `/uploads/` or `/stores/`; or the host is `static` plus optional digits plus `.klipy.com`, compared case-insensitively (`static.klipy.com`, `static1.klipy.com`), with any path. Otherwise `400` and error `Invalid image URL` or `Invalid audio URL`.

A ticket with status `closed` returns `403` and error `This ticket is closed`. `resolved` and the other non-closed statuses still accept a reply.

When `replyToId` is set, `replyToMessage` is a preview of that reply. A leading `Automatic reply\n` is removed, then `**bold**` and `_italic_` markers are stripped. If no text remains, the preview is `Voice note` when `audioUrl` is set, otherwise `Photo` when `imageUrl` is set, otherwise `Attachment`. `replyToIsAdmin` is copied from the quoted reply.

```json theme={null}
{ "message": "We shipped a replacement key" }
```

```bash theme={null}
curl -X POST "https://dash.joinmarkt.com/api/merchant/v1/stores/{storeId}/tickets/{ticketId}/reply" \
  -H "Authorization: Bearer mk_live_<your_key>" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d "{\"message\":\"We shipped a replacement key\"}"
```

## Close

```http theme={null}
POST /api/merchant/v1/stores/{storeId}/tickets/{ticketId}/close
```

Scope: `ticket:write`

Status `200`. Sets status to `closed` and appends a system reply (`isAdmin` true, `seenAt` set) whose message is:

```txt theme={null}
Ticket closed by admin.
Reason: {reason}
```

Returns `{ "replies" }`: every reply on the ticket, `createdAt` ascending, with the reply fields above. The current status is not checked, so closing an already closed ticket appends another system reply.

| Field | Rules |
| - | - |
| `reason` | String, trimmed, 1–1000 characters. Required. |

```json theme={null}
{ "reason": "Resolved" }
```

## Reopen

```http theme={null}
POST /api/merchant/v1/stores/{storeId}/tickets/{ticketId}/reopen
```

Scope: `ticket:write`

No body. Status `200`. Sets status to `open` and appends a system reply with message `Ticket reopened by admin.` Returns `{ "replies" }` in the same shape as close. The current status is not checked.

## Errors

| Status | `code` | When |
| - | - | - |
| 400 | `BAD_REQUEST` | Invalid JSON (`Invalid JSON`), body is not an object (`JSON body must be an object`), `page` or `limit` is invalid, `message`, `reason`, `imageUrl`, `audioUrl`, or `replyToId` fails validation, `Invalid image URL`, `Invalid audio URL`, or `Quoted reply not found` |
| 401 | `UNAUTHORIZED` | Missing, malformed, unknown, or revoked token (`Unauthorized`), completed account deletion (`Unauthorized`), or expired key (`API key expired`). The token must start with `mk_live_`. |
| 403 | `FORBIDDEN` | Scope, shop, or `manageTickets` not allowed (`Forbidden`); tickets disabled on list (`Support tickets are not available for this store`); reply on a closed ticket (`This ticket is closed`) |
| 404 | `NOT_FOUND` | `Ticket not found` or, on list, `Store not found` |
| 429 | `RATE_LIMITED` | 120 requests per 60 seconds for this client IP (`Too many requests`) |
| 500 | `INTERNAL_ERROR` | Unexpected failure. `error` is `Failed to list tickets`, `Failed to load ticket`, `Failed to reply to ticket`, `Failed to close ticket`, or `Failed to reopen ticket`. `errorId` is set. |

## Related

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


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