Promotions API
The Promotions API provides endpoints for managing and applying promotions (discount codes) and vouchers (gift cards/store credit) to bookings.
List Active Promotions
Retrieve all active promotions available for the current tenant.
GET /api/v1/promotions/active
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
site_id | string | No | Filter by site |
product_id | string | No | Filter by product |
code | string | No | Search by promotion code |
Example Request
GET /api/v1/promotions/active?site_id=s_demo
Authorization: Bearer <token>
x-tenant-id: t_demo
Response
Status: 200 OK
{
"promotions": [
{
"id": "promo_123",
"code": "SUMMER25",
"name": "Summer 25% Off",
"description": "Get 25% off all bookings this summer",
"type": "percentage",
"discount_value": 25,
"min_spend_amount": 50.0,
"max_discount_amount": 100.0,
"starts_at": "2025-06-01T00:00:00.000Z",
"expires_at": "2025-08-31T23:59:59.000Z",
"is_stackable": true,
"usage_remaining": 100,
"usage_limit": 500
},
{
"id": "promo_124",
"code": "FIXED10",
"name": "€10 Off",
"description": "Get €10 off your booking",
"type": "fixed_amount",
"discount_value": 10.0,
"min_spend_amount": 30.0,
"starts_at": "2025-01-01T00:00:00.000Z",
"expires_at": null,
"is_stackable": false,
"usage_remaining": null
}
],
"total": 2
}
Response Fields
| Field | Type | Description |
|---|---|---|
promotions | array | Array of active promotions |
promotions[].id | string | Promotion identifier |
promotions[].code | string | Promotion code (case-insensitive) |
promotions[].name | string | Promotion name |
promotions[].description | string | Promotion description |
promotions[].type | string | Discount type: "percentage" or "fixed_amount" |
promotions[].discount_value | number | Discount amount or percentage |
promotions[].min_spend_amount | number | Minimum spend required (null if none) |
promotions[].max_discount_amount | number | Maximum discount cap (for percentage, null if none) |
promotions[].starts_at | string | When promotion becomes active |
promotions[].expires_at | string | When promotion expires (null if never) |
promotions[].is_stackable | boolean | Can combine with other promotions |
promotions[].usage_remaining | number | Remaining uses (null if unlimited) |
promotions[].usage_limit | number | Total usage limit (null if unlimited) |
total | number | Total number of promotions |
Validate Promotion Code
Check if a promotion code is valid and applicable before applying it.
POST /api/v1/promotions/validate
Request Body
{
"code": "SUMMER25",
"site_id": "s_demo",
"product_id": "p_demo",
"cart_subtotal": 100.0
}
Request Fields
| Field | Type | Required | Description |
|---|---|---|---|
code | string | Yes | Promotion code to validate |
site_id | string | No | Site context for validation |
product_id | string | No | Product context for validation |
cart_subtotal | number | No | Cart subtotal (for min spend validation) |
Response
Status: 200 OK
{
"valid": true,
"promotion": {
"id": "promo_123",
"code": "SUMMER25",
"name": "Summer 25% Off",
"type": "percentage",
"discount_value": 25,
"estimated_discount": 25.0,
"applicable": true
},
"warnings": []
}
Error Response
400 Bad Request - Invalid promotion
{
"valid": false,
"error": "INVALID_PROMOTION",
"message": "Promotion code is invalid or expired",
"code": 400,
"reason": "expired"
}
Apply Promotion to Booking
Apply a promotion code to an existing booking hold. This recalculates pricing and returns a new pricing hash.
Note: This endpoint is also available as POST /api/v1/bookings/{id}/apply-promo. See Bookings API for details.
POST /api/v1/promotions/apply
Request Body
{
"booking_id": "booking_abc123",
"promotion_code": "SUMMER25",
"expected_pricing_hash": "abc123..."
}
Response
Status: 200 OK
{
"success": true,
"booking": {
"id": "booking_abc123",
"status": "hold",
"subtotal": 80.0,
"promotion_discount": 20.0,
"tax_amount": 20.0,
"total": 80.0,
"currency": "EUR",
"pricing_hash": "xyz789...",
"updated_at": "2025-10-15T10:06:00.000Z"
},
"applied_promotion": {
"id": "promo_123",
"code": "SUMMER25",
"name": "Summer 25% Off",
"type": "percentage",
"discount_amount": 20.0,
"applied_at": "2025-10-15T10:06:00.000Z"
},
"pricing_recalculated": true,
"hash_validated": true
}
Promotion Types
Percentage Discount
Applies a percentage discount to the cart subtotal.
{
"type": "percentage",
"discount_value": 25,
"max_discount_amount": 100.0
}
- Example: 25% off, capped at €100
- For a €500 cart: Discount = €100 (not €125)
- For a €200 cart: Discount = €50
Fixed Amount Discount
Applies a fixed discount amount.
{
"type": "fixed_amount",
"discount_value": 10.0
}
- Example: €10 off any booking
- Applies flat €10 discount regardless of cart total
Promotion Stacking
Promotions can be configured as stackable, allowing multiple promotions to be applied together. The platform uses a deterministic stacking engine that ensures consistent ordering and pricing calculations.
Stacking Engine
The stacking engine implements a deterministic algorithm to ensure consistent promotion application:
- Promotion Ordering: Fixed-amount promotions are applied before percentage promotions
- Sequential Application: Each promotion calculates discount from the subtotal after previous promotions
- Total Discount Cap: Total discount cannot exceed the cart subtotal
- Canonical JSON Generation: Promotion metadata is normalized for consistent hash generation
Processing Order:
1. Fixed-amount promotions (€10 off, €20 off, etc.)
↓
2. Percentage promotions (10% off, 25% off, etc.)
↓
3. Canonical ordering within each type (alphabetical by code)
↓
4. Generate consistent pricing hash
Stacking Rules
- Stackable promotions: Can be combined with other stackable promotions
- Non-stackable promotions: Only one can be applied at a time
- Vouchers: Always stack with promotions
- Conflict Resolution: If a non-stackable promotion is detected, stacking stops
Stacking Metadata
When multiple promotions are applied, the system generates metadata to track processing:
{
"processingOrder": ["promo_fixed10", "promo_summer25"],
"stackingStopped": false,
"canonicalOrder": ["promo_fixed10", "promo_summer25"],
"totalDiscountAmount": 35.0,
"discountBreakdown": [
{
"promotionId": "promo_fixed10",
"code": "FIXED10",
"type": "fixed_amount",
"discountApplied": 10.0,
"subtotalBefore": 100.0,
"subtotalAfter": 90.0
},
{
"promotionId": "promo_summer25",
"code": "SUMMER25",
"type": "percentage",
"discountApplied": 25.0,
"subtotalBefore": 90.0,
"subtotalAfter": 65.0
}
]
}
Example Stacking
Scenario: Cart subtotal: €100 with two stackable promotions
Promotion 1 (€10 off, fixed amount, stackable):
- Type: Fixed amount
- Applied first (fixed amounts before percentages)
- Discount: €10
- New subtotal: €90
Promotion 2 (25% off, percentage, stackable):
- Type: Percentage
- Applied second (after fixed amount)
- Discount: 25% of €90 = €22.50
- Final subtotal: €67.50
Total Discount: €32.50 (€10 + €22.50)
Non-Stackable Example
If a non-stackable promotion is detected during processing:
{
"processingOrder": ["promo_vip50"],
"stackingStopped": true,
"stoppedBy": "promo_vip50",
"reason": "Non-stackable promotion applied",
"totalDiscountAmount": 50.0
}
Promotion Validation Rules
Promotions are validated against:
- Expiration: Must be within valid date range
- Usage Limits: Check total usage and per-customer limits
- Minimum Spend: Cart subtotal must meet minimum requirement
- Scope: Must be applicable to the site/product
- Stacking: Non-stackable promotions cannot be combined
- Exclusions: Check for excluded products or conditions
Usage Examples
List Available Promotions
async function listActivePromotions(tenantId: string, siteId?: string) {
const params = siteId ? { site_id: siteId } : {};
const response = await fetch(
`http://localhost:3001/api/v1/promotions/active?${new URLSearchParams(params)}`,
{
headers: {
Authorization: `Bearer ${token}`,
'x-tenant-id': tenantId,
},
},
);
const data = await response.json();
return data.promotions;
}
// Usage
const promotions = await listActivePromotions('t_demo', 's_demo');
console.log(`Found ${promotions.length} active promotions`);
Validate Promotion Before Applying
async function validatePromotion(
code: string,
cartSubtotal: number,
tenantId: string,
) {
const response = await fetch(
'http://localhost:3001/api/v1/promotions/validate',
{
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'x-tenant-id': tenantId,
'Content-Type': 'application/json',
},
body: JSON.stringify({
code,
cart_subtotal: cartSubtotal,
}),
},
);
if (!response.ok) {
const error = await response.json();
console.error('Promotion invalid:', error.message);
return null;
}
const data = await response.json();
return data.promotion;
}
// Usage
const promotion = await validatePromotion('SUMMER25', 100.0, 't_demo');
if (promotion) {
console.log(
`Valid promotion: ${promotion.name}, discount: ${promotion.estimated_discount}`,
);
}
Vouchers
Vouchers are stored-value instruments (gift cards, store credit) that can be applied to bookings as payment.
Voucher Characteristics
- Prepaid Balance: Vouchers have a stored balance
- Partial Usage: Can be used for partial payment
- Remaining Balance: Unused balance remains on voucher after use
- Multiple Applications: Can combine multiple vouchers
- Always Stackable: Vouchers always stack with promotions and other vouchers
- Atomic Balance Management: Balance decrements are atomic to prevent double-spending
- Idempotency: Prevents duplicate applications on retries
Apply Voucher to Booking
Apply a voucher code to an existing booking hold for payment.
Endpoint: POST /api/v1/bookings/{id}/apply-voucher
Request Body:
{
"voucher_code": "GIFT2025",
"expected_pricing_hash": "abc123..."
}
Response:
{
"success": true,
"booking": {
"id": "booking_abc123",
"status": "hold",
"subtotal": 100.0,
"promotion_discount": 0.0,
"voucher_applied": 50.0,
"tax_amount": 12.5,
"total": 62.5,
"currency": "EUR",
"pricing_hash": "xyz789...",
"updated_at": "2025-11-17T12:00:00.000Z"
},
"applied_voucher": {
"id": "voucher_xyz",
"code": "GIFT2025",
"type": "gift_card",
"applied_amount": 50.0,
"balance_before": 100.0,
"balance_after": 50.0,
"applied_at": "2025-11-17T12:00:00.000Z"
},
"pricing_recalculated": true,
"hash_validated": true
}
Voucher Balance Check
Check voucher balance and validity before applying.
Endpoint: GET /api/v1/vouchers/{code}/balance
Response:
{
"voucher": {
"id": "voucher_xyz",
"code": "GIFT2025",
"type": "gift_card",
"original_value": 100.0,
"current_balance": 50.0,
"is_active": true,
"is_redeemed": false,
"expires_at": "2026-12-31T23:59:59.000Z",
"usage_count": 1,
"usage_limit": null
}
}
Voucher Application Flow
1. Validate voucher code and balance
↓
2. Check for existing application (idempotency)
↓
3. Calculate applicable amount (min of balance and booking total)
↓
4. Atomic balance decrement in database transaction
↓
5. Record voucher transaction
↓
6. Recalculate booking pricing
↓
7. Return new pricing hash
Key Features:
- Partial Application: If voucher balance < booking total, applies partial amount
- Full Application: If voucher balance ≥ booking total, applies exact booking amount
- Idempotency: Duplicate applications with same booking_id return cached result
- Transaction Safety: Balance updates are atomic to prevent race conditions
Admin Promotion Management
Admin users can create, update, and manage promotions through dedicated admin endpoints. All admin endpoints require admin:write permissions.
Create Promotion
Endpoint: POST /api/v1/admin/promotions
Request Body:
{
"code": "SUMMER2025",
"name": "Summer 2025 Sale",
"description": "Get 25% off all bookings this summer",
"type": "percentage",
"discount_value": 25,
"min_spend_amount": 50.0,
"max_discount_amount": 100.0,
"starts_at": "2025-06-01T00:00:00.000Z",
"expires_at": "2025-08-31T23:59:59.000Z",
"is_stackable": true,
"usage_limit": 1000,
"per_customer_limit": 1,
"applicable_sites": ["s_demo", "s_hq"],
"applicable_products": ["p_laser_tag", "p_extended_session"]
}
Response:
{
"success": true,
"promotion": {
"id": "promo_abc123",
"code": "SUMMER2025",
"name": "Summer 2025 Sale",
"description": "Get 25% off all bookings this summer",
"type": "percentage",
"discount_value": 25,
"min_spend_amount": 50.0,
"max_discount_amount": 100.0,
"starts_at": "2025-06-01T00:00:00.000Z",
"expires_at": "2025-08-31T23:59:59.000Z",
"is_stackable": true,
"usage_limit": 1000,
"usage_count": 0,
"per_customer_limit": 1,
"is_active": true,
"created_at": "2025-11-17T12:00:00.000Z",
"tenant_id": "t_demo"
}
}
List All Promotions (Admin)
Endpoint: GET /api/v1/admin/promotions
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
status | string | Filter by status: active, expired, upcoming, all |
type | string | Filter by type: percentage, fixed_amount |
search | string | Search by code or name |
Response:
{
"promotions": [
{
"id": "promo_abc123",
"code": "SUMMER2025",
"name": "Summer 2025 Sale",
"type": "percentage",
"discount_value": 25,
"usage_count": 245,
"usage_limit": 1000,
"is_active": true,
"starts_at": "2025-06-01T00:00:00.000Z",
"expires_at": "2025-08-31T23:59:59.000Z"
}
],
"total": 1,
"page": 1,
"per_page": 50
}
Update Promotion
Endpoint: PATCH /api/v1/admin/promotions/{id}
Request Body:
{
"usage_limit": 2000,
"expires_at": "2025-09-30T23:59:59.000Z",
"is_active": true
}
Response:
{
"success": true,
"promotion": {
"id": "promo_abc123",
"usage_limit": 2000,
"expires_at": "2025-09-30T23:59:59.000Z",
"updated_at": "2025-11-17T12:30:00.000Z"
}
}
Delete Promotion
Endpoint: DELETE /api/v1/admin/promotions/{id}
Response:
{
"success": true,
"message": "Promotion deleted successfully",
"deleted_id": "promo_abc123"
}
Promotion Usage Statistics
Endpoint: GET /api/v1/admin/promotions/{id}/stats
Response:
{
"promotion": {
"id": "promo_abc123",
"code": "SUMMER2025",
"name": "Summer 2025 Sale"
},
"statistics": {
"total_usage_count": 245,
"usage_limit": 1000,
"remaining_uses": 755,
"total_discount_given": 12500.5,
"average_discount_per_use": 51.02,
"unique_customers": 198,
"bookings_with_promotion": 245,
"first_used_at": "2025-06-01T08:15:00.000Z",
"last_used_at": "2025-11-17T11:45:00.000Z",
"peak_usage_day": "2025-06-15",
"usage_by_day": [
{
"date": "2025-06-01",
"usage_count": 23,
"total_discount": 1150.5
}
]
}
}
CSV Export/Import Endpoints
The API supports CSV export and import for bulk promotion and voucher management.
Export Promotions
Export promotions as JSON or CSV format with filtering options.
Endpoint: GET /api/v1/admin/promotions/export
Permissions Required: admin:read
Query Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
format | string | No | Export format: json (default) or csv |
isActive | boolean | No | Filter by active status |
isPublic | boolean | No | Filter by public visibility |
promotionType | string | No | Filter by type: percentage, fixed_amount, buy_x_get_y, bundle |
scopeType | string | No | Filter by scope: site, product, booking |
search | string | No | Search by code or name |
startDate | string | No | Filter promotions starting after this date (ISO 8601) |
endDate | string | No | Filter promotions expiring before this date (ISO 8601) |
page | number | No | Page number (default: 1) |
pageSize | number | No | Page size (default: 100, max: 1000) |
Example Request:
GET /api/v1/admin/promotions/export?format=csv&isActive=true
Authorization: Bearer <admin_token>
x-tenant-id: t_demo
JSON Response:
{
"data": [
{
"id": "promo_abc123",
"code": "SUMMER25",
"name": "Summer 25% Off",
"description": "Summer promotion",
"promotionType": "percentage",
"scopeType": "site",
"discountValue": 25,
"startsAt": "2025-06-01T00:00:00.000Z",
"expiresAt": "2025-08-31T23:59:59.000Z",
"currentUsageCount": 245,
"isActive": true,
"isPublic": true
}
],
"total": 1,
"page": 1,
"pageSize": 100,
"totalPages": 1,
"generatedAt": "2025-12-16T12:00:00.000Z"
}
CSV Response:
Returns CSV file download with Content-Type: text/csv and Content-Disposition: attachment headers.
Preview Promotion Import
Upload a CSV file and preview import results without persisting data.
Endpoint: POST /api/v1/admin/promotions/import/preview
Permissions Required: admin:write
Content-Type: multipart/form-data
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
file | file | Yes | CSV file to import (max 10MB) |
Example Request:
curl -X POST "http://localhost:3001/api/v1/admin/promotions/import/preview" \
-H "Authorization: Bearer <admin_token>" \
-H "x-tenant-id: t_demo" \
-F "file=@promotions.csv"
Response:
{
"totalRows": 10,
"validRows": 8,
"invalidRows": 2,
"preview": [
{
"rowNumber": 2,
"data": {
"code": "SUMMER25",
"name": "Summer 25% Off",
"promotionType": "percentage"
},
"isValid": true,
"errors": [],
"warnings": [],
"action": "create"
},
{
"rowNumber": 3,
"data": {
"code": "",
"name": "Missing Code",
"promotionType": "percentage"
},
"isValid": false,
"errors": [
{
"field": "code",
"type": "missing_required_field",
"message": "Field 'code' is required",
"value": ""
}
],
"warnings": [],
"action": "skip"
}
],
"errorSummary": [
{
"field": "code",
"errorType": "missing_required_field",
"count": 2,
"affectedRows": [3, 7]
}
],
"warnings": [],
"duplicateCodes": [
{
"code": "EXISTING25",
"existingId": "promo_xyz",
"rows": [5]
}
],
"estimatedSuccessRate": 0.8,
"canProceed": true
}
Response Fields:
| Field | Type | Description |
|---|---|---|
totalRows | number | Total rows in CSV (excluding header) |
validRows | number | Number of valid rows |
invalidRows | number | Number of invalid rows |
preview | array | First 50 rows with validation results |
errorSummary | array | Aggregated errors by field and type |
warnings | array | Non-blocking warnings |
duplicateCodes | array | Duplicate codes detected |
estimatedSuccessRate | number | Success rate (0-1) |
canProceed | boolean | Whether import can proceed |
Execute Promotion Import
Upload a CSV file and create promotions.
Endpoint: POST /api/v1/admin/promotions/import
Permissions Required: admin:write
Content-Type: multipart/form-data
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
file | file | Yes | CSV file to import (max 10MB) |
Response:
{
"success": true,
"totalRows": 10,
"successCount": 8,
"failureCount": 2,
"skippedCount": 0,
"results": [
{
"rowNumber": 2,
"success": true,
"action": "created",
"entityId": "promo_new123",
"code": "SUMMER25"
},
{
"rowNumber": 3,
"success": false,
"action": "failed",
"code": "",
"errors": [
{
"field": "code",
"type": "missing_required_field",
"message": "Field 'code' is required"
}
]
}
],
"createdIds": ["promo_new123", "promo_new124"],
"executionTimeMs": 1250,
"importedBy": "admin_user_1",
"importedAt": "2025-12-16T12:00:00.000Z"
}
Export Vouchers
Export vouchers as JSON or CSV format with filtering options.
Endpoint: GET /api/v1/admin/vouchers/export
Permissions Required: admin:read
Query Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
format | string | No | Export format: json (default) or csv |
isActive | boolean | No | Filter by active status |
isRedeemed | boolean | No | Filter by redeemed status |
voucherType | string | No | Filter by type: single_use, multi_use, gift_card, loyalty_credit |
customerEmail | string | No | Filter by customer email |
customerId | string | No | Filter by customer ID |
search | string | No | Search by code |
issuedAfter | string | No | Filter vouchers issued after this date (ISO 8601) |
issuedBefore | string | No | Filter vouchers issued before this date (ISO 8601) |
page | number | No | Page number (default: 1) |
pageSize | number | No | Page size (default: 100, max: 1000) |
Example Request:
GET /api/v1/admin/vouchers/export?format=json&voucherType=gift_card&isActive=true
Authorization: Bearer <admin_token>
x-tenant-id: t_demo
Response:
{
"data": [
{
"id": "voucher_xyz789",
"code": "GC-A1B2C3",
"voucherType": "gift_card",
"originalValue": 100,
"currentBalance": 75,
"usageLimit": null,
"currentUsageCount": 1,
"customerEmail": "customer@example.com",
"customerId": "cust_123",
"expiresAt": "2026-12-31T23:59:59.000Z",
"isActive": true,
"isRedeemed": false,
"issuedAt": "2025-12-01T10:00:00.000Z",
"issuedBy": "admin_1"
}
],
"total": 1,
"page": 1,
"pageSize": 100,
"totalPages": 1,
"generatedAt": "2025-12-16T12:00:00.000Z"
}
Preview Voucher Import
Upload a CSV file and preview import results without persisting data.
Endpoint: POST /api/v1/admin/vouchers/import/preview
Permissions Required: admin:write
Content-Type: multipart/form-data
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
file | file | Yes | CSV file to import (max 10MB) |
Response:
{
"totalRows": 5,
"validRows": 5,
"invalidRows": 0,
"preview": [
{
"rowNumber": 2,
"data": {
"voucherType": "gift_card",
"originalValue": "50",
"code": ""
},
"isValid": true,
"errors": [],
"warnings": ["Code will be auto-generated"],
"action": "create"
}
],
"errorSummary": [],
"warnings": ["3 vouchers will have auto-generated codes"],
"duplicateCodes": [],
"estimatedSuccessRate": 1.0,
"canProceed": true
}
Execute Voucher Import
Upload a CSV file and create vouchers.
Endpoint: POST /api/v1/admin/vouchers/import
Permissions Required: admin:write
Content-Type: multipart/form-data
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
file | file | Yes | CSV file to import (max 10MB) |
Response:
{
"success": true,
"totalRows": 5,
"successCount": 5,
"failureCount": 0,
"skippedCount": 0,
"results": [
{
"rowNumber": 2,
"success": true,
"action": "created",
"entityId": "voucher_new123",
"code": "GC-X7Y8Z9"
}
],
"createdIds": ["voucher_new123", "voucher_new124"],
"generatedCodes": ["GC-X7Y8Z9", "GC-A1B2C3", "GC-D4E5F6"],
"executionTimeMs": 850,
"importedBy": "admin_user_1",
"importedAt": "2025-12-16T12:00:00.000Z"
}
Response Fields:
| Field | Type | Description |
|---|---|---|
success | boolean | Overall operation success |
totalRows | number | Total rows processed |
successCount | number | Successfully imported count |
failureCount | number | Failed import count |
skippedCount | number | Skipped row count |
results | array | Detailed results per row |
createdIds | array | IDs of created vouchers |
generatedCodes | array | Auto-generated voucher codes |
executionTimeMs | number | Execution time in milliseconds |
importedBy | string | Admin user who performed import |
importedAt | string | Import timestamp |
Import Error Types
| Error Type | Description |
|---|---|
missing_required_field | Required field is missing or empty |
invalid_field_type | Field value has wrong type |
field_too_long | Field exceeds maximum length |
invalid_enum_value | Value not in allowed enum values |
invalid_date_format | Date is not valid ISO 8601 format |
value_out_of_range | Numeric value outside allowed range |
duplicate_code_in_batch | Same code appears multiple times in file |
code_already_exists | Code already exists in database |
invalid_date_range | expiresAt is before startsAt |
reference_not_found | Referenced entity (promotionId, siteId) not found |
database_error | Database error during creation |
unknown_error | Unexpected error occurred |
Best Practices
- Validate Before Applying: Always validate promotion codes before applying to bookings
- Handle Pricing Hash Changes: When applying promotions, get new pricing hash for confirmation
- Check Stacking Rules: Verify stacking compatibility before applying multiple promotions
- Monitor Usage Limits: Track promotion usage to avoid over-application
- User Feedback: Show clear error messages when promotions are invalid
Error Handling
Common Errors
Invalid Code
{
"error": "INVALID_PROMOTION",
"message": "Promotion code is invalid or expired"
}
Minimum Spend Not Met
{
"error": "MIN_SPEND_NOT_MET",
"message": "Cart must be at least €50 to use this promotion",
"details": {
"minimum_spend": 50.0,
"current_subtotal": 30.0
}
}
Usage Limit Exceeded
{
"error": "USAGE_LIMIT_EXCEEDED",
"message": "This promotion has reached its usage limit"
}
Stacking Violation
{
"error": "STACKING_VIOLATION",
"message": "This promotion cannot be combined with the already applied promotion"
}