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

> List, create, read, update, and delete store coupons 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 `manageCoupons` |
| 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 `manageCoupons`. A restricted key needs the method scope on the key and `manageCoupons`.

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`.

`{couponId}` is the coupon `id`, or a public id shaped `CPN-XXXX-XXXX-XXXX-XXXX-XXXX` (matched case-insensitively). A missing coupon, or a coupon in another store, returns 404 `Coupon 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}/coupons`

Scope: `coupon:read`

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

Query (`merchantListQuerySchema` in the merchant HTTP helper). 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 |

Coupons for the store are loaded newest `createdAt` first, then sliced for `page` and `limit`.

200:

```json theme={null}
{
  "coupons": [
    {
      "id": "coupon_id",
      "publicId": "CPN-2345-6789-ABCD-EFGH-JKMN",
      "code": "SAVE10",
      "discountType": "percentage",
      "discountValue": 10,
      "minPurchase": 0,
      "maxDiscount": null,
      "usageLimit": 100,
      "usedCount": 0,
      "validFrom": "2026-09-21T00:00:00.000Z",
      "validUntil": "2027-09-21T00:00:00.000Z",
      "isActive": true,
      "storeId": "store_id",
      "createdAt": "2026-09-21T00:00:00.000Z",
      "updatedAt": "2026-09-21T00:00:00.000Z"
    }
  ],
  "page": 1,
  "limit": 50,
  "total": 1
}
```

| Field | Type |
| - | - |
| `coupons` | Array of coupon objects |
| `page` | integer |
| `limit` | integer |
| `total` | integer. Count of coupons in the store, before the page slice |

A page past the end returns `"coupons": []` with that `page`, `limit`, and `total`.

Coupon object (`serializeCoupon`):

| Field | Type |
| - | - |
| `id` | string |
| `publicId` | string |
| `code` | string |
| `discountType` | string. `percentage` or `fixed` |
| `discountValue` | number |
| `minPurchase` | number or null |
| `maxDiscount` | number or null |
| `usageLimit` | number or null |
| `usedCount` | number |
| `validFrom` | ISO 8601 string |
| `validUntil` | ISO 8601 string |
| `isActive` | boolean |
| `storeId` | string |
| `createdAt` | ISO 8601 string |
| `updatedAt` | ISO 8601 string |

500 fallback: `Failed to list coupons`.

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

Scope: `coupon:create`

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

Body (`createCouponBodySchema`). The path `storeId` is attached after this parse.

| Field | Required | Type | Rules |
| - | - | - | - |
| `code` | yes | string | Trimmed. Length 1–50. Stored trimmed and uppercased |
| `discountType` | no | enum | `percentage` or `fixed`. Omitted values are stored as `percentage` |
| `discountValue` | yes | number | Coerced, finite, minimum 0, maximum 1000000 |
| `minPurchase` | no | number or null | Coerced, finite, minimum 0, maximum 1000000. Omitted, `null`, or `0` is stored as `0` |
| `maxDiscount` | no | number or null | Coerced, finite, minimum 0, maximum 1000000. Omitted, `null`, or `0` is stored as `null` |
| `usageLimit` | no | integer or null | Coerced positive integer, maximum 1000000. Omitted or `null` is stored as `null` |
| `validFrom` | no | date | Coerced with `Date`. Omitted values are stored as the request time |
| `validUntil` | no | date | Coerced with `Date`. Omitted values are stored as 365 days after the request time |
| `isActive` | no | boolean | Omitted values are stored as `true`. Only `false` stores `false` |

```json theme={null}
{
  "code": "SAVE10",
  "discountType": "percentage",
  "discountValue": 10,
  "minPurchase": 0,
  "usageLimit": 100,
  "isActive": true
}
```

201 returns the coupon object from [GET one coupon](#get-apimerchantv1storesstoreidcouponscouponid).

A duplicate code in the store returns 400:

```json theme={null}
{
  "success": false,
  "error": "Coupon code already exists for this store",
  "code": "coupon_code_duplicate"
}
```

When the store is already at its plan coupon cap, create returns 403. `error` is `Your <planId> plan allows up to <cap> coupons. Upgrade to add more.` The body also includes:

| Field | Type |
| - | - |
| `code` | `plan_coupon_limit` |
| `requiredPlan` | `business` when `planId` is `business`, otherwise `premium` |
| `upgradePath` | `/billing` |

500 fallback: `Failed to create coupon`.

## GET `/api/merchant/v1/stores/{storeId}/coupons/{couponId}`

Scope: `coupon:read`

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

200 returns one coupon object. Fields are the coupon object in the list section.

500 fallback: `Failed to load coupon`.

## PATCH `/api/merchant/v1/stores/{storeId}/coupons/{couponId}`

Scope: `coupon:update`

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

Body (`updateCouponBodySchema`): every create field is optional, with the same types and limits.

| Field | Type | Rules |
| - | - | - |
| `code` | string | Trimmed. Length 1–50. A provided code is stored trimmed and uppercased |
| `discountType` | enum | `percentage` or `fixed` |
| `discountValue` | number | Coerced, finite, minimum 0, maximum 1000000. A value of `0` passes validation and is not written |
| `minPurchase` | number or null | Coerced, finite, minimum 0, maximum 1000000. A provided number, including `0`, is written. `null` passes the schema and is passed to `parseFloat`, which does not yield a finite number |
| `maxDiscount` | number or null | Coerced, finite, minimum 0, maximum 1000000. `null` or `0` is stored as `null` |
| `usageLimit` | integer or null | Coerced positive integer, maximum 1000000. `null` is stored as `null` |
| `validFrom` | date | Coerced with `Date`. A provided date replaces the stored value |
| `validUntil` | date | Coerced with `Date`. A provided date replaces the stored value |
| `isActive` | boolean | `true` or `false` replaces the stored value |

Omitted fields stay as stored.

```json theme={null}
{ "discountValue": 15, "isActive": false }
```

200 returns the coupon object from GET one.

A duplicate code returns 400 `Coupon code already exists for this store` with `code` `coupon_code_duplicate`.

500 fallback: `Failed to update coupon`.

## PUT `/api/merchant/v1/stores/{storeId}/coupons/{couponId}`

Scope: `coupon:update`

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

PUT calls the same handler as PATCH: same scope, same partial body, same 200 coupon object, same duplicate-code error. The 500 fallback is `Failed to update coupon`.

## DELETE `/api/merchant/v1/stores/{storeId}/coupons/{couponId}`

Scope: `coupon:delete`

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

No body.

200:

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

500 fallback: `Failed to delete coupon`.

## Related

* [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.