active coupon. All codes in the batch share the same restrictions and batchId, and are returned together in the response — for example, to export as a CSV of single-use codes.
POST /v1/actions/promotion-code/generate-promotion-codes
Always send an
X-Idempotency-Key and retry with the same key. Codes are random, so a retry without the key creates a second batch.Request Body
| Field | Type | Required | Description |
|---|---|---|---|
couponId | string | Yes | Coupon ID (Short ID format CPN_xxx). The coupon must be active |
count | integer | Yes | Number of codes, 1–500 |
prefix | string | No | Code prefix, 1–12 letters, digits or -, converted to uppercase |
length | integer | No | Length of the random part, 8–16. Default 10. Uses uppercase letters and digits without 0, O, 1, I and L |
restrictions | object | No | Applied to every code. Same fields as Create Promotion Code; for single-use codes set maxRedemptions: 1 |
prefix + random part, e.g. VIP-7KQ2MZ9H3X. Generated codes never match an active code in the same store and environment.
Example Request
const { batchId, promotionCodes } = await client.promotionCodes.generate(
{
couponId: "CPN_5dXBtmF2HLlHfbPNm0Wcnz",
count: 100,
prefix: "vip-",
length: 10,
restrictions: { maxRedemptions: 1 },
},
{ idempotencyKey: "MER_2aUyqjCzEIiEcYMKj7TZtw-7c41d0b2-93ae-4f68-b5d1-6a2e9f03c8d4" },
);
console.log(promotionCodes.length, promotionCodes[0].code); // => 100 "VIP-7KQ2MZ9H3X"
MERCHANT_ID="MER_2aUyqjCzEIiEcYMKj7TZtw"
IDEMPOTENCY_KEY="MER_2aUyqjCzEIiEcYMKj7TZtw-7c41d0b2-93ae-4f68-b5d1-6a2e9f03c8d4"
TIMESTAMP=$(date +%s)
BODY='{"couponId":"CPN_5dXBtmF2HLlHfbPNm0Wcnz","count":100,"prefix":"vip-","length":10,"restrictions":{"maxRedemptions":1}}'
BODY_HASH=$(echo -n "$BODY" | openssl dgst -sha256 -binary | base64 | tr -d '\n')
CANONICAL_REQUEST="POST
/v1/actions/promotion-code/generate-promotion-codes
$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/generate-promotion-codes" \
-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 = {
"couponId": "CPN_5dXBtmF2HLlHfbPNm0Wcnz",
"count": 100,
"prefix": "vip-",
"length": 10,
"restrictions": {"maxRedemptions": 1},
}
body_json = json.dumps(body, separators=(",", ":"))
body_hash = base64.b64encode(hashlib.sha256(body_json.encode()).digest()).decode()
path = "/v1/actions/promotion-code/generate-promotion-codes"
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-7c41d0b2-93ae-4f68-b5d1-6a2e9f03c8d4",
},
data=body_json,
)
print(resp.json())
Success Response (200)
{
"data": {
"batchId": "9b2f6c1e-3d4a-4f5b-8c7d-1e2f3a4b5c6d",
"promotionCodes": [
{
"id": "PMC_7fZDvoH4JNnJhdRPobYepB",
"couponId": "CPN_5dXBtmF2HLlHfbPNm0Wcnz",
"code": "VIP-7KQ2MZ9H3X",
"status": "active",
"restrictions": {
"customerEmails": [],
"perCustomerLimit": null,
"maxRedemptions": 1,
"expiresAt": "2026-12-01T00:00:00.000Z",
"minimumAmount": null
},
"batchId": "9b2f6c1e-3d4a-4f5b-8c7d-1e2f3a4b5c6d",
"createdAt": "2026-11-03T08:00:00.000Z"
}
]
}
}
Response Fields
| Field | Type | Description |
|---|---|---|
batchId | string | Batch ID shared by all codes in this call. Filter GraphQL promotionCodes by it to read the batch again |
promotionCodes | array | The generated promotion codes. Each item has the same fields as the Create Promotion Code response |
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 / Missing required field: count | A required field was omitted | Add the missing field, resubmit |
| 400 | Expected format: CPN_xxx, got "X" | couponId is not a valid Short ID | Use a CPN_ prefixed Short ID |
| 400 | count must be an integer / count must be between 1 and 500 | count is out of range | Use an integer from 1 to 500; split larger batches into several calls |
| 400 | prefix must be 1-12 characters of letters, digits or '-' | prefix has an invalid character or length | Fix prefix, resubmit |
| 400 | length must be an integer / length must be between 8 and 16 | length is out of range | Use an integer from 8 to 16 |
| 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 |
| 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. length: Invalid input: expected number, received string or 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") |
| 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 codes kept colliding with existing codes; try a longer length or another prefix | Random codes kept matching existing active codes. No codes were created | Increase length or change prefix, then retry |
| 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 |