Promotion Checkout Integration API
Overview
API tích hợp với Promotion Service để áp dụng promotion trong quá trình thanh toán.
Quan trọng: Các hệ thống OMS của PnL kết nối với Promotion Service đi qua MuleSoft, không gọi trực tiếp tới Promotion Service.
Onboard MiniApp với Promotion Service
MiniApp khi onboard
- Tạo Merchant trên VPortal
- Promotion Service cấp credentials:
- Promotion-API-Key: Xác định merchant, có expiry và status
- Mini App/OMS lưu credentials để authenticate
- Promotion Service xác định merchant dựa trên
Promotion-API-Keytrong request header
Thông tin credentials
| Field | Mô tả | Quản lý bởi |
|---|---|---|
Promotion-API-Key | API key xác định merchant | Promotion Service |
Routing qua Mule (Promotion Service)
Base URL trên Mule (endpoint tới Loyalty/Promotion Service):
| Môi trường | Base URL |
|---|---|
| DEV / SIT / UAT | https://test2-api-internal.vingroup.net:8090/loyalty/ |
| Production | https://api-cloud-internal.vingroup.net/loyalty/ |
Môi trường DEV/SIT/UAT dùng cùng Base URL; phân biệt qua header env (dev, sit, uat).
Ví dụ full URL cho từng endpoint:
| Endpoint | DEV/SIT/UAT |
|---|---|
| applicable | POST https://test2-api-internal.vingroup.net:8090/loyalty/v1/promotion/applicable |
| preview-discount | POST https://test2-api-internal.vingroup.net:8090/loyalty/v1/promotion/preview-discount |
| reserve | POST https://test2-api-internal.vingroup.net:8090/loyalty/v1/promotion/reserve |
| confirm | POST https://test2-api-internal.vingroup.net:8090/loyalty/v1/promotion/confirm |
| release | POST https://test2-api-internal.vingroup.net:8090/loyalty/v1/promotion/release |
| get-reservation | POST https://test2-api-internal.vingroup.net:8090/loyalty/v1/promotion/get-reservation |
| refund | POST https://test2-api-internal.vingroup.net:8090/loyalty/v1/promotion/refund |
Lưu ý: Với môi trường Production, các bên kết nối sẽ được team Mule cấp key riêng (client_id và client_secret). Với môi trường DEV/SIT/UAT dùng chung key.
Endpoints
| Endpoint | Method | Purpose |
|---|---|---|
v1/promotion/applicable | POST | Lấy danh sách promotions có thể áp dụng |
v1/promotion/preview-discount | POST | Tính preview discount (lightweight) |
v1/promotion/reserve | POST | Reserve promotion + Calculate final discount |
v1/promotion/confirm | POST | Confirm promotion usage sau payment thành công |
v1/promotion/release | POST | Release promotion khi checkout fail/cancel |
v1/promotion/get-reservation | POST | Lấy thông tin reservation (mã đã reserve) |
v1/promotion/refund | POST | Hoàn trả lại promotion khi refund từ payment |
Sequence diagram
MiniApp/OMS Promotion Service
| |
| POST /applicable |
|----------------------->|
| applicable_promotions |
|<-----------------------|
| |
| POST /preview-discount|
|----------------------->|
| total_discount, |
| breakdown |
|<-----------------------|
| |
| POST /reserve | issued_promotion: ISSUED -> RESERVED
|----------------------->|
| reservation_key, |
| discount |
|<-----------------------|
| |
| [Payment success] |
| POST /confirm | issued_promotion: RESERVED -> USED
|----------------------->|
| confirmed |
|<-----------------------|
| |
| [Payment fail/cancel] |
| POST /release | issued_promotion: RESERVED -> ISSUED
| (reason: ...) |
|----------------------->|
| promotions |
|<-----------------------|
| |
| [Payment success] |
| POST /refund | issued_promotion: USED -> ISSUED
| (reason: ...) |
|----------------------->|
| promotions |
|<-----------------------|
Issued promotion state transition
Trạng thái issued promotion (mã đã cấp cho user) chuyển theo flow reserve → confirm/release. Khi POST /release, trạng thái chuyển sang ISSUED:

| State | Ý nghĩa |
|---|---|
| ISSUED | Mã đã cấp, chưa dùng; có thể chọn để reserve |
| RESERVED | Đã reserve cho order; chờ confirm hoặc release |
| USED | Đã confirm sau payment thành công, không dùng lại |
| CANCELLED | Promotion bị huỷ bởi admin/user |
| EXPIRED | Promotion hết hạn |
Integration Notes
Discount Calculation Base
Promotion Service tính discount dựa trên subtotal (tổng tiền hàng), không bao gồm shipping, thuế, hoặc phí khác.
min_order_valuelà trường để kiểm tra điều kiện áp dụng khuyến mãi → so sánh vớisubtotal- Discount amount → tính % hoặc số tiền cố định trên
subtotal final_amounttrong response → là subtotal sau khi trừ discount => cân nhắc là chuyển thành final_subtotal_amount
OMS/MiniApp cần tính lại tổng thanh toán = final_amount (từ Promotion) + shipping + thuế + phí.
Promotion Selection
Hiện tại mỗi lần checkout chỉ hỗ trợ chọn 1 promotion.
Stacking (chọn nhiều promotion cùng lúc) sẽ được hỗ trợ trong phiên bản sau.
Reservation TTL
Promotion Service không có TTL cho reservation. Reservation tồn tại cho đến khi được /confirm hoặc /release explicitly.
OMS/MiniApp chịu trách nhiệm gọi /release khi checkout timeout hoặc bị huỷ. Nên gắn release vào checkout TTL phía OMS (ví dụ: checkout expire sau 15 phút → gọi /release).
Atomicity
/reserve là atomic: tất cả promotions trong request thành công hoặc tất cả rollback. Không có partial reservation. Khi nhận error, OMS nên gọi lại /applicable để refresh danh sách promotions rồi cho user chọn lại.
Idempotency
Các mutation endpoints (/reserve, /confirm, /release) hỗ trợ idempotency qua header X-Idempotency-Key:
- Gửi lại request với cùng
X-Idempotency-Keysẽ trả về response giống lần đầu, không tạo side effect mới - Mỗi idempotency key chỉ hợp lệ cho cùng endpoint + cùng request body
- OMS/MiniApp nên luôn gửi
X-Idempotency-Keycho 3 endpoints trên để safe retry khi gặp network error hoặc timeout
Common Headers
Header Definitions:
| Header | Required | Description |
|---|---|---|
client_id | Yes | Client ID để authenticate với MuleSoft |
client_secret | Yes | Client secret để authenticate với MuleSoft |
env | Yes | Môi trường: dev, sit, uat, prod (bắt buộc set đúng theo môi trường gọi MuleSoft) |
X-Auth-Audience | Yes | Platform identifier, default = v-app |
X-Merchant-Id | Yes | Merchant identifier |
X-App-Id | Yes | App identifier (hiện tại: miniapp Id) |
X-App-User-Id | Yes | App user Id |
X-Promotion-API-Key | Yes | App api key (do Promotion Service cấp) |
X-Request-Id | Yes | Unique request ID for tracing (UUID) |
X-Idempotency-Key | Yes (/reserve) | Prevents duplicate operations. Format: opaque UUID. Gửi lại cùng key sẽ trả response giống lần đầu |
1. POST v1/promotion/applicable

cURL Example (qua Mule)
curl --location 'https://test2-api-internal.vingroup.net:8090/loyalty/v1/promotion/applicable' \
--header 'client_id: YOUR_CLIENT_ID' \
--header 'client_secret: YOUR_CLIENT_SECRET' \
--header 'Content-Type: application/json' \
--header 'X-App-User-Id: YOUR_APP_USER_ID' \
--header 'X-App-Id: v-app' \
--header 'X-Request-Id: 550e8400-e29b-41d4-a716-446655440000' \
--header 'X-Auth-Audience: v-app' \
--header 'X-Promotion-API-Key: YOUR_PROMOTION_API_KEY' \
--header 'X-Merchant-Id: vinfast' \
--header 'env: dev' \
--data '{
"orders": [
{
"merchant_id": "merchant_ugreen",
"subtotal": 1260000,
"items": [
{
"item_id": "item_001",
"sku": "CABLE-USBC-001",
"name": "Item name",
"quantity": 1,
"unit_price": 1260000,
"categories": ["electronics", "cables"]
}
]
}
]
}'
Request
{
"orders": [
{
"merchant_id": "merchant_ugreen",
"subtotal": 1260000,
"items": [
{
"item_id": "item_001",
"sku": "CABLE-USBC-001",
"name": "Item name",
"quantity": 1,
"unit_price": 1260000,
"categories": [
"electronics",
"cables"
]
}
]
}
]
}
Request Fields:
| Field | Type | Required | Description |
|---|---|---|---|
orders | array | Yes | Danh sách đơn hàng theo merchant (mỗi merchant một phần tử). Dùng để kiểm tra promotion nào áp dụng được. |
orders[].merchant_id | string | Yes | Mã merchant (vd. merchant_ugreen). Dùng để match promotion theo merchant và phân scope MERCHANT. |
orders[].subtotal | number | Yes | Tổng tiền hàng (VND) = sum(items[].unit_price × quantity). Promotion tính discount và check min_order_value dựa trên field này |
orders[].items | array | Yes | Danh sách dòng hàng trong đơn. Dùng để check applicability theo category/SKU và (sau này) phân bổ discount theo item. |
orders[].items[].item_id | string | Yes | ID sản phẩm/dòng hàng (unique trong đơn). |
orders[].items[].sku | string | No | Mã SKU/variant. Dùng cho rule áp dụng theo sản phẩm. |
orders[].items[].name | string | No | Tên sản phẩm |
orders[].items[].quantity | number | Yes | Số lượng mua. |
orders[].items[].unit_price | number | Yes | Giá item. Cho phép âm |
orders[].items[].categories | array[string] | No | Danh mục sản phẩm. Dùng cho rule “chỉ áp dụng cho category X”. |
Response
{
"code": "0",
"message": "Success",
"data": {
"applicable_promotions": {
"discount_promotions": [
{
"promotion_master_id": 202,
"promotion_id": 2001,
"code": "FEB30",
"promotion_type": "VOUCHER",
"title": "Giảm 30% tháng 2",
"min_order_value": 1000000,
"expires_at": 1730160000000,
"is_eligibility": true,
"discount_config": {
"type": "PERCENTAGE",
"percentage": 30,
"max_discount": 500000
},
"code_type": "UNIQUE"
},
{
"promotion_master_id": 200,
"promotion_id": 2002,
"code": "UGREEN20",
"promotion_type": "VOUCHER",
"scope": "MERCHANT",
"merchant_id": "merchant_ugreen",
"title": "Giảm 20% tối đa 100K",
"min_order_value": 500000,
"expires_at": 1742025600000,
"is_eligibility": false,
"discount_config": {
"type": "PERCENTAGE",
"percentage": 20,
"max_discount": 100000
},
"code_type": "UNIQUE",
"pin_required": true,
"pin_code": "123456",
"logo_url": "https://example.com/",
"banner_url": "https://example.com/"
}
]
}
}
}
Response Fields:
| Field | Type | Description |
|---|---|---|
applicable_promotions.discount_promotions | array | Danh sách promotion |
discount_promotions[].promotion_master_id | number | Promotion master ID |
discount_promotions[].promotion_id | number | User's issued promotion ID |
discount_promotions[].promotion_type | string | Promotion type |
discount_promotions[].is_eligibility | boolean | true = đủ điều kiện áp dụng với order hiện tại; false = không đủ (vd. chưa đạt min_order_value, hết quota) — hiển thị nhưng không cho chọn. |
discount_promotions[].scope | string | (Optional) "PLATFORM" hoặc "MERCHANT" |
discount_promotions[].merchant_id | string | (Nếu scope=MERCHANT) |
discount_promotions[].discount_config | object | type, percentage, max_discount, amount |
discount_promotions[].expires_at | number (long) | Unix timestamp milliseconds — hết hạn dùng mã |
discount_promotions[].code_type | string | "UNIQUE" hoặc "SHARED" |
discount_promotions[].pin_required | boolean | true hoặc false |
discount_promotions[].pin_code | string | Mã pin sử dụng khuyến mãi, nếu voucher có yêu cầu nhập mã pin |
discount_promotions[].logo_url | string | Ảnh logo url |
discount_promotions[].banner_url | string | Ảnh banner url |
2. POST v1/promotion/preview-discount

Request
Checkout gửi cùng thông tin order như /applicable: subtotal, items (và merchant_id) để promotion service tính preview discount trên subtotal.
{
"selected_promotions": [
{
"promotion_id": 1001,
"code_type": "UNIQUE"
},
{
"promotion_id": 1002,
"code_type": "SHARED"
}
],
"orders": [
{
"merchant_id": "merchant_ugreen",
"subtotal": 1260000,
"items": [
{
"item_id": "item_001",
"sku": "CABLE-USBC-001",
"name": "Item name",
"quantity": 1,
"unit_price": 1260000,
"categories": [
"electronics",
"cables"
]
}
]
}
]
}
Request Fields:
| Field | Type | Required | Description |
|---|---|---|---|
selected_promotions | object | Yes | Thông tin về về promotions |
selected_promotions[].promotion_id | number | Yes | User's issued promotion ID (unique identifier) hoặc mã voucher shared |
selected_promotions[].code_type | string | Yes | "UNIQUE", "SHARED" (hiện tại chưa hỗ trợ SHARED) |
orders | array | Yes | Cùng cấu trúc như /applicable (merchant_id, subtotal, items) |
Response
{
"code": "0",
"message": "Success",
"data": {
"total_discount": 126000,
"original_total": 1260000,
"final_amount": 1134000,
"breakdown": [
{
"promotion": {
"promotion_master_id": 101,
"promotion_id": 1001,
"code": "VAPP10",
"promotion_type": "VOUCHER",
"code_type": "UNIQUE"
},
"discount": {
"amount": 126000,
"scope": "PLATFORM"
}
}
]
}
}
Response Fields:
| Field | Type | Description |
|---|---|---|
total_discount | number | Tổng tiền được giảm (VND). Ví dụ: voucher giảm 10% trên đơn 1.260.000₫ → total_discount = 126.000 |
original_total | number | Tổng tiền hàng trước khi giảm = sum(orders[].subtotal). Đây là tiền hàng, không bao gồm ship/thuế/phí |
final_amount | number | Tiền hàng sau khi trừ discount = original_total − total_discount. |
breakdown | array | Chi tiết discount từng promotion (thứ tự = thứ tự áp dụng) |
breakdown[].promotion | object | Promotion info |
breakdown[].promotion.promotion_type | string | "SHIPPING", "VOUCHER", "COIN_CASHBACK" |
breakdown[].promotion.code_type | string | "UNIQUE", "SHARED" |
breakdown[].discount | object | Discount details (extensible) |
breakdown[].discount.amount | number | Discount amount (VND) |
breakdown[].discount.scope | string | "PLATFORM" hoặc "MERCHANT" |
breakdown[].discount.merchant_id | string | Merchant ID (nếu scope=MERCHANT) |
3. POST v1/promotion/reserve

Request
{
"order_id": "ORDER-20260205-001",
"selected_promotions": [
{
"promotion_id": 1001,
"code_type": "UNIQUE"
},
{
"promotion_id": 1002,
"code_type": "SHARED",
"pin_code": "123456"
}
],
"orders": [
{
"merchant_id": "merchant_ugreen",
"subtotal": 1260000,
"items": [...]
}
]
}
Request Fields:
| Field | Type | Required | Description |
|---|---|---|---|
order_id | string | Yes | Order ID - Single Merchant: Order ID đơn lẻ. Multi Merchant: Master Order ID (Group Order ID) |
selected_promotions | object | Yes | Thông tin về về promotions |
selected_promotions[].promotion_id | number | Yes | User's issued promotion ID (unique identifier) hoặc mã voucher shared |
selected_promotions[].code_type | string | Yes | "UNIQUE", "SHARED" (hiện tại chưa hỗ trợ SHARED) |
selected_promotions[].pin_code | string | No | Mã pin sử dụng khuyến mãi, nếu voucher có yêu cầu nhập mã pin |
orders | array | Yes | Cùng cấu trúc như /applicable (merchant_id, subtotal, items) |
Response
{
"code": "0",
"message": "Success",
"data": {
"reservation_key": "RSV-{your-reservation-key}",
"discount": {
"total_discount": 126000,
"original_total": 1260000,
"final_amount": 1134000,
"breakdown": [
{
"promotion": {
"title": "Promotion title",
"short_title": "Promotion short title",
"promotion_master_id": 101,
"promotion_id": 1001,
"code": "VAPP10",
"promotion_type": "VOUCHER",
"code_type": "UNIQUE"
},
"discount": {
"amount": 126000,
"scope": "PLATFORM"
}
}
]
}
}
}
Response Fields:
| Field | Type | Description |
|---|---|---|
reservation_key | string | UUID unique cho reservation |
discount | object | Thông tin giảm giá |
discount.total_discount | number | Tổng tiền được giảm (VND) |
discount.original_total | number | Tổng tiền hàng trước khi giảm = sum(orders[].subtotal). Không bao gồm ship/thuế/phí |
discount.final_amount | number | Tiền hàng sau khi trừ discount = original_total − total_discount. OMS/MiniApp dùng số này để tính tiền thanh toán |
discount.breakdown | array | Chi tiết discount từng promotion (thứ tự = thứ tự áp dụng) |
breakdown[].promotion | object | Promotion infos |
breakdown[].promotion.title | string | Tiêu đề promotion (hiển thị đầy đủ) |
breakdown[].promotion.short_title | string | Tiêu đề ngắn gọn của promotion |
breakdown[].promotion.promotion_type | string | "SHIPPING" | "VOUCHER" | "COIN_CASHBACK" |
breakdown[].promotion.code_type | string | "UNIQUE", "SHARED" |
breakdown[].discount | object | Discount details (same structure như /preview-discount) |
4. POST v1/promotion/confirm

Request
{
"reservation_key": "RSV-{your-reservation-key}",
"order_id": "ORDER-20260205-001"
}
Request Fields:
| Field | Type | Required | Description |
|---|---|---|---|
reservation_key | string | Yes | Reservation key từ /reserve |
order_id | string | Yes | Order ID (phải khớp với lúc reserve) để verify |
Response
{
"code": "0",
"message": "Success",
"data": {
"promotions": [
{
"promotion_master_id": 101,
"promotion_id": 1001,
"code": "VAPP10",
"status": "USED",
"code_type": "UNIQUE"
}
]
}
}
Response Fields:
| Field | Type | Description |
|---|---|---|
promotions | array | Danh sách promotions đã confirm |
promotions[].promotion_master_id | number | Promotion ID |
promotions[].promotion_id | number | Issued promotion ID |
promotions[].code | string | Promotion code |
promotions[].status | string | Status mới = "USED" |
promotions[].code_type | string | "UNIQUE", "SHARED" |
5. POST v1/promotion/release

Request
{
"reservation_key": "RSV-{your-reservation-key}",
"order_id": "ORDER-20260205-001",
"reason": "PAYMENT_FAILED"
}
Request Fields:
| Field | Type | Required | Description |
|---|---|---|---|
reservation_key | string | Yes | Reservation key từ /reserve |
order_id | string | Yes | Order ID (phải khớp với lúc reserve) để verify |
reason | string | No | Lý do release (logging only) |
Response
{
"code": "0",
"message": "Success",
"data": {
"promotions": [
{
"promotion_master_id": 101,
"promotion_id": 1001,
"code": "VAPP10",
"status": "ISSUED",
"code_type": "UNIQUE"
}
]
}
}
Response Fields:
| Field | Type | Description |
|---|---|---|
promotions | array | Danh sách promotions đã release |
promotions[].promotion_master_id | number | Promotion ID |
promotions[].promotion_id | number | Issued promotion ID |
promotions[].code | string | Promotion code |
promotions[].status | string | Status mới = "ISSUED" |
promotions[].code_type | string | "UNIQUE", "SHARED" |
6. POST v1/promotion/get-reservation
Lấy thông tin reservation đã tạo.
Request
{
"reservation_key": "RSV-{your-reservation-key}",
"order_id": "ORDER-20260205-001"
}
Request Fields:
| Field | Type | Required | Description |
|---|---|---|---|
reservation_key | string | Yes | Reservation key trả về từ POST /reserve |
order_id | string | Yes | Order ID / Master Order ID (khớp với lúc reserve) — dùng để verify reservation thuộc đúng đơn |
Response
Trả về thông tin reservation + danh sách promotions (status = RESERVED).
{
"code": "0",
"message": "Success",
"data": {
"reservation_key": "RSV-{your-reservation-key}",
"order_id": "ORDER-20260205-001",
"promotions": [
{
"promotion_master_id": 101,
"promotion_id": 1001,
"code": "VAPP10",
"status": "RESERVED",
"code_type": "UNIQUE"
}
]
}
}
Response Fields:
| Field | Type | Description |
|---|---|---|
reservation_key | string | Reservation key (trùng request) |
order_id | string | Order ID / Master Order ID gắn với reservation |
promotions | array | Danh sách issued promotions đang ở trạng thái RESERVED trong reservation này |
promotions[].promotion_master_id | number | Promotion master ID |
promotions[].promotion_id | number | Issued promotion ID |
promotions[].code | string | Mã promotion |
promotions[].status | string | Trạng thái issued promotion = "RESERVED" |
promotions[].code_type | string | "UNIQUE", "SHARED" |
7. POST v1/promotion/refund
Request
{
"reservation_key": "RSV-{your-reservation-key}",
"order_id": "ORDER-20260205-001",
"reason": "REFUND"
}
Request Fields:
| Field | Type | Required | Description |
|---|---|---|---|
reservation_key | string | Yes | Reservation key từ /reserve |
order_id | string | Yes | Order ID (phải khớp với lúc reserve) để verify |
reason | string | No | Lý do refund (logging only) |
Response
{
"code": "0",
"message": "Success",
"data": {
"promotions": [
{
"promotion_master_id": 101,
"promotion_id": 1001,
"code": "VAPP10",
"status": "ISSUED",
"code_type": "UNIQUE"
}
]
}
}
Response Fields:
| Field | Type | Description |
|---|---|---|
promotions | array | Danh sách promotions đã release |
promotions[].promotion_master_id | number | Promotion ID |
promotions[].promotion_id | number | Issued promotion ID |
promotions[].code | string | Promotion code |
promotions[].status | string | Status mới = "ISSUED" |
promotions[].code_type | string | "UNIQUE", "SHARED" |
V. Phụ lục
A. Bảng mã lỗi Promotion
| HTTP | Code | Message (EN) | Message (VI) | Retryable |
|---|---|---|---|---|
| 200 | 0 | Success | Thành công | N/A |
| 400 | 8001 | Voucher not applicable | Voucher không áp dụng được | No |
| 400 | 8002 | Invalid voucher code | Mã voucher không hợp lệ | No |
| 404 | 8003 | Voucher not found | Không tìm thấy voucher | No |
| 409 | 8004 | Voucher already reserved | Voucher đã được giữ chỗ | No |
| 409 | 8005 | Voucher out of stock | Voucher đã hết | No |
| 422 | 8006 | Order minimum not met | Chưa đạt giá trị đơn hàng tối thiểu | No |
| 422 | 8007 | Voucher expired | Voucher đã hết hạn | No |
| 422 | 8008 | Usage limit exceeded | Vượt giới hạn sử dụng | No |
| 422 | 8009 | User not eligible | User không đủ điều kiện | No |
| 404 | 8010 | Reservation not found | Không tìm thấy reservation | No |
| 409 | 8011 | Reservation expired | Reservation đã hết hạn | No |
| 409 | 8012 | Reservation already confirmed | Reservation đã được xác nhận | No |
| 500 | 8500 | Promotion service error | Lỗi hệ thống Promotion | Yes |
| 502 | 8501 | Promotion service timeout | Timeout với Promotion service | Yes |