Free Gift Module — Admin
HTTP surface for managing free-gift rules — one row per promotion. The cart engine evaluates active rules and produces cart.pendingGifts[], which the customer resolves into an…
HTTP surface for managing free-gift rules — one row per promotion. The cart engine evaluates active rules and produces cart.pendingGifts[], which the customer resolves into an attached gift selection at checkout.
Source:
api-modules/free-gift/src/controllers/admin-free-gift.controller.ts.Three rule types:
AUTOMATIC(apply a fixed quantity of pre-set variants when criteria pass),BUYXGETY(buy N matching variants → get M gift variants), andCOUPON_BASED(customer applies a coupon code → receives pre-set gift variants). Each type has its own config sub-block; only one config field is allowed per rule.
Conventions
Authentication
All endpoints require a Better-Auth admin session and a role granting the matching freeGift:* permission.
| Endpoint group | Permission |
|---|---|
GET /admin/free-gifts, GET /admin/free-gifts/:id | freeGift: read |
POST /admin/free-gifts | freeGift: create |
PATCH /admin/free-gifts/:id, POST /admin/free-gifts/:id/restore | freeGift: update |
PATCH /admin/free-gifts/:id/archive, PATCH /admin/free-gifts/:id/unarchive | freeGift: archive |
DELETE /admin/free-gifts/:id | freeGift: delete |
Response envelope
Successful responses are wrapped by ResponseInterceptor:
{
"data": <payload>,
"message": "Success",
"statusCode": 200,
"metadata": { /* optional, e.g. pagination */ }
}Error envelope
statusCode | errorCode examples |
|---|---|
| 400 | BAD_REQUEST, VALIDATION_ERROR |
| 401 | UNAUTHORIZED |
| 403 | FORBIDDEN |
| 404 | NOT_FOUND |
| 500 | INTERNAL_SERVER_ERROR, DATABASE_ERROR |
Lifecycle
Same active / archived / deleted model as discounts.
| State | How | Visibility |
|---|---|---|
active | Default after create | Listed and evaluated by the cart engine |
archived | PATCH /:id/archive | Listed (admin) but not evaluated |
deleted | DELETE /:id (soft) | Hidden from default lists; reversible via POST /:id/restore |
Currency
minAmount, maxAmount are integer subunits. Quantity/count fields are plain integers.
Domain types
Enums
type FreeGiftRuleType = "AUTOMATIC" | "BUYXGETY" | "COUPON_BASED";
type FreeGiftBuyScope = "VARIANT" | "BRAND" | "CATEGORY" | "TAG" | "INGREDIENT" | "VENDOR";
type FreeGiftProductMode = "SAME" | "DIFFERENT";
type FreeGiftCriteriaScope = "CART_SUBTOTAL" | "ORDER_TOTAL"
| "CATEGORY_TOTAL" | "BRAND_TOTAL" | "TAG_TOTAL"
| "INGREDIENT_TOTAL" | "VENDOR_TOTAL";
type FreeGiftFilterMode = "INCLUDE" | "EXCLUDE";
type Platform = "APP" | "WEB" | "BOTH";
type CustomerScope = "ALL" | "INCLUDE" | "EXCLUDE";
type PurchaseHistoryMode = "DISABLED" | "FIRST_ORDER" | "MIN_ORDERS";FreeGiftResponse
type FreeGiftFilterEntry = { id: string; mode: FreeGiftFilterMode };
type FreeGiftResponse = {
id: string;
name: string;
description: string | null;
isActive: boolean;
archivedAt: string | null;
platform: Platform;
type: FreeGiftRuleType;
automaticConfig: {
quantity: number;
variantIds: string[];
} | null;
buyXGetYConfig: {
buyScope: FreeGiftBuyScope;
buyScopeIds: string[];
buyQuantity: number;
getQuantity: number;
giftProductMode: FreeGiftProductMode;
giftVariantIds: string[];
repeatGift: boolean;
repeatLimit: number | null;
} | null;
couponConfig: {
couponCode: string;
couponQuantity: number;
variantIds: string[];
} | null;
criteriaScope: FreeGiftCriteriaScope;
criteriaScopeIds: string[];
minAmount: number | null; // subunits
maxAmount: number | null;
minQuantity: number | null;
maxQuantity: number | null;
minProductCount: number | null;
maxProductCount: number | null;
startsAt: string | null;
endsAt: string | null;
totalUsageLimit: number | null;
usageLimitPerCustomer: number | null;
requireCustomerLogin: boolean;
purchaseHistoryMode: PurchaseHistoryMode;
minOrderCount: number | null;
individualUsageOnly: boolean;
customerScope: CustomerScope;
customerUserIds: string[];
variants: FreeGiftFilterEntry[];
categories: FreeGiftFilterEntry[];
brands: FreeGiftFilterEntry[];
tags: FreeGiftFilterEntry[];
ingredients: FreeGiftFilterEntry[];
vendors: FreeGiftFilterEntry[];
showOnCart: boolean;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
};Endpoints
Base path: /admin/free-gifts.
GET /admin/free-gifts — List rules
Required permission: freeGift: read.
Query
| Name | Type | Default | Notes |
|---|---|---|---|
q | string? | — | Free-text search (trimmed, min 1) |
status | "active" | "archived" | "deleted" | "all" | "active" | Lifecycle filter |
platform | Platform? | — | — |
type | FreeGiftRuleType? | — | — |
isActive | boolean? | — | Coerced |
criteriaScope | FreeGiftCriteriaScope? | — | — |
sortBy | "createdAt" | "updatedAt" | "name" | "endsAt" | "createdAt" | — |
sortDirection | "asc" | "desc" | "desc" | — |
limit | int | 100 | 1..500 |
offset | int | 0 | >= 0 |
Response 200 — paginated envelope of FreeGiftResponse[].
GET /admin/free-gifts/:id — Get a rule
Required permission: freeGift: read.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Unknown id |
POST /admin/free-gifts — Create a rule
Required permission: freeGift: create. Cross-field rules enforced by zod:
type=AUTOMATIC→automaticConfigis required;buyXGetYConfigandcouponConfigmust be absent.type=BUYXGETY→buyXGetYConfigis required;automaticConfigandcouponConfigmust be absent.- If
giftProductMode=DIFFERENT,giftVariantIds[]must be non-empty; ifSAME, must be empty. - If
repeatGift=false,repeatLimitmust be absent (ornull).
- If
type=COUPON_BASED→couponConfigis required;automaticConfigandbuyXGetYConfigmust be absent.criteriaScopein{CART_SUBTOTAL, ORDER_TOTAL}→criteriaScopeIdsmust be empty.criteriaScopein{CATEGORY_TOTAL, BRAND_TOTAL, TAG_TOTAL, INGREDIENT_TOTAL, VENDOR_TOTAL}→criteriaScopeIdsmust be non-empty.purchaseHistoryMode=MIN_ORDERSrequiresminOrderCount >= 1.customerScope != ALLrequires non-emptycustomerUserIds.- If both set,
minAmount <= maxAmount,minQuantity <= maxQuantity,minProductCount <= maxProductCount. - If both set,
startsAt < endsAt.
Body
{
"name": "Buy 2 lipsticks, get 1 free",
"description": "Mix and match across the lipstick category.",
"isActive": true,
"platform": "BOTH",
"type": "BUYXGETY",
"buyXGetYConfig": {
"buyScope": "CATEGORY",
"buyScopeIds": ["01J9..."],
"buyQuantity": 2,
"getQuantity": 1,
"giftProductMode": "SAME",
"giftVariantIds": [],
"repeatGift": true,
"repeatLimit": 3
},
"criteriaScope": "CART_SUBTOTAL",
"criteriaScopeIds": [],
"minAmount": 100000,
"maxAmount": null,
"startsAt": null,
"endsAt": null,
"totalUsageLimit": null,
"usageLimitPerCustomer": 1,
"requireCustomerLogin": false,
"purchaseHistoryMode": "DISABLED",
"minOrderCount": null,
"individualUsageOnly": false,
"customerScope": "ALL",
"customerUserIds": [],
"variants": [],
"categories": [],
"brands": [],
"tags": [],
"ingredients": [],
"vendors": [],
"showOnCart": true
}Field shapes are documented in the type definitions above. Validation summary:
| Field | Type | Constraints |
|---|---|---|
name | string | 1..255 |
description | string | null? | max 2000 |
couponConfig.couponCode | string | 2..50, uppercase alnum + -_ |
buyXGetYConfig.buyQuantity / getQuantity | int | >= 1 |
automaticConfig.quantity | int | >= 1 |
*.variantIds / buyScopeIds | string[] | Non-empty arrays where required |
criteriaScopeIds | string[] | Required non-empty for per-entity criteria scopes |
Response 201 — FreeGiftResponse.
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | Body fails zod or cross-field refinements |
PATCH /admin/free-gifts/:id — Update a rule
Required permission: freeGift: update. Partial. All cross-field rules re-run on the merged state.
Response 200 — updated FreeGiftResponse.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Unknown id |
| 400 | VALIDATION_ERROR | Body fails zod |
PATCH /admin/free-gifts/:id/archive — Archive
Required permission: freeGift: archive. Stamps archivedAt; rule stops being evaluated by the cart engine but stays visible in admin.
Response 200 — FreeGiftResponse.
PATCH /admin/free-gifts/:id/unarchive — Unarchive
Required permission: freeGift: archive.
Response 200 — FreeGiftResponse.
DELETE /admin/free-gifts/:id — Soft-delete
Required permission: freeGift: delete.
Response 200 — deleted FreeGiftResponse.
POST /admin/free-gifts/:id/restore — Restore
Required permission: freeGift: update.
Response 200 — restored FreeGiftResponse.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Unknown id |
Related modules
admin-rbac— gates every endpoint viafreeGift:*. Seeadmin-rbac.md.cart— evaluates active free-gift rules and producescart.pendingGifts[]. Seecart.md.discount—couponConfig.couponCodereferences a discount.code at evaluation time.catalog—variants,categories,brands,tags,ingredients,buyScopeIds,criteriaScopeIds,*Config.variantIdsall reference catalog ids.vendor—vendors[]references vendor ids.
Frequently Bought Together Module — Admin
HTTP surface for operating the Frequently-Bought-Together (FBT) recommendation pipeline — an offline batch that mines product co-purchase pairs from confirmed orders and stores a…
Global Scripts Module — Admin
HTTP surface for managing global scripts — raw HTML/JS snippets (analytics tags, marketing pixels, inline <style> blocks, chat widgets) that the storefront injects into one of…