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

# Create Coupon

> Create a coupon with its code, optionally with its promotion codes, in one request

Create a coupon with its own `code` — buyers can type it at checkout — optionally together with up to 20 promotion codes. Everything is created in one request: the coupon, its code and all its promotion codes become usable together, or none of them is created. A coupon limits redemptions only by its expiry (`redeemBy`) and total uses (`maxRedemptions`); put buyer restrictions (buyer emails, uses per buyer, minimum amount) on promotion codes. The coupon can also be applied with `couponId` on a [checkout session](https://docs.waffo.ai/api-reference/endpoints/orders/create-checkout-session.md); add promotion codes later with [Create Promotion Code](https://docs.waffo.ai/api-reference/endpoints/promotion-codes/create-promotion-code.md).

```
POST /v1/actions/coupon/create-coupon
```

**Authentication:** API Key (store owner or admin)

## Request Body

| Field | Type | Required | Description |
| - | - | - | - |
| `storeId` | string | Yes | Store ID (Short ID format `STO_xxx`) |
| `name` | string | Yes | Internal name (max 64 chars). Buyers do not see it |
| `code` | string | No | The coupon's own code, which buyers type at checkout: 3–32 letters, digits or `-`, case-insensitive, stored in uppercase. Must not match an active code in the same store and environment, nor a code in `promotionCodes`. Omitted: a random 10-character code is generated |
| `discount` | object | Yes | `{ "type": "percentage", "percentOff": 20 }` or `{ "type": "fixed_amount", "amountOff": { "USD": "5.00" } }` (see below) |
| `duration` | string | No | How many subscription billing periods get the discount: `once` (default), `repeating` or `forever`. One-time orders are discounted once |
| `durationInPeriods` | integer | When `repeating` | Number of billing periods (≥ 1). Only allowed when `duration` is `repeating` |
| `productIds` | string\[] | No | Limit the coupon to these products (`PROD_xxx`, max 100). Empty or omitted means the whole store |
| `maxRedemptions` | integer \| null | No | Total redemption limit (≥ 1) across the coupon's code and all its promotion codes. Refunds do not give uses back. `null` = unlimited |
| `redeemBy` | string \| null | No | Expiry (ISO 8601, more than 60 seconds in the future). Discounts already attached to subscriptions also end at this time |
| `promotionCodes` | object\[] | No | Promotion codes to create with the coupon, 0–20 (see [Promotion Code Object](#promotion-code-object)). Omit for a coupon without codes |

## Discount Object

| Field | Type | Description |
| - | - | - |
| `type` | string | `percentage` or `fixed_amount` |
| `percentOff` | number | Required for `percentage`. 0 \< x ≤ 100, at most 2 decimals. `100` makes the order free |
| `amountOff` | object | Required for `fixed_amount`. Amount off per ISO 4217 currency, as a display-format string, with no more decimal places than the currency allows (USD 2, JPY 0, BHD 3) — extra decimals return 400 and are not rounded. Orders in a currency that is not listed cannot use the coupon — there is no currency conversion |

```json theme={"system"}
{
  "discount": {
    "type": "fixed_amount",
    "amountOff": { "USD": "5.00", "EUR": "4.50", "JPY": "800" }
  }
}
```

## Promotion Code Object

Each entry of `promotionCodes` takes the same fields as [Create Promotion Code](https://docs.waffo.ai/api-reference/endpoints/promotion-codes/create-promotion-code.md), without `couponId`:

| Field | Type | Required | Description |
| - | - | - | - |
| `code` | string | No | Code buyers type: 3–32 letters, digits or `-`, case-insensitive, stored in uppercase. Must not match an active code in the same store and environment, nor another entry of the request, nor the coupon's `code`. Omitted: a random 10-character code is generated |
| `restrictions` | object | No | `customerEmails`, `perCustomerLimit`, `maxRedemptions` (≤ the coupon's `maxRedemptions`), `expiresAt` (more than 60 seconds in the future and ≤ the coupon's `redeemBy`; defaults to it), `minimumAmount` — see [Restrictions Object](https://docs.waffo.ai/api-reference/endpoints/promotion-codes/create-promotion-code.md#restrictions-object). `perCustomerLimit`, `maxRedemptions`, `expiresAt` and `minimumAmount` accept `null`, meaning not set (`expiresAt: null` falls back to the coupon's `redeemBy`). `customerEmails` does not accept `null`: omit it, or send `[]` |

## Example Request

```typescript SDK theme={"system"}
import { WaffoPancake } from "@waffo/pancake-ts";

const client = new WaffoPancake({
  merchantId: process.env.WAFFO_MERCHANT_ID!,
  privateKey: process.env.WAFFO_PRIVATE_KEY!,
});

const { coupon } = await client.coupons.create(
  {
    storeId: "STO_2aUyqjCzEIiEcYMKj7TZtw",
    name: "Black Friday 20% off",
    discount: { type: "percentage", percentOff: 20 },
    duration: "repeating",
    durationInPeriods: 3,
    maxRedemptions: 1000,
    redeemBy: "2026-12-01T00:00:00.000Z",
    code: "bf2026",
    promotionCodes: [{ code: "bf-new", restrictions: { perCustomerLimit: 1 } }],
  },
  { idempotencyKey: "MER_2aUyqjCzEIiEcYMKj7TZtw-0f8e2a9c-5b1d-4e7a-9c3f-2d6b8e1a4c70" },
);
console.log(coupon.status, coupon.code, coupon.promotionCodes[0].code); // => "active" "BF2026" "BF-NEW"
```

```bash cURL theme={"system"}
MERCHANT_ID="MER_2aUyqjCzEIiEcYMKj7TZtw"
IDEMPOTENCY_KEY="MER_2aUyqjCzEIiEcYMKj7TZtw-0f8e2a9c-5b1d-4e7a-9c3f-2d6b8e1a4c70"
TIMESTAMP=$(date +%s)
BODY='{"storeId":"STO_2aUyqjCzEIiEcYMKj7TZtw","name":"Black Friday 20% off","discount":{"type":"percentage","percentOff":20},"duration":"repeating","durationInPeriods":3,"maxRedemptions":1000,"redeemBy":"2026-12-01T00:00:00.000Z","code":"bf2026","promotionCodes":[{"code":"bf-new","restrictions":{"perCustomerLimit":1}}]}'
BODY_HASH=$(echo -n "$BODY" | openssl dgst -sha256 -binary | base64 | tr -d '\n')
CANONICAL_REQUEST="POST
/v1/actions/coupon/create-coupon
$TIMESTAMP
$BODY_HASH"

SIGNATURE=$(echo -n "$CANONICAL_REQUEST" | openssl dgst -sha256 -sign private_key.pem | base64 | tr -d '\n')

curl -X POST "https://api.waffo.ai/v1/actions/coupon/create-coupon" \
  -H "Content-Type: application/json" \
  -H "X-Merchant-Id: $MERCHANT_ID" \
  -H "X-Timestamp: $TIMESTAMP" \
  -H "X-Signature: $SIGNATURE" \
  -H "X-Idempotency-Key: $IDEMPOTENCY_KEY" \
  -d "$BODY"
```

```python Python theme={"system"}
import hashlib, time, base64, json, requests
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding

merchant_id = "MER_2aUyqjCzEIiEcYMKj7TZtw"
timestamp = str(int(time.time()))
body = {
    "storeId": "STO_2aUyqjCzEIiEcYMKj7TZtw",
    "name": "Black Friday 20% off",
    "discount": {"type": "percentage", "percentOff": 20},
    "duration": "repeating",
    "durationInPeriods": 3,
    "maxRedemptions": 1000,
    "redeemBy": "2026-12-01T00:00:00.000Z",
    "code": "bf2026",
    "promotionCodes": [{"code": "bf-new", "restrictions": {"perCustomerLimit": 1}}],
}

body_json = json.dumps(body, separators=(",", ":"))
body_hash = base64.b64encode(hashlib.sha256(body_json.encode()).digest()).decode()
path = "/v1/actions/coupon/create-coupon"
canonical = f"POST\n{path}\n{timestamp}\n{body_hash}"

with open("private_key.pem", "rb") as f:
    private_key = serialization.load_pem_private_key(f.read(), password=None)

signature = base64.b64encode(
    private_key.sign(canonical.encode(), padding.PKCS1v15(), hashes.SHA256())
).decode()

resp = requests.post(
    f"https://api.waffo.ai{path}",
    headers={
        "Content-Type": "application/json",
        "X-Merchant-Id": merchant_id,
        "X-Timestamp": timestamp,
        "X-Signature": signature,
        "X-Idempotency-Key": "MER_2aUyqjCzEIiEcYMKj7TZtw-0f8e2a9c-5b1d-4e7a-9c3f-2d6b8e1a4c70",
    },
    data=body_json,
)
print(resp.json())
```

## Success Response (200)

```json theme={"system"}
{
  "data": {
    "coupon": {
      "id": "CPN_5dXBtmF2HLlHfbPNm0Wcnz",
      "storeId": "STO_2aUyqjCzEIiEcYMKj7TZtw",
      "name": "Black Friday 20% off",
      "code": "BF2026",
      "status": "active",
      "discountType": "percentage",
      "percentOff": 20,
      "amountOff": null,
      "duration": "repeating",
      "durationInPeriods": 3,
      "productIds": [],
      "maxRedemptions": 1000,
      "redeemBy": "2026-12-01T00:00:00.000Z",
      "createdAt": "2026-11-01T08:00:00.000Z",
      "updatedAt": "2026-11-01T08:00:00.000Z",
      "promotionCodes": [
        {
          "id": "PMC_7fZDvoH4JNnJhdRPo2Yep1",
          "couponId": "CPN_5dXBtmF2HLlHfbPNm0Wcnz",
          "code": "BF-NEW",
          "status": "active",
          "restrictions": {
            "customerEmails": [],
            "perCustomerLimit": 1,
            "maxRedemptions": null,
            "expiresAt": "2026-12-01T00:00:00.000Z",
            "minimumAmount": null
          },
          "batchId": null,
          "createdAt": "2026-11-01T08:00:00.000Z"
        }
      ]
    }
  }
}
```

## Response Fields

The response is wrapped in `data.coupon`.

| Field | Type | Description |
| - | - | - |
| `id` | string | Coupon ID (`CPN_xxx`) |
| `storeId` | string | Store ID (`STO_xxx`) |
| `name` | string | Internal name |
| `code` | string | The coupon's own code, in uppercase. Buyers can type it at checkout |
| `status` | string | Always `active` in this response (see [Coupon Status Values](https://docs.waffo.ai/api-reference/endpoints/coupons/overview.md)) |
| `discountType` | string | `percentage` or `fixed_amount` |
| `percentOff` | number \| null | Percentage off; `null` for `fixed_amount` |
| `amountOff` | object \| null | Amount off per currency (display-format strings); `null` for `percentage`, and in a [response with warnings](#response-with-warnings) |
| `duration` | string | `once`, `repeating` or `forever` |
| `durationInPeriods` | integer \| null | Billing periods for `repeating`; otherwise `null` |
| `productIds` | string\[] | Products the coupon is limited to; empty means the whole store |
| `maxRedemptions` | integer \| null | Total redemption limit across the coupon's code and all its promotion codes |
| `redeemBy` | string \| null | Expiry (ISO 8601) |
| `createdAt` | string | Creation timestamp (ISO 8601) |
| `updatedAt` | string | Last update timestamp (ISO 8601) |
| `promotionCodes` | object\[] | The promotion codes created in this request, in request order (same fields as the [Create Promotion Code](https://docs.waffo.ai/api-reference/endpoints/promotion-codes/create-promotion-code.md) response; `restrictions.minimumAmount` is `null` in a [response with warnings](#response-with-warnings)); empty when none were requested |

> [!NOTE]
>
> Creation either succeeds or fails within the request. A `200` response always carries an `active` coupon. If the payment provider rejects the coupon or does not confirm the creation, the response is a `502`, the coupon is recorded as `failed` and every code of the request is released — create it again, with the same codes if you like. If a request is interrupted before it finishes, its coupon can stay `pending` and hold its codes; such a coupon is cleaned up on the spot — recorded as `failed`, codes released — by the next Create Coupon, Create Promotion Code or Generate Promotion Codes request in the same store and environment once it is more than 10 minutes old.

## Response With Warnings

Once the coupon is active its codes are usable, and the request no longer fails. If Pancake cannot convert the amounts to display format at that point, it still returns `200` with the `active` coupon: `amountOff` and every `promotionCodes[].restrictions.minimumAmount` are `null` — the response shape does not change — and a top-level `warnings` array says the amounts were not converted. All other fields are filled in as usual.

```json theme={"system"}
{
  "data": {
    "coupon": {
      "id": "CPN_5dXBtmF2HLlHfbPNm0Wcnz",
      "status": "active",
      "discountType": "fixed_amount",
      "amountOff": null,
      "...": "...",
      "promotionCodes": [
        {
          "code": "BF-NEW",
          "restrictions": { "minimumAmount": null, "...": "..." },
          "...": "..."
        }
      ]
    }
  },
  "warnings": [
    {
      "message": "Coupon was created, but amounts could not be converted to display format; amountOff and restrictions.minimumAmount are returned as null",
      "layer": "product",
      "aiHint": "- Query: read the coupon with the GraphQL `coupon(id)` query to get the display amounts; do not retry the creation"
    }
  ]
}
```

The coupon has been created as requested — do not create it again. Read it with GraphQL `coupon(id)` to get its amounts.

## Errors

> **Retry policy:** Never retry 4xx — fix the request and resubmit. Retry 5xx with exponential backoff (start 5s, max 3 attempts). Always retry with the same `X-Idempotency-Key`.

| Status | `errors[0].message` | What it means | Recommended handling |
| - | - | - | - |
| 400 | `Missing header: x-context-merchant-id` | The merchant context header was not forwarded | Fix your SDK configuration |
| 400 | `Missing or invalid header: x-context-environment` | Environment header is missing or not `test` / `prod` | Set `X-Environment` to `test` or `prod` |
| 400 | `Invalid JSON body` | The body is not valid JSON | Fix the body, resubmit |
| 400 | `Missing required field: storeId` / `Missing required field: name` | A required field was omitted | Add the missing field, resubmit |
| 400 | `Expected format: STO_xxx, got "X"` | `storeId` is not a valid Short ID | Use a `STO_` prefixed Short ID |
| 400 | `name must not exceed 64 characters` | `name` is too long | Shorten `name`, resubmit |
| 400 | `code must be a string` / `promotionCodes[N].code must be a string` | `code`, or the `code` of entry `N` (0-based) of `promotionCodes`, is not a string | Send a string, or omit it for a random code |
| 400 | `code must be 3-32 characters of letters, digits or '-'` / `promotionCodes[N].code must be 3-32 characters of letters, digits or '-'` | `code`, or the `code` of entry `N` (0-based), has an invalid character or length | Fix the code, resubmit |
| 400 | `promotionCodes must not exceed 20 entries` | More than 20 promotion codes | Create at most 20 with the coupon; add the rest with Create Promotion Code |
| 400 | `promotionCodes[N].code "X" duplicates another promotion code in this request` | Entry `N` (0-based) has the same code as an earlier entry (case-insensitive) | Remove the duplicate |
| 400 | `promotionCodes[N].code "X" duplicates the coupon code` | Entry `N` (0-based) has the same code as `code` (case-insensitive) | Use another code for the entry |
| 400 | `promotionCodes[N].restrictions.maxRedemptions must not exceed the coupon's maxRedemptions (M)` / `promotionCodes[N].restrictions.expiresAt must be more than 60 seconds in the future` / `promotionCodes[N].restrictions.expiresAt must not be later than the coupon's redeemBy (T)` | Entry `N` (0-based) goes beyond the coupon's limits, or its `expiresAt` is less than 60 seconds away | Fix that entry's restrictions |
| 400 | `discount.type must be percentage or fixed_amount` / `discount: discount.type must be percentage or fixed_amount` | `discount.type` is not allowed (first form), or `discount` is missing or not an object (second form) | Send `discount` with `type` `percentage` or `fixed_amount` |
| 400 | `discount.percentOff is required for percentage` / `discount.percentOff must be greater than 0` / `discount.percentOff must not exceed 100` / `discount.percentOff allows at most 2 decimal places` | `percentOff` is missing or out of range | Use a number with 0 \< x ≤ 100 and at most 2 decimals |
| 400 | `discount.amountOff: At least one currency is required` / `promotionCodes[N].restrictions.minimumAmount: At least one currency is required` | `amountOff`, or the `minimumAmount` of entry `N` (0-based) of `promotionCodes`, is an empty object | Add at least one currency |
| 400 | `discount.amountOff: Invalid currency code: "X". Must be 3 uppercase letters (e.g., "USD", "EUR", "JPY")` / `promotionCodes[N].restrictions.minimumAmount: Invalid currency code: "X". Must be 3 uppercase letters (e.g., "USD", "EUR", "JPY")` | A key in `amountOff`, or in the `minimumAmount` of entry `N`, is not an ISO 4217 code | Use 3 uppercase letters |
| 400 | `discount.amountOff: Invalid amount for X: "Y". Must be a positive number string (e.g., "5.00", "500")` / `promotionCodes[N].restrictions.minimumAmount: Invalid amount for X: "Y". Must be a positive number string (e.g., "5.00", "500")` | A value in `amountOff`, or in the `minimumAmount` of entry `N`, is not a positive number string | Use a positive decimal string |
| 400 | `discount.amountOff: Invalid amount for X: "Y". X allows at most D decimal places` / `discount.amountOff: Invalid amount for X: "Y". X does not allow decimal places` / `promotionCodes[N].restrictions.minimumAmount: Invalid amount for X: "Y". X allows at most D decimal places` / `promotionCodes[N].restrictions.minimumAmount: Invalid amount for X: "Y". X does not allow decimal places` | A value in `amountOff`, or in the `minimumAmount` of entry `N`, has more decimal places than the currency allows (e.g. USD `"0.001"`, JPY `"5.5"`). Amounts are not rounded | Use the currency's decimal places (USD 2, JPY 0, BHD 3) |
| 400 | `discount.amountOff: Invalid amount for X: "Y". Must be at least Z X` / `promotionCodes[N].restrictions.minimumAmount: Invalid amount for X: "Y". Must be at least Z X` | A value in `amountOff`, or in the `minimumAmount` of entry `N`, is less than one minor unit of the currency | Send at least the currency's minor unit (e.g. USD `"0.01"`) |
| 400 | `duration must be once, repeating or forever` | `duration` is not allowed | Use `once`, `repeating` or `forever` |
| 400 | `durationInPeriods is required when duration is repeating, and only allowed then` | `durationInPeriods` is missing for `repeating`, or set for another `duration` | Send `durationInPeriods` only with `repeating` |
| 400 | `durationInPeriods must be an integer` / `durationInPeriods must be at least 1` / `durationInPeriods must not exceed 2147483647` | `durationInPeriods` is not a positive integer, or is too large | Use an integer from 1 to 2147483647 |
| 400 | `maxRedemptions must be an integer` / `maxRedemptions must be at least 1` / `maxRedemptions must not exceed 2147483647` | The coupon's `maxRedemptions` is not a positive integer, or is too large | Use an integer from 1 to 2147483647, or `null` for unlimited |
| 400 | `promotionCodes[N].restrictions.X must be an integer` / `promotionCodes[N].restrictions.X must be at least 1` / `promotionCodes[N].restrictions.X must not exceed 2147483647` | `X` (`perCustomerLimit` or `maxRedemptions`) of entry `N` (0-based) is not a positive integer, or is too large — e.g. `promotionCodes[1].restrictions.perCustomerLimit must be at least 1` | Use an integer from 1 to 2147483647, or `null` for unlimited |
| 400 | `productIds must not exceed 100 entries` | Too many products | Send at most 100 product IDs |
| 400 | `Expected format: PROD_xxx, got "X"` | A `productIds` entry is not a valid Short ID | Use `PROD_` prefixed Short IDs |
| 400 | `redeemBy must be an ISO 8601 datetime` / `redeemBy must be more than 60 seconds in the future` | `redeemBy` is malformed, or is less than 60 seconds away | Send an ISO 8601 time with an offset (e.g. `Z`) more than 60 seconds in the future |
| 400 | `restrictions is not a coupon field; set it in promotionCodes[].restrictions` | Buyer restrictions were sent on the coupon. A coupon only has `redeemBy` and `maxRedemptions` | Move them to `promotionCodes[].restrictions` (or set them on promotion codes created later) |
| 400 | `dailyRedemptionLimit is not supported` | `dailyRedemptionLimit` was sent on the coupon (any value, including `null`). This field is not supported | Remove the field |
| 400 | `promotionCodes[N].restrictions.firstTimeOnly is not supported` / `promotionCodes[N].restrictions.dailyRedemptionLimit is not supported` | The `restrictions` of entry `N` (0-based) include `firstTimeOnly` or `dailyRedemptionLimit` (any value, including `null` / `false`). These fields are not supported | Remove the field |
| 400 | `promotionCodes[N].restrictions.expiresAt must be an ISO 8601 datetime` | The `expiresAt` of entry `N` (0-based) is malformed | Send an ISO 8601 time with an offset (e.g. `Z`) |
| 400 | `promotionCodes[N].restrictions.customerEmails[M] must be a valid email address` / `promotionCodes[N].restrictions.customerEmails must not exceed 100 entries` | Email `M` (0-based) in the `customerEmails` of entry `N` (0-based) is not a valid address, or the list has more than 100 entries | Fix the list, resubmit |
| 400 | `X: Invalid input: expected Y, received Z` | Field `X` has the wrong JSON type. `X` is its full path, e.g. `promotionCodes[0].restrictions.maxRedemptions: Invalid input: expected number, received string` or `promotionCodes[0].restrictions.customerEmails: Invalid input: expected array, received null` | Send the field with the type documented above |
| 401 | `Authentication required` | Authentication failed | Verify API key, timestamp, and signature |
| 403 | `Access denied` | The store does not exist, or the caller is not its owner or admin | Use a key of the store's owner or admin, and verify `storeId` |
| 404 | `Product not found in this store: PROD_xxx` | A `productIds` entry does not exist in this store | Remove or fix the product ID |
| 409 | `Code "X" is already used by an active code in this store` | Another active code in this store and environment — another coupon's code or a promotion code — has this code; this applies to `code` and to every `promotionCodes[].code`, including codes your first attempt created if you retried without an idempotency key. `errors` holds one entry per taken code; nothing is created | Choose other codes. If this is a retry, look the coupon up with GraphQL instead of creating it again |
| 409 | `A generated code collided with an existing code; retry the request` | Random codes (the coupon's or a promotion code's) are generated to avoid codes already in use, but at write time one of them had just been taken — for example by a concurrent request. Pancake regenerated the random codes on each attempt, up to 3 attempts in total, and every attempt collided. Nothing is created | Retry the request |
| 409 | `Request is being processed` | A request with the same `X-Idempotency-Key` is still running | **Wait a few seconds, then retry with the same key** |
| 502 | `Payment provider rejected the coupon (code: X); its codes have been released` | The payment provider refused the coupon. It is recorded as `failed` and all its codes are free again | Check the discount settings; create the coupon again (with a new idempotency key) |
| 502 | `Payment provider did not confirm the coupon creation; its codes have been released, please retry` | The payment provider did not confirm the creation (timeout or provider error) and Pancake could not find the coupon there either. It is handled as a failure: recorded as `failed`, codes released | **Retry the request** — the same codes are free again |
| 500 | `Internal server error` | Unexpected server-side failure before the coupon became active. If the coupon had already been recorded, it is marked `failed` and its codes are released. Once the coupon is active the response is `200`, never `500` (see [Response With Warnings](#response-with-warnings)) | Retry with exponential backoff (start 5s, max 3 attempts), same idempotency key |


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