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; add promotion codes later with Create Promotion Code.
POST /v1/actions/coupon/create-coupon
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). 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 |
{
"discount": {
"type": "fixed_amount",
"amountOff": { "USD": "5.00", "EUR": "4.50", "JPY": "800" }
}
}
Promotion Code Object
Each entry ofpromotionCodes takes the same fields as Create Promotion Code, 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. 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
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"
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"
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)
{
"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 indata.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) |
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 |
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 response; restrictions.minimumAmount is null in a response with warnings); empty when none were requested |
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 returns200 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.
{
"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"
}
]
}
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) | Retry with exponential backoff (start 5s, max 3 attempts), same idempotency key |