Skip to main content

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

ParameterTypeRequiredDescription
site_idstringNoFilter by site
product_idstringNoFilter by product
codestringNoSearch 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

FieldTypeDescription
promotionsarrayArray of active promotions
promotions[].idstringPromotion identifier
promotions[].codestringPromotion code (case-insensitive)
promotions[].namestringPromotion name
promotions[].descriptionstringPromotion description
promotions[].typestringDiscount type: "percentage" or "fixed_amount"
promotions[].discount_valuenumberDiscount amount or percentage
promotions[].min_spend_amountnumberMinimum spend required (null if none)
promotions[].max_discount_amountnumberMaximum discount cap (for percentage, null if none)
promotions[].starts_atstringWhen promotion becomes active
promotions[].expires_atstringWhen promotion expires (null if never)
promotions[].is_stackablebooleanCan combine with other promotions
promotions[].usage_remainingnumberRemaining uses (null if unlimited)
promotions[].usage_limitnumberTotal usage limit (null if unlimited)
totalnumberTotal 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

FieldTypeRequiredDescription
codestringYesPromotion code to validate
site_idstringNoSite context for validation
product_idstringNoProduct context for validation
cart_subtotalnumberNoCart 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:

  1. Promotion Ordering: Fixed-amount promotions are applied before percentage promotions
  2. Sequential Application: Each promotion calculates discount from the subtotal after previous promotions
  3. Total Discount Cap: Total discount cannot exceed the cart subtotal
  4. 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:

  1. Expiration: Must be within valid date range
  2. Usage Limits: Check total usage and per-customer limits
  3. Minimum Spend: Cart subtotal must meet minimum requirement
  4. Scope: Must be applicable to the site/product
  5. Stacking: Non-stackable promotions cannot be combined
  6. 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:

ParameterTypeDescription
statusstringFilter by status: active, expired, upcoming, all
typestringFilter by type: percentage, fixed_amount
searchstringSearch 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:

ParameterTypeRequiredDescription
formatstringNoExport format: json (default) or csv
isActivebooleanNoFilter by active status
isPublicbooleanNoFilter by public visibility
promotionTypestringNoFilter by type: percentage, fixed_amount, buy_x_get_y, bundle
scopeTypestringNoFilter by scope: site, product, booking
searchstringNoSearch by code or name
startDatestringNoFilter promotions starting after this date (ISO 8601)
endDatestringNoFilter promotions expiring before this date (ISO 8601)
pagenumberNoPage number (default: 1)
pageSizenumberNoPage 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:

FieldTypeRequiredDescription
filefileYesCSV 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:

FieldTypeDescription
totalRowsnumberTotal rows in CSV (excluding header)
validRowsnumberNumber of valid rows
invalidRowsnumberNumber of invalid rows
previewarrayFirst 50 rows with validation results
errorSummaryarrayAggregated errors by field and type
warningsarrayNon-blocking warnings
duplicateCodesarrayDuplicate codes detected
estimatedSuccessRatenumberSuccess rate (0-1)
canProceedbooleanWhether 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:

FieldTypeRequiredDescription
filefileYesCSV 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:

ParameterTypeRequiredDescription
formatstringNoExport format: json (default) or csv
isActivebooleanNoFilter by active status
isRedeemedbooleanNoFilter by redeemed status
voucherTypestringNoFilter by type: single_use, multi_use, gift_card, loyalty_credit
customerEmailstringNoFilter by customer email
customerIdstringNoFilter by customer ID
searchstringNoSearch by code
issuedAfterstringNoFilter vouchers issued after this date (ISO 8601)
issuedBeforestringNoFilter vouchers issued before this date (ISO 8601)
pagenumberNoPage number (default: 1)
pageSizenumberNoPage 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:

FieldTypeRequiredDescription
filefileYesCSV 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:

FieldTypeRequiredDescription
filefileYesCSV 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:

FieldTypeDescription
successbooleanOverall operation success
totalRowsnumberTotal rows processed
successCountnumberSuccessfully imported count
failureCountnumberFailed import count
skippedCountnumberSkipped row count
resultsarrayDetailed results per row
createdIdsarrayIDs of created vouchers
generatedCodesarrayAuto-generated voucher codes
executionTimeMsnumberExecution time in milliseconds
importedBystringAdmin user who performed import
importedAtstringImport timestamp

Import Error Types

Error TypeDescription
missing_required_fieldRequired field is missing or empty
invalid_field_typeField value has wrong type
field_too_longField exceeds maximum length
invalid_enum_valueValue not in allowed enum values
invalid_date_formatDate is not valid ISO 8601 format
value_out_of_rangeNumeric value outside allowed range
duplicate_code_in_batchSame code appears multiple times in file
code_already_existsCode already exists in database
invalid_date_rangeexpiresAt is before startsAt
reference_not_foundReferenced entity (promotionId, siteId) not found
database_errorDatabase error during creation
unknown_errorUnexpected error occurred

Best Practices

  1. Validate Before Applying: Always validate promotion codes before applying to bookings
  2. Handle Pricing Hash Changes: When applying promotions, get new pricing hash for confirmation
  3. Check Stacking Rules: Verify stacking compatibility before applying multiple promotions
  4. Monitor Usage Limits: Track promotion usage to avoid over-application
  5. 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"
}