> ## 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 Promotion Code

> Add a promotion code to a coupon

Add one promotion code to an `active` coupon. Pass your own `code`, or omit it to get a random one.

```
POST /v1/actions/promotion-code/create-promotion-code
```

**Authentication:** API Key

## Request Body

| Field | Type | Required | Description |
| - | - | - | - |
| `couponId` | string | Yes | Coupon ID (Short ID format `CPN_xxx`). The coupon must be `active` |
| `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. Omit it to generate a random 10-character code |
| `restrictions` | object | No | Buyer restrictions of this code (see below); the coupon's `redeemBy` and `maxRedemptions` also apply |

## Restrictions Object

| Field | Type | Description |
| - | - | - |
| `customerEmails` | string\[] | Only these buyers (max 100 emails, case-insensitive). Empty means any buyer |
| `perCustomerLimit` | integer \| null | Lifetime uses per buyer (≥ 1). `null` = unlimited |
| `maxRedemptions` | integer \| null | Uses of this promotion code (≥ 1). Must not exceed the coupon's `maxRedemptions`. `null` = no extra limit |
| `expiresAt` | string \| null | Expiry (ISO 8601, more than 60 seconds in the future). Must not be later than the coupon's `redeemBy`. Defaults to the coupon's `redeemBy`. Returns 409 if the coupon has expired |
| `minimumAmount` | object \| null | Minimum order amount per currency, before discount and tax, as display-format strings (e.g. `{ "USD": "50.00" }`), with no more decimal places than the currency allows. Currencies not listed have no minimum |

## Example Request

```typescript SDK theme={"system"}
const { promotionCode } = await client.promotionCodes.create(
  {
    couponId: "CPN_5dXBtmF2HLlHfbPNm0Wcnz",
    code: "kol-alice", // optional; a random code is generated when omitted
    restrictions: {
      maxRedemptions: 100,
      minimumAmount: { USD: "50.00" },
    },
  },
  { idempotencyKey: "MER_2aUyqjCzEIiEcYMKj7TZtw-0f8e2a9c-5b1d-4e7a-9c3f-2d6b8e1a4c70" },
);
console.log(promotionCode.code); // => "KOL-ALICE"
```

```bash cURL theme={"system"}
MERCHANT_ID="MER_2aUyqjCzEIiEcYMKj7TZtw"
IDEMPOTENCY_KEY="MER_2aUyqjCzEIiEcYMKj7TZtw-0f8e2a9c-5b1d-4e7a-9c3f-2d6b8e1a4c70"
TIMESTAMP=$(date +%s)
BODY='{"couponId":"CPN_5dXBtmF2HLlHfbPNm0Wcnz","code":"kol-alice","restrictions":{"maxRedemptions":100,"minimumAmount":{"USD":"50.00"}}}'
BODY_HASH=$(echo -n "$BODY" | openssl dgst -sha256 -binary | base64 | tr -d '\n')
CANONICAL_REQUEST="POST
/v1/actions/promotion-code/create-promotion-code
$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/promotion-code/create-promotion-code" \
  -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 = {
    "couponId": "CPN_5dXBtmF2HLlHfbPNm0Wcnz",
    "code": "kol-alice",
    "restrictions": {"maxRedemptions": 100, "minimumAmount": {"USD": "50.00"}},
}

body_json = json.dumps(body, separators=(",", ":"))
body_hash = base64.b64encode(hashlib.sha256(body_json.encode()).digest()).decode()
path = "/v1/actions/promotion-code/create-promotion-code"
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": {
    "promotionCode": {
      "id": "PMC_6eYCunG3IMmIgcQOnaXdoA",
      "couponId": "CPN_5dXBtmF2HLlHfbPNm0Wcnz",
      "code": "KOL-ALICE",
      "status": "active",
      "restrictions": {
        "customerEmails": [],
        "perCustomerLimit": null,
        "maxRedemptions": 100,
        "expiresAt": "2026-12-01T00:00:00.000Z",
        "minimumAmount": { "USD": "50.00" }
      },
      "batchId": null,
      "createdAt": "2026-11-02T08:00:00.000Z"
    }
  }
}
```

## Response Fields

The response is wrapped in `data.promotionCode`.

| Field | Type | Description |
| - | - | - |
| `id` | string | Promotion code ID (`PMC_xxx`) |
| `couponId` | string | Coupon ID (`CPN_xxx`) |
| `code` | string | The code, in uppercase |
| `status` | string | `active` or `inactive` |
| `restrictions` | object | `customerEmails`, `perCustomerLimit`, `maxRedemptions`, `expiresAt`, `minimumAmount` |
| `batchId` | string \| null | Batch ID when created by [Generate Promotion Codes](https://docs.waffo.ai/api-reference/endpoints/promotion-codes/generate-promotion-codes.md); otherwise `null` |
| `createdAt` | string | Creation timestamp (ISO 8601) |

## 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: couponId` | `couponId` was omitted | Add `couponId`, resubmit |
| 400 | `Expected format: CPN_xxx, got "X"` | `couponId` is not a valid Short ID | Use a `CPN_` prefixed Short ID |
| 400 | `code must be a string` / `code must be 3-32 characters of letters, digits or '-'` | `code` is not a string, or has an invalid character or length | Fix `code`, or omit it to get a random code |
| 400 | `restrictions.customerEmails[N] must be a valid email address` / `restrictions.customerEmails must not exceed 100 entries` | Email `N` (0-based) in `customerEmails` is not a valid address, or the list has more than 100 entries | Fix the list, resubmit |
| 400 | `restrictions.firstTimeOnly is not supported` / `restrictions.dailyRedemptionLimit is not supported` | `restrictions` includes `firstTimeOnly` or `dailyRedemptionLimit` (any value, including `null` / `false`). These fields are not supported | Remove the field |
| 400 | `restrictions.X must be an integer` / `restrictions.X must be at least 1` / `restrictions.X must not exceed 2147483647` | `perCustomerLimit` or `maxRedemptions` is not a positive integer, or is too large. `X` is the field, e.g. `restrictions.perCustomerLimit must be at least 1` | Use an integer from 1 to 2147483647, or `null` |
| 400 | `restrictions.expiresAt must be an ISO 8601 datetime` | `expiresAt` is malformed | Send an ISO 8601 time with an offset (e.g. `Z`) |
| 400 | `X: Invalid input: expected Y, received Z` | Field `X` has the wrong JSON type. `X` is its full path, e.g. `restrictions.maxRedemptions: Invalid input: expected number, received string` | Send the field with the type documented above |
| 400 | `restrictions.minimumAmount: At least one currency is required` | `minimumAmount` is an empty object | Add a currency, or send `null` |
| 400 | `restrictions.minimumAmount: Invalid currency code: "X". Must be 3 uppercase letters (e.g., "USD", "EUR", "JPY")` | A key in `minimumAmount` is not an ISO 4217 code | Use 3 uppercase letters |
| 400 | `restrictions.minimumAmount: Invalid amount for X: "Y". Must be a positive number string (e.g., "5.00", "500")` | A `minimumAmount` value is not a positive number string | Use a positive decimal string |
| 400 | `restrictions.minimumAmount: Invalid amount for X: "Y". X allows at most N decimal places` / `restrictions.minimumAmount: Invalid amount for X: "Y". X does not allow decimal places` | An `minimumAmount` value 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 | `restrictions.minimumAmount: Invalid amount for X: "Y". Must be at least Z X` | An `minimumAmount` value is less than one minor unit of the currency | Send at least the currency's minor unit (e.g. USD `"0.01"`) |
| 400 | `restrictions.maxRedemptions must not exceed the coupon's maxRedemptions (X)` | The limit is higher than the coupon's | Lower `maxRedemptions` to at most the coupon's |
| 400 | `restrictions.expiresAt must not be later than the coupon's redeemBy (X)` | The expiry is after the coupon's expiry | Use a time no later than the coupon's `redeemBy` |
| 400 | `restrictions.expiresAt must be more than 60 seconds in the future` | The `expiresAt` you sent is less than 60 seconds away, or has already passed | Send an `expiresAt` more than 60 seconds in the future |
| 401 | `Authentication required` | Authentication failed | Verify API key, timestamp, and signature |
| 404 | `Coupon not found` | The coupon does not exist, belongs to another merchant, or is in the other environment | Verify `couponId` and the environment of your API key |
| 409 | `Coupon is not active (status: X)` / `Coupon is not active` | The coupon is `pending` (still being created), `failed` or `inactive` | Use an active coupon |
| 409 | `Coupon has expired (redeemBy: X); new promotion codes cannot be added` | The coupon has expired (less than 60 seconds before `redeemBy` counts as expired), whether or not `expiresAt` was sent | An expired coupon cannot get new promotion codes; create a new coupon |
| 409 | `Generated code kept colliding with existing codes; retry the request or pass a code` | `code` was omitted and the random code kept matching existing active codes. No promotion code was created | Retry with a new idempotency key, or pass a `code` |
| 409 | `Code "X" is already used by an active code in this store` | Another active code in this store and environment — a coupon's code or a promotion code — has this code, including the one your first attempt created, if you retried without an idempotency key | Choose another code. If this is a retry, look the promotion code up with GraphQL instead |
| 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** |
| 500 | `Internal server error` | Unexpected server-side failure | 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.