优惠与促销码
商品目录接口回答的是「每个房型里我能订什么」。这里的接口回答的是「这段住宿适用哪些优惠」,也正是预订引擎做销售展示所需要的:对外名称、描述、图片、套餐包含什么,以及针对这批客人的价格。
房价方案、套餐和促销,本质上是同一类对象。它们的区别在于 kind,以及价格如何从父方案推导而来,所以一个套餐的分发和定价方式,与任何其他房价完全一样。
GET /rate-plans权限范围: availability:read
返回酒店为预订引擎发布的每一项优惠,并按所请求的住宿与人数完成定价。
| 参数 | 必填 | 说明 |
|---|---|---|
check_in | 是 | YYYY-MM-DD。 |
check_out | 是 | YYYY-MM-DD,不含当天。 |
adults | 否 | 默认为 2。 |
children | 否 | 默认为 0。 |
room_type_id | 否 | 限定在一个房型内。 |
curl "$VRDN_BASE/rate-plans?check_in=2026-08-01&check_out=2026-08-04&adults=2" \
-H "Authorization: Bearer $VRDN_KEY"{
"check_in": "2026-08-01",
"check_out": "2026-08-04",
"data": [
{
"rate_plan_id": "5c1e8f4a-2b7d-4a9c-8e6f-1d3b9a5c7e02",
"room_type_id": "9b2f6c3e-8d1a-4f5b-a7c9-4e0d2b8f1a63",
"room_type_name": "Ocean Suite",
"public_name": "Half Board Escape",
"kind": "package",
"description": "Breakfast and dinner included daily.",
"image_url": "https://cdn.example.com/half-board.jpg",
"inclusions": [
{ "label": "Breakfast", "frequency": "perGuestPerNight", "included_quantity": 1 },
{ "label": "Dinner", "frequency": "perGuestPerNight", "included_quantity": 1 }
],
"min_los": 2,
"max_los": null,
"currency": "USD",
"total": "1341.60",
"room_subtotal": "1020.00",
"taxes_total": "321.60",
"base_occupancy": 2,
"nightly_rates": [{ "date": "2026-08-01", "day_of_week": 6, "rate": "340.00", "source": "interval" }]
}
]
}只有以下条件全部成立时,一项优惠才会出现。任何一条不成立,它就直接不在响应里,没有部分展示,也没有「不可用」的条目。
- 方案处于启用状态,且酒店已为预订引擎开启它。
- 这段住宿落在该优惠的可预订窗口与可入住窗口之内。
- 住宿天数满足该优惠的最短与最长要求,且每一晚都落在允许的星期上。
- 该房型装得下这批客人。
- 该方案没有被促销码锁住。
被促销码锁住的优惠绝不会出现在这里。只有通过下面介绍的促销码校验成功,才能拿到它们。
inclusions 列出一个套餐打包了什么,以及每一项按什么频次重复:perStay、perNight、perGuest 或 perGuestPerNight。它们只是描述性的。套餐的售价就是那一个 total;酒店会在内部把这个总额分摊到各个包含项上,让每个组成部分适用正确的税务处理,而客人只付一个数字。
POST /promo-codes/validate权限范围: availability:read
| 字段 | 必填 | 说明 |
|---|---|---|
code | 是 | 忽略大小写和首尾空白。 |
check_in | 是 | YYYY-MM-DD。 |
check_out | 是 | YYYY-MM-DD,不含当天。 |
room_type_id | 否 | 如果传了它,而该码对应的优惠属于别的房型,响应就是那个统一的 valid: false。 |
adults | 否 | 默认为 2。 |
children | 否 | 默认为 0。 |
child_ages | 否 | 默认为 []。非空时,它的长度就是儿童人数,并覆盖 children。 |
人数会与房型的最大可住人数比对,因此一个报少了的人数可能在这里校验通过,却在 POST /holds 时失败。
促销码解锁的是一个房价方案。它本身不施加折扣:优惠已经内建在它揭示出来的那个方案里,所以你拿到的价格就是最终价格,不需要再算什么。
curl -X POST "$VRDN_BASE/promo-codes/validate" \
-H "Authorization: Bearer $VRDN_KEY" \
-H "Content-Type: application/json" \
-d '{
"code": "SUMMER26",
"check_in": "2026-08-01",
"check_out": "2026-08-04",
"adults": 2
}'一个适用的促销码会返回它解锁的优惠,结构与 /rate-plans 的条目一致。
{
"valid": true,
"code": "SUMMER26",
"rate_plan": {
"rate_plan_id": "e2a9d7c4-3f6b-4c1e-9a8d-7b5f0c2e4a19",
"room_type_id": "9b2f6c3e-8d1a-4f5b-a7c9-4e0d2b8f1a63",
"room_type_name": "Ocean Suite",
"public_name": "Early Bird",
"kind": "promotion",
"description": "Book 60 days ahead and save.",
"image_url": "https://cdn.example.com/early-bird.jpg",
"inclusions": [],
"min_los": 3,
"max_los": null,
"currency": "USD",
"total": "1140.36",
"room_subtotal": "867.00",
"taxes_total": "273.36",
"nightly_rates": [{ "date": "2026-08-01", "day_of_week": 6, "rate": "289.00", "source": "interval" }]
}
}一个不适用的促销码,无论原因是什么,都返回同一个响应。
{
"valid": false,
"reason": "That code is not valid for these dates."
}无论是不存在的码、已过期的码、已被用完的码,还是对应优惠不覆盖所选日期的码,失败响应都是刻意做成一模一样的。区分它们会让调用方能够探出哪些码是真实存在的,因此具体原因绝不对外披露。
促销码匹配忽略大小写和首尾空白,所以 summer26 和 SUMMER26 是同一个码。
促销码校验的限额比 API 其余部分更严,并叠加在身份认证中说明的按密钥和按 IP 的限额之上。反复失败会返回 429。请在客人提交促销码时校验,而不是每敲一个键就校验一次。
把促销码传给下单调用。服务端会从头重新解析它,并重新为这段住宿定价。
curl -X POST "$VRDN_BASE/holds" \
-H "Authorization: Bearer $VRDN_KEY" \
-H "Content-Type: application/json" \
-d '{
"guest_id": "c4e7b9d2-6a1f-4e3c-9b8d-2f5a7c0e1d46",
"room_type_id": "9b2f6c3e-8d1a-4f5b-a7c9-4e0d2b8f1a63",
"rate_plan_id": "e2a9d7c4-3f6b-4c1e-9a8d-7b5f0c2e4a19",
"check_in": "2026-08-01",
"check_out": "2026-08-04",
"adults": 2,
"promo_code": "SUMMER26"
}'有两点后果值得在设计时考虑:
- 校验结果只是参考。 它反映的是签发那一刻的情况。如果促销码在客人付完款之前过期、被停用或用满了兑换次数,那么下单会失败,而不是按之前的报价成交。请在你的结账流程里处理这种失败。
- 绝不要把价格传过来。 服务端会自己为每一次下单定价,并忽略客户端提交的任何金额。报价只用于展示。
兑换次数是在预订被确认时计数的,不是在校验促销码时;预订取消后会释放。一个限定兑换次数的促销码,不会因为多人同时下单而被超卖。