code —— 买家可在收银台输入 —— 并可同时创建最多 20 个促销码。全部内容在一次请求中创建:优惠券、它的码与全部促销码一起生效,或者都不创建。优惠券只按到期时间(redeemBy)与总兑换次数(maxRedemptions)限制兑换;买家限制(限定买家邮箱、每位买家次数、最低消费)设置在促销码上。优惠券也可在结账会话上通过 couponId 使用;之后可用创建促销码补充促销码。
POST /v1/actions/coupon/create-coupon
请求体
| 字段 | 类型 | 必需 | 说明 |
|---|---|---|---|
storeId | string | 是 | 店铺 ID(Short ID 格式 STO_xxx) |
name | string | 是 | 内部名称(最多 64 个字符),买家看不到 |
code | string | 否 | 优惠券自己的码,买家在收银台输入:3–32 个字母、数字或 -,不区分大小写,以大写存储。不能与同一店铺、同一环境的有效码重复,也不能与 promotionCodes 中的码重复。不传时随机生成 10 位 |
discount | object | 是 | { "type": "percentage", "percentOff": 20 } 或 { "type": "fixed_amount", "amountOff": { "USD": "5.00" } }(见下文) |
duration | string | 否 | 对订阅生效几个计费周期:once(默认)、repeating 或 forever。一次性订单只优惠一次 |
durationInPeriods | integer | repeating 时必需 | 计费周期数(≥ 1)。仅在 duration 为 repeating 时允许传入 |
productIds | string[] | 否 | 仅对这些商品生效(PROD_xxx,最多 100 个)。为空或不传表示全店通用 |
maxRedemptions | integer | null | 否 | 优惠券的码与全部促销码合计的总兑换上限(≥ 1)。退款不会退回次数。null 表示不限 |
redeemBy | string | null | 否 | 到期时间(ISO 8601,必须晚于当前时间 60 秒以上)。已挂在订阅上的优惠也会在此时结束 |
promotionCodes | object[] | 否 | 与优惠券一起创建的促销码,0–20 个(见促销码对象)。不传则创建不带码的优惠券 |
优惠对象
| 字段 | 类型 | 说明 |
|---|---|---|
type | string | percentage 或 fixed_amount |
percentOff | number | percentage 时必需。0 < x ≤ 100,最多 2 位小数。100 表示订单免费 |
amountOff | object | fixed_amount 时必需。按 ISO 4217 币种给出减免金额(显示格式字符串),小数位不能超过该币种的小数位(USD 2 位、JPY 0 位、BHD 3 位),超出时返回 400、不做四舍五入。订单币种不在其中时不能使用该优惠券,不做汇率换算 |
{
"discount": {
"type": "fixed_amount",
"amountOff": { "USD": "5.00", "EUR": "4.50", "JPY": "800" }
}
}
促销码对象
promotionCodes 的每一项与创建促销码的字段相同(不含 couponId):
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
code | string | 否 | 买家输入的码:3–32 个字母、数字或 -,不区分大小写,以大写存储。不能与同一店铺、同一环境的有效码重复,也不能与本次请求中的其他项、优惠券的 code重复。不传时随机生成 10 位 |
restrictions | object | 否 | customerEmails、perCustomerLimit、maxRedemptions(≤ 优惠券的 maxRedemptions)、expiresAt(晚于当前时间 60 秒以上,且 ≤ 优惠券的 redeemBy,默认取它)、minimumAmount —— 见限制对象。perCustomerLimit、maxRedemptions、expiresAt、minimumAmount 可传 null,表示不设置(expiresAt: null 取优惠券的 redeemBy);customerEmails 不接受 null,不设置时省略,或传 [] |
请求示例
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())
成功响应 (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"
}
]
}
}
}
响应字段
响应包裹在data.coupon 中。
| 字段 | 类型 | 说明 |
|---|---|---|
id | string | 优惠券 ID(CPN_xxx) |
storeId | string | 店铺 ID(STO_xxx) |
name | string | 内部名称 |
code | string | 优惠券自己的码(大写),买家可在收银台输入 |
status | string | 本响应中始终为 active(见优惠券状态值) |
discountType | string | percentage 或 fixed_amount |
percentOff | number | null | 减免百分比;fixed_amount 时为 null |
amountOff | object | null | 按币种的减免金额(显示格式字符串);percentage 时为 null,带 warnings 的响应中也为 null |
duration | string | once、repeating 或 forever |
durationInPeriods | integer | null | repeating 的计费周期数;其他情况为 null |
productIds | string[] | 适用商品;为空表示全店通用 |
maxRedemptions | integer | null | 优惠券的码与全部促销码合计的总兑换上限 |
redeemBy | string | null | 到期时间(ISO 8601) |
createdAt | string | 创建时间(ISO 8601) |
updatedAt | string | 最后更新时间(ISO 8601) |
promotionCodes | object[] | 本次请求创建的促销码,顺序与请求一致(字段同创建促销码的响应;带 warnings 的响应中 restrictions.minimumAmount 为 null);未请求时为空数组 |
创建在本次请求内要么成功、要么失败。
200 响应中的优惠券始终为 active。支付服务商拒绝或未确认创建时返回 502,优惠券记为 failed,本次请求的全部码已释放 —— 可以重新创建,也可以继续使用同样的码。请求在完成前中途中断时,优惠券可能停留在 pending 并占用它的码;这样的优惠券创建超过 10 分钟后,会由同一店铺同一环境的下一次创建优惠券、创建促销码或批量生成促销码请求就地清理 —— 记为 failed,码被释放。带 warnings 的响应
优惠券变为active 后它的码即可使用,请求不会再失败。若此时 Pancake 无法把金额换算为显示格式,仍返回 200 和 active 的优惠券:amountOff 与每个 promotionCodes[].restrictions.minimumAmount 为 null —— 响应结构不变 —— 并在响应顶层附带 warnings 数组,说明金额未换算。其余字段照常返回。
{
"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) 查询该优惠券以获取金额。
错误响应
重试策略:4xx 一律不要重试 — 修正请求后重发。5xx 指数退避重试(起步 5s,最多 3 次)。重试时始终使用同一个 X-Idempotency-Key。
| 状态码 | errors[0].message | 含义 | 推荐处理 |
|---|---|---|---|
| 400 | Missing header: x-context-merchant-id | 商户上下文 header 未透传 | 修正 SDK 配置 |
| 400 | Missing or invalid header: x-context-environment | 环境 header 缺失或不是 test / prod | 把 X-Environment 设为 test 或 prod |
| 400 | Invalid JSON body | 请求体不是合法 JSON | 修正请求体后重发 |
| 400 | Missing required field: storeId / Missing required field: name | 必填字段缺失 | 补齐字段后重发 |
| 400 | Expected format: STO_xxx, got "X" | storeId 不是合法 Short ID | 使用 STO_ 前缀的 Short ID |
| 400 | name must not exceed 64 characters | name 过长 | 缩短 name 后重发 |
| 400 | code must be a string / promotionCodes[N].code must be a string | code,或 promotionCodes 第 N 项(从 0 开始)的 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,或第 N 项(从 0 开始)的 code 含非法字符或长度不符 | 修正该码后重发 |
| 400 | promotionCodes must not exceed 20 entries | 促销码超过 20 个 | 随优惠券最多创建 20 个,其余用创建促销码补充 |
| 400 | promotionCodes[N].code "X" duplicates another promotion code in this request | 第 N 项(从 0 开始)的码与前面某一项相同(不区分大小写) | 去掉重复项 |
| 400 | promotionCodes[N].code "X" duplicates the coupon code | 第 N 项(从 0 开始)的码与 code 相同(不区分大小写) | 为该项换一个码 |
| 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) | 第 N 项(从 0 开始)超出优惠券的限制,或其 expiresAt 距当前不足 60 秒 | 修正该项的限制 |
| 400 | discount.type must be percentage or fixed_amount / discount: discount.type must be percentage or fixed_amount | discount.type 不在允许范围内(前一种),或缺少 discount、discount 不是对象(后一种) | 传入 type 为 percentage 或 fixed_amount 的 discount |
| 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 缺失或超出范围 | 使用 0 < x ≤ 100、最多 2 位小数的数值 |
| 400 | discount.amountOff: At least one currency is required / promotionCodes[N].restrictions.minimumAmount: At least one currency is required | amountOff,或 promotionCodes 第 N 项(从 0 开始)的 minimumAmount 是空对象 | 至少提供一个币种 |
| 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") | amountOff,或第 N 项 minimumAmount 的 key 不是合法 ISO 4217 代码 | 改为 3 位大写字母 |
| 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") | amountOff,或第 N 项 minimumAmount 的值不是正数字符串 | 改为正数十进制字符串 |
| 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 | amountOff,或第 N 项 minimumAmount 的金额超出该币种的小数位(如 USD "0.001"、JPY "5.5"),服务端不做四舍五入 | 按币种小数位填写(USD 最多 2 位、JPY 为整数、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 | amountOff,或第 N 项 minimumAmount 的金额换算后不足该币种的 1 个最小单位 | 至少填写该币种的最小单位(如 USD "0.01") |
| 400 | duration must be once, repeating or forever | duration 不在允许范围内 | 改为 once、repeating 或 forever |
| 400 | durationInPeriods is required when duration is repeating, and only allowed then | repeating 时缺少 durationInPeriods,或其他 duration 传了该字段 | 仅在 repeating 时传 durationInPeriods |
| 400 | durationInPeriods must be an integer / durationInPeriods must be at least 1 / durationInPeriods must not exceed 2147483647 | durationInPeriods 不是正整数,或过大 | 改为 1 到 2147483647 之间的整数 |
| 400 | maxRedemptions must be an integer / maxRedemptions must be at least 1 / maxRedemptions must not exceed 2147483647 | 优惠券的 maxRedemptions 不是正整数或过大 | 改为 1 到 2147483647 之间的整数,不限时传 null |
| 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 | 第 N 个(从 0 开始)促销码的 X(perCustomerLimit 或 maxRedemptions)不是正整数或过大,如 promotionCodes[1].restrictions.perCustomerLimit must be at least 1 | 改为 1 到 2147483647 之间的整数,不限时传 null |
| 400 | productIds must not exceed 100 entries | 商品数量过多 | 最多传 100 个商品 ID |
| 400 | Expected format: PROD_xxx, got "X" | productIds 中有不合法的 Short ID | 使用 PROD_ 前缀的 Short ID |
| 400 | redeemBy must be an ISO 8601 datetime / redeemBy must be more than 60 seconds in the future | redeemBy 格式错误,或距当前不足 60 秒 | 传入带时区偏移(如 Z)、晚于当前时间 60 秒以上的 ISO 8601 时间 |
| 400 | restrictions is not a coupon field; set it in promotionCodes[].restrictions | 在优惠券上传了买家限制。优惠券只有 redeemBy 与 maxRedemptions | 移到 promotionCodes[].restrictions(或之后创建的促销码)上 |
| 400 | dailyRedemptionLimit is not supported | 在优惠券上传了 dailyRedemptionLimit(任意值,含 null),暂不支持该字段 | 去掉该字段 |
| 400 | promotionCodes[N].restrictions.firstTimeOnly is not supported / promotionCodes[N].restrictions.dailyRedemptionLimit is not supported | 第 N 项(从 0 开始)促销码的 restrictions 带了 firstTimeOnly 或 dailyRedemptionLimit(任意值,含 null / false),暂不支持这两个字段 | 去掉该字段 |
| 400 | promotionCodes[N].restrictions.expiresAt must be an ISO 8601 datetime | 第 N 项(从 0 开始)的 expiresAt 格式不正确 | 传带时区偏移(如 Z)的 ISO 8601 时间 |
| 400 | promotionCodes[N].restrictions.customerEmails[M] must be a valid email address / promotionCodes[N].restrictions.customerEmails must not exceed 100 entries | 第 N 项(从 0 开始)customerEmails 中的第 M 个(从 0 开始)不是有效邮箱,或列表超过 100 个 | 修正列表后重发 |
| 400 | X: Invalid input: expected Y, received Z | 字段 X 的 JSON 类型不对,X 为完整字段路径,如 promotionCodes[0].restrictions.maxRedemptions: Invalid input: expected number, received string、promotionCodes[0].restrictions.customerEmails: Invalid input: expected array, received null | 按上文的类型传该字段 |
| 401 | Authentication required | 认证失败 | 检查 API Key、时间戳与签名 |
| 403 | Access denied | 店铺不存在,或调用方不是该店铺的 owner / admin | 使用店铺 owner 或 admin 的 API Key,并核对 storeId |
| 404 | Product not found in this store: PROD_xxx | productIds 中的商品在本店不存在 | 删除或修正该商品 ID |
| 409 | Code "X" is already used by an active code in this store | 同一店铺、同一环境中已有有效的码(其他优惠券的码或促销码)使用这个码;code 与每个 promotionCodes[].code 都适用 —— 若未带幂等键重试,也包括第一次请求已创建的码。每个被占用的码对应 errors 中的一项;本次未创建任何内容 | 换别的码。如果是重试,请改用 GraphQL 查询该优惠券,不要再次创建 |
| 409 | A generated code collided with an existing code; retry the request | 随机码(优惠券或促销码的)生成时已避开已占用的码,但写入时其中有码刚被占用(例如被并发请求占用)。Pancake 每次尝试都重新生成随机码,共尝试至多 3 次,每次都冲突。本次未创建任何内容 | 重试请求 |
| 409 | Request is being processed | 使用同一 X-Idempotency-Key 的请求仍在处理中 | 等待几秒后用同一个键重试 |
| 502 | Payment provider rejected the coupon (code: X); its codes have been released | 支付服务商拒绝了该优惠券。优惠券记为 failed,全部码已释放 | 检查优惠设置;用新的幂等键重新创建 |
| 502 | Payment provider did not confirm the coupon creation; its codes have been released, please retry | 支付服务商未确认创建(超时或服务商错误),Pancake 在服务商侧也没有找到该优惠券。按失败处理:优惠券记为 failed,全部码已释放 | 重试请求 —— 同样的码已可再次使用 |
| 500 | Internal server error | 优惠券变为 active 之前服务端意外失败。若优惠券已写入,会被记为 failed 并释放它的码。优惠券变为 active 后只会返回 200,不会返回 500(见带 warnings 的响应) | 指数退避重试(起步 5s,最多 3 次),沿用同一个幂等键 |