Promotions API
Sessiq has two kinds of discount:
- Promotions reduce the price by a percentage or a fixed amount. A promotion either needs a code (
SUMMER25) or applies automatically with no code. - Vouchers carry a money balance that pays for part or all of a booking. Gift cards are vouchers too; see Gift Cards API.
Customers use both during checkout. Staff manage them through the admin endpoints below.
Using codes at checkout
Codes are applied to a booking hold. The endpoints are part of the booking flow and are documented in Bookings API:
| Method | Path | Body |
|---|---|---|
| POST | /api/v1/bookings/{bookingId}/apply-promotion | { "promotionCode": "SUMMER25" } |
| POST | /api/v1/bookings/{bookingId}/apply-voucher | { "voucherCode": "GIFT-ABCD-1234" } |
| DELETE | /api/v1/bookings/{bookingId}/remove-promotion | none |
There is no separate "validate code" endpoint. To check a code, apply it: an invalid code returns 400 and leaves the booking unchanged.
A code is accepted when all of these hold:
- the promotion is active, and today is between
startsAtandexpiresAt; - the booking's start date is between
bookingValidFromandbookingValidTo, when set; - the booking matches the promotion's scope (site, product or variant);
- the booking total reaches
minSpendAmount, when set; - the total usage limit (
usageLimitTotal) is not used up.
A booking carries one code at a time. To swap codes, remove the current one first.
Automatic promotions
An automatic promotion has no code. It applies by itself to every booking it matches: the right product or site, a booking date inside bookingValidFrom–bookingValidTo, and the minimum spend. Typical use: "10% off Product X for bookings 1–24 December".
How it behaves:
- It is applied when the hold is created, so the hold response already shows the reduced total and a
Promotion: ...line inpricing.discounts. - If several automatic promotions match, the one with the highest
prioritywins. - A booking gets one promotion. When the customer enters a code, the booking keeps whichever discount is larger. A code wins a tie. If the automatic discount is larger, the code is not used and the response message says a larger automatic discount is already applied.
- Removing a code brings the automatic promotion back.
- Usage is recorded with the source
automatic, so reports can tell it apart from code redemptions.
To create one, send "isAutomatic": true and leave out code (see below).
Admin: promotions
All admin endpoints need a staff JWT with the promotions:manage permission, plus the x-tenant-id header.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/admin/promotions | List promotions |
| POST | /api/v1/admin/promotions | Create a promotion |
| GET | /api/v1/admin/promotions/{id} | Get a promotion |
| PUT | /api/v1/admin/promotions/{id} | Update a promotion (send only the fields to change) |
| DELETE | /api/v1/admin/promotions/{id} | Delete, or deactivate if it has been used |
| POST | /api/v1/admin/promotions/{id}/duplicate | Copy a promotion |
| GET | /api/v1/admin/promotions/{id}/usage | Usage figures |
| GET | /api/v1/admin/promotions/validate-code | Check whether a code is still free (?code=) |
| POST | /api/v1/admin/promotions/bulk-update | Turn many promotions on or off |
| GET | /api/v1/admin/promotions/export | Export as JSON or CSV |
| POST | /api/v1/admin/promotions/import/preview | Validate a CSV without saving |
| POST | /api/v1/admin/promotions/import | Import a CSV |
Create a promotion
curl -X POST https://api.sessiq.com/api/v1/admin/promotions \
-H "Authorization: Bearer {token}" \
-H "x-tenant-id: {tenantId}" \
-H "Content-Type: application/json" \
-d '{
"code": "SUMMER25",
"name": "Summer 25% off",
"promotionType": "percentage",
"discountValue": 25,
"maxDiscountAmount": 50,
"scopeType": "product",
"scopeProductIds": ["p_demo"],
"startsAt": "2026-06-01T00:00:00.000Z",
"expiresAt": "2026-08-31T23:59:59.000Z",
"usageLimitTotal": 500,
"isStackable": false,
"priority": 10,
"isActive": true,
"isPublic": true
}'
| Field | Required | Description |
|---|---|---|
code | Unless automatic | 3–50 characters. Must be unique in the tenant |
isAutomatic | No | true for a codeless promotion. Default false |
name | Yes | 3–100 characters |
description | No | Up to 500 characters |
promotionType | Yes | percentage or fixed_amount. See the note below |
discountValue | No | Percent (for percentage) or euros (for fixed_amount) |
maxDiscountAmount | No | Cap for a percentage discount, in euros |
minSpendAmount | No | Minimum booking total, in euros |
scopeType | Yes | site, booking, product, variant or category |
scopeProductIds | For product | One or more product IDs. The promotion applies if the booking is for any of them |
scopeId | For variant | The variant ID |
siteId | No | Limit to one site |
startsAt | Yes | When the code can first be used |
expiresAt | No | When the code stops working |
bookingValidFrom, bookingValidTo | No | The booking's start date must fall in this window. Separate from startsAt/expiresAt |
usageLimitTotal | No | Total uses allowed |
usageLimitPerCustomer | No | Stored with the promotion. Not yet enforced at checkout |
priority | Yes | 0–100. Higher wins when several automatic promotions match |
isStackable | Yes | Stored with the promotion. A booking still carries only one promotion |
isActive | Yes | Whether it can be used now |
isPublic | Yes | Whether it may be shown to customers |
metadata | No | Free-form object |
site and booking scopes match every booking. product scope needs at least one product ID.
The API also accepts buy_x_get_y, bundle and free_shipping as promotionType, but they are not fully supported for bookings (free_shipping never gives a discount). Use percentage or fixed_amount.
The response is the saved promotion, with id, currentUsageCount, isAutomatic, createdAt and updatedAt added. code is null for automatic promotions.
List promotions
GET /api/v1/admin/promotions accepts isActive, isPublic, promotionType, scopeType, search (code or name), startDate, endDate, page (default 1) and limit (default 20).
{
"data": [
{
"id": "promo_123",
"code": "SUMMER25",
"name": "Summer 25% off",
"isAutomatic": false
}
],
"meta": { "page": 1, "limit": 20, "total": 1, "totalPages": 1 }
}
Usage figures
GET /api/v1/admin/promotions/{id}/usage:
{
"promotionId": "promo_123",
"code": "SUMMER25",
"name": "Summer 25% off",
"totalUses": 42,
"usageLimitTotal": 500,
"totalDiscountGiven": 830.5,
"uniqueCustomers": 40,
"recentApplications": [
{
"id": "pa_1",
"bookingId": "bk_abc123",
"customerId": null,
"appliedAt": "2026-07-02T10:00:00.000Z",
"discountAmount": 20
}
]
}
Bulk on/off
POST /api/v1/admin/promotions/bulk-update with { "ids": ["promo_1", "promo_2"], "isActive": false }. The response lists what succeeded and what failed.
Admin: vouchers
| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/admin/vouchers | List vouchers |
| POST | /api/v1/admin/vouchers | Issue one voucher |
| POST | /api/v1/admin/vouchers/bulk | Issue up to 1,000 vouchers at once |
| GET | /api/v1/admin/vouchers/{id} | Get a voucher |
| GET | /api/v1/admin/vouchers/code/{code} | Look up by code |
| GET | /api/v1/admin/vouchers/customer/{email} | A customer's vouchers |
| GET | /api/v1/admin/vouchers/{id}/history | Where the voucher was used |
| PUT | /api/v1/admin/vouchers/{id} | Update |
| POST | /api/v1/admin/vouchers/{id}/adjust | Change the balance: { "amount": -10, "reason": "..." } |
| POST | /api/v1/admin/vouchers/{id}/reactivate | Reactivate an expired or deactivated voucher |
| DELETE | /api/v1/admin/vouchers/{id} | Deactivate |
| GET | /api/v1/admin/vouchers/accounting-snapshot | Outstanding balances for accounting |
| GET | /api/v1/admin/vouchers/export | Export as JSON or CSV |
| POST | /api/v1/admin/vouchers/import/preview | Validate a CSV without saving |
| POST | /api/v1/admin/vouchers/import | Import a CSV |
Issue a voucher
{
"voucherType": "multi_use",
"originalValue": 50,
"customerEmail": "customer@example.com",
"expiresAt": "2027-06-30T23:59:59.000Z"
}
| Field | Required | Description |
|---|---|---|
voucherType | Yes | single_use, multi_use, gift_card or loyalty_credit |
originalValue | Yes | Value in euros |
code | No | Generated when omitted |
usageLimit | No | Number of uses allowed |
customerEmail, customerId | No | Assign to a customer |
expiresAt | No | Expiry date |
siteId | No | Limit to one site |
promotionId | No | Link to a promotion |
redemptionNotes | No | Notes shown to staff |
Bulk issue takes count (1–1,000), an optional code prefix, the same voucherType, originalValue, usageLimit, expiresAt, siteId and promotionId, and an optional customerEmails list.
List filters: isActive, isRedeemed, voucherType, customerEmail, customerId, search (code), page, limit.
CSV import and export
Export. GET /api/v1/admin/promotions/export and GET /api/v1/admin/vouchers/export take format=json (default) or format=csv, the same filters as the list endpoints, and page/pageSize (default 100, max 1,000). Voucher export also takes issuedAfter and issuedBefore. CSV comes back as a file download.
Import. Upload one CSV file as multipart/form-data in a field called file:
curl -X POST https://api.sessiq.com/api/v1/admin/promotions/import/preview \
-H "Authorization: Bearer {token}" \
-H "x-tenant-id: {tenantId}" \
-F "file=@promotions.csv"
Run import/preview first. It returns row counts, per-row errors, codes that already exist, and canProceed. Then send the same file to the import endpoint.
| Import | Required columns | Optional columns |
|---|---|---|
| Promotions | code, name, promotionType, scopeType, isStackable, priority, startsAt, isActive, isPublic | description, scopeId, siteId, discountValue, minSpendAmount, maxDiscountAmount, usageLimitTotal, usageLimitPerCustomer, expiresAt, bookingValidFrom, bookingValidTo |
| Vouchers | voucherType, originalValue | code, usageLimit, customerEmail, customerId, expiresAt, promotionId, siteId, redemptionNotes |
The promotion import cannot create automatic or multi-product promotions. Create those through the API or the admin.