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

> List and read store customers, and adjust store credit, with a Bearer API key.

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

```txt theme={null}
Authorization: Bearer mk_live_...
Accept: application/json
```

| Scope | Ceiling | Routes |
| - | - | - |
| `customer:read` | `viewCustomers` | `GET` list, `GET` one |
| `customer:balance:write` | `viewCustomers` | `PATCH` balance |

The user must be able to access `{storeId}`. Store scope `SELECTED` must include that shop. `ALL_ACCESSIBLE` does not add a shop list. Access mode `RESTRICTED` must include the route scope exactly. Access mode `FULL` skips the scope list. Both modes still require `viewCustomers`.

Limiter: 120 requests per 60 seconds per client IP. `429` `Too many requests` is returned before the key is checked.

`{customerKey}` is the `key` field: a public id `CUS-` plus five groups of four characters from `23456789ABCDEFGHJKMNPQRSTUVWXYZ` (no `0`, `O`, `1`, `I`, or `L`). Lookup trims the path value and uppercases it. Any other shape, including a legacy base64 email key, returns `404` `Customer not found`. A `CUS-` id that is not stored for this shop, or a stored email with no `@`, returns the same `404`. The `id` field is a separate legacy numeric string derived from the email. It is not accepted in the path.

Successful bodies are the objects below. Errors:

```json theme={null}
{ "success": false, "error": "Customer not found", "code": "NOT_FOUND" }
```

`code` is `BAD_REQUEST`, `UNAUTHORIZED`, `FORBIDDEN`, `NOT_FOUND`, `RATE_LIMITED`, or `INTERNAL_ERROR`. A `500` also includes `errorId`.

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

Scope: `customer:read`

Query parameters this route reads:

| Param | Rule |
| - | - |
| `page` | Integer ≥ 1. Default `1`. |
| `limit` | Integer 1–100. Default `50`. |

Other query keys are ignored. An invalid `page` or `limit` returns `400`. The message is the Zod issues joined with `; ` (`path: message` when the issue has a path). An empty `page` or `limit` is invalid (`""` coerces to `0`), not the default.

The list is every customer derived for the shop, then sliced. `total` is the count before the slice. `page` and `limit` in the body are the values used. A page past the end returns `"customers": []` and the same `total`.

A row is included when the shop has an order, a customer auth session, a ticket, or a store-credit balance for that email. Sort is `joinedAt` descending.

```json theme={null}
{
  "customers": [
    {
      "key": "CUS-2345-6789-ABCD-EFGH-JKMN",
      "id": "1842201",
      "email": "buyer@example.com",
      "status": "active",
      "balance": 5,
      "totalSpent": 9.99,
      "orderCount": 1,
      "averageOrder": 9.99,
      "lastOrder": "2026-09-21T10:05:00.000Z",
      "joinedAt": "2026-09-01T08:00:00.000Z",
      "ipAddress": "203.0.113.10",
      "referralCount": 0,
      "isAffiliate": false,
      "ticketCount": 0,
      "integrationCount": 1
    }
  ],
  "page": 1,
  "limit": 50,
  "total": 1
}
```

| Field | Type |
| - | - |
| `key` | `CUS-` public id. Use this as `{customerKey}`. |
| `id` | Legacy numeric string derived from the email. Not a path id. |
| `email` | String |
| `status` | `affiliate`, `vip`, `regular`, `active`, or `new`. See below. |
| `balance` | Store credit, rounded to 2 decimal places. |
| `totalSpent` | Sum of order `price` for this email. `0` when the row has no orders. |
| `orderCount` | Number of those orders. |
| `averageOrder` | `totalSpent / orderCount`, or `0` when `orderCount` is `0`. Not rounded. |
| `lastOrder` | ISO-8601 of the latest `completedAt`, or `createdAt` when `completedAt` is empty. `null` when there is no order. |
| `joinedAt` | ISO-8601. From orders this is the earliest order `createdAt`, moved earlier when an auth session `createdAt` is earlier. A ticket-only or balance-only row with no order and no session uses the request time. |
| `ipAddress` | Newest non-null order IP, otherwise the newest non-null auth-session IP, or `null`. |
| `referralCount` | On the list, counted only when this customer has at least one order, using the case-sensitive referral code for the aggregate email (SQL `max` of `customerEmail`). Otherwise `0`, even when other orders use this customer’s code. On detail, counted with the code for the lowercased email, including ticket-only and balance-only customers. |
| `isAffiliate` | `true` when `referralCount` is greater than `0`. |
| `ticketCount` | Number of tickets for this email. |
| `integrationCount` | `1` when a customer auth session exists for this email, otherwise `0`. |

`status` uses the first match: `affiliate` when `referralCount` > 0; else `vip` when `totalSpent` ≥ 200 or `orderCount` ≥ 8; else `regular` when `orderCount` ≥ 3; else `active` when `orderCount` > 0; else `new`.

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

Fallback `error`: `Failed to list customers`.

## `GET /api/merchant/v1/stores/{storeId}/customers/{customerKey}`

Scope: `customer:read`

No query parameters.

The body is one customer. It includes every list field, then the fields below. `integrationCount` on this response is always `0`. Detail does not load auth sessions. A list row that exists only because of an auth session (no orders, no tickets, and no store-credit balance row) returns `404` `Customer not found`. A balance of `0` still counts as a row. A key that does not resolve to an email, or resolves to a value with no `@`, also returns `404`.

`orders` is every order for the email in this shop, `createdAt` descending. `publicId` and `orderId` on each order are the two stored columns, returned as saved. `tickets` is every ticket for the lowercased email, `createdAt` descending.

```json theme={null}
{
  "key": "CUS-2345-6789-ABCD-EFGH-JKMN",
  "id": "1842201",
  "email": "buyer@example.com",
  "status": "active",
  "balance": 5,
  "totalSpent": 9.99,
  "orderCount": 1,
  "averageOrder": 9.99,
  "lastOrder": "2026-09-21T10:05:00.000Z",
  "joinedAt": "2026-09-21T10:00:00.000Z",
  "ipAddress": "203.0.113.10",
  "referralCount": 0,
  "isAffiliate": false,
  "ticketCount": 1,
  "integrationCount": 0,
  "orders": [
    {
      "id": "clxyzorder",
      "publicId": "ORD-2345-6789-ABCD-EFGH-JKMN",
      "orderId": "ORD-2345-6789-ABCD-EFGH-JKMN",
      "productName": "Premium key",
      "price": 9.99,
      "status": "completed",
      "paymentMethod": "stripe",
      "createdAt": "2026-09-21T10:00:00.000Z",
      "completedAt": "2026-09-21T10:05:00.000Z"
    }
  ],
  "tickets": [
    {
      "id": "clxyzticket",
      "publicId": "TKT-2345-6789-ABCD-EFGH-JKMN",
      "subject": "Where is my key?",
      "status": "open",
      "createdAt": "2026-09-22T11:00:00.000Z",
      "updatedAt": "2026-09-22T11:00:00.000Z"
    }
  ],
  "timeline": [
    {
      "id": "registered",
      "label": "Customer registered",
      "at": "2026-09-21T10:00:00.000Z"
    },
    {
      "id": "clxyzorder",
      "label": "Order ORD-2345-6789-ABCD-EFGH-JKMN placed",
      "at": "2026-09-21T10:00:00.000Z"
    }
  ],
  "affiliateEnabled": false,
  "affiliatePercentage": 0,
  "isBlacklisted": false,
  "network": {
    "ipAddress": "203.0.113.10",
    "country": "DE",
    "browser": "Chrome",
    "operatingSystem": "Windows",
    "userAgent": "Mozilla/5.0",
    "asn": "AS64496",
    "seenAt": "2026-09-21T10:00:00.000Z",
    "distinctIps": 1,
    "distinctCountries": 1
  }
}
```

Detail-only fields:

| Field | Type |
| - | - |
| `orders` | Array. Each item: `id`, `publicId`, `orderId`, `productName`, `price`, `status`, `paymentMethod`, `createdAt` (ISO-8601), `completedAt` (ISO-8601 or `null`). |
| `tickets` | Array. Each item: `id`, `publicId`, `subject`, `status`, `createdAt`, `updatedAt` (ISO-8601). |
| `timeline` | One `registered` event (`id` `registered`, `label` `Customer registered`, `at` = `joinedAt`) plus up to five newest orders (`id` is the order row id, `label` is `Order {publicId} placed`, `at` is that order’s `createdAt`), then sorted by `at` descending. Equal timestamps keep `registered` first. |
| `affiliateEnabled` | Store setting, boolean. |
| `affiliatePercentage` | Store setting, number. `0` when the store has none. |
| `isBlacklisted` | `true` when this shop has a blacklist row with type `email` and this lowercased email. |
| `network` | Session fields from the newest order that has `ipAddress`, `userAgent`, or `country`. |

`network` fields: `ipAddress`, `country`, `browser`, `operatingSystem`, `userAgent`, `asn` (string or `null`), `seenAt` (that order’s `createdAt` ISO-8601, or `null`), `distinctIps`, `distinctCountries`. The distinct counts are unique trimmed, lowercased values across the customer’s orders. When no order carries those session fields, the strings and `seenAt` are `null` and both counts are `0`.

On detail, `joinedAt` is the oldest order `createdAt`, otherwise the oldest ticket `createdAt`, otherwise the request time. `totalSpent` is the sum of those orders’ `price`. `orderCount` is that array’s length. `ticketCount` is `tickets.length`. `ipAddress` is the newest order IP, or `null`. `balance` is the stored store-credit balance, or `0`.

Fallback `error`: `Failed to load customer`.

## `PATCH /api/merchant/v1/stores/{storeId}/customers/{customerKey}/balance`

Scope: `customer:balance:write`

No query parameters. `Content-Type: application/json`.

The body must be a JSON object. Invalid JSON returns `400` `Invalid JSON`. A non-object returns `400` `JSON body must be an object`. A body `storeId` is dropped. Unknown keys are ignored. The path `storeId` is the shop that is updated. A schema failure returns `400` with Zod issues joined by `; ` (`path: message` when the issue has a path).

| Field | Rule |
| - | - |
| `type` | `add`, `subtract`, or `set`. Required. |
| `amount` | Finite number ≥ 0. Numeric strings are coerced. Required. |
| `note` | String, at most 500 characters, or `null`. Optional. |

```json theme={null}
{ "type": "add", "amount": 5, "note": "Goodwill credit" }
```

`add` and `subtract` require `amount` > 0. Otherwise `400` `Amount must be greater than zero.` The current balance is rounded to 2 decimal places before the write. `subtract` returns `400` `Cannot subtract more than the current balance.` when `amount` is greater than that rounded balance, and nothing is written. An equal `amount` is allowed and stores `0`. `set` may be `0`. `add` stores the rounded sum, `subtract` stores the rounded difference, and `set` stores the rounded `amount`. Negative `amount` never reaches those rules: the schema rejects it.

Unknown key, or an email that is not in this shop’s customer map, returns `404` `Customer not found`.

```json theme={null}
{
  "balance": 15,
  "previousBalance": 10,
  "newBalance": 15
}
```

| Field | Type |
| - | - |
| `balance` | Stored balance after the write, rounded to 2 decimal places. |
| `previousBalance` | Rounded balance before the write. |
| `newBalance` | Same number as `balance`. |

```bash theme={null}
curl -X PATCH "https://dash.joinmarkt.com/api/merchant/v1/stores/STORE_ID/customers/CUS-2345-6789-ABCD-EFGH-JKMN/balance" \
  -H "Authorization: Bearer mk_live_..." \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d "{\"type\":\"add\",\"amount\":5,\"note\":\"Goodwill credit\"}"
```

Fallback `error`: `Failed to update customer balance`.

## Errors

| Status | When |
| - | - |
| 400 | Invalid `page` or `limit`, invalid JSON, or balance rule |
| 401 | Missing header, token not starting with `mk_live_`, unknown, revoked, or expired key (`Unauthorized` or `API key expired`) |
| 403 | Shop, scope, or `viewCustomers` ceiling (`Forbidden`) |
| 404 | `Customer not found` |
| 429 | `Too many requests` |
| 500 | Unexpected failure (`errorId` included) |

## Related

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


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