Complete REST API documentation for customer loyalty, points, vouchers, and rewards management. Integrate loyalty functionality into your POS, mobile app, or e-commerce platform.
The KiriMel Loyalty API allows you to programmatically manage customer loyalty programs, including registration, points earning/redemption, voucher management, and rewards. Built for seamless integration with POS systems like VectorPOS (VectorDine and VectorRetail).
Get started in 3 steps: 1) Create a loyalty group, 2) Add your sites, 3) Register customers and start awarding points. See the endpoints below for details.
All Loyalty API endpoints require an API key with HMAC authentication. Generate your API keys in the Loyalty Dashboard after creating your loyalty group.
Need step-by-step integration examples? Check out our Loyalty API Workflows page for complete POS integration scenarios including member checkout, voucher redemption, and points payment.
| Method | Endpoint | Purpose |
|---|---|---|
| POST | /group/create | Create a loyalty group |
| POST | /group/sites/add | Add a site/outlet to the group |
| POST | /customers/register | Register a new loyalty member |
| GET | /customers/lookup | Lookup member by phone/QR/code |
| GET | /customers/lookup-by-email | Lookup member by email |
| PUT | /customers/{id} | Update name/birthdate (phone/email need OTP) |
| POST | /customers/{id}/identity-change/request | Request OTP for phone/email change |
| POST | /customers/{id}/identity-change/confirm | Confirm OTP and apply the change |
| POST | /points/earn | Award points for a transaction |
| GET | /points/redeem-config | Redemption rules for POS UIs |
| POST | /points/preview-redeem | Validate redemption without committing |
| POST | /points/commit-redeem | Deduct points for a transaction |
| POST | /points/reverse | Void/refund all movements for a reference |
| GET | /wallet/balance | Get member's current point balance |
| POST | /vouchers/batches | Create a voucher batch |
| POST | /vouchers/redeem | Redeem a voucher by code |
The Loyalty API uses HMAC-SHA256 signature authentication for security. Each request must include three headers:
X-API-Key: your_api_key_here
X-Timestamp: 2024-01-01T12:00:00Z
X-Signature: computed_hmac_signature_here
The signature is computed over the timestamp and request body joined by a period (.):
signature = HMAC-SHA256(timestamp + "." + requestBody, apiSecret)
The timestamp is ISO format (e.g., 2024-01-01T12:00:00Z) and must be within ยฑ5 minutes of server time. For GET requests, use {} as the request body.
$timestamp = gmdate('Y-m-d\TH:i:s\Z');
$signature = hash_hmac('sha256', $timestamp . '.' . $requestBody, $apiSecret);
$headers = [
'X-API-Key: ' . $apiKey,
'X-Timestamp: ' . $timestamp,
'X-Signature: ' . $signature
];
const crypto = require('crypto');
const timestamp = new Date().toISOString();
const signature = crypto.createHmac('sha256', apiSecret)
.update(timestamp + '.' + requestBody)
.digest('hex');
import hmac
import hashlib
import time
timestamp = time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())
signing_string = (timestamp + '.' + body).encode()
signature = hmac.new(apiSecret.encode(), signing_string, hashlib.sha256).hexdigest()
API requests are rate-limited per API key to ensure fair usage:
Rate limit headers are included in every response:
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 95
X-RateLimit-Reset: 1704067200
Creates a new loyalty group (company) with an associated tenant database. Each merchant can have one loyalty group containing multiple sites.
/group/create
| Parameter | Type | Required | Description |
|---|---|---|---|
company_slug |
string | Required | URL-safe company identifier (lowercase letters and numbers only, min 3 chars) |
company_name |
string | Required | Legal company name |
business_type |
string | Required | RESTAURANT or RETAIL |
curl -X POST http://localhost/api/loyalty/group/create \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "X-API-Key: your_api_key" \
-H "X-Timestamp: 2024-01-01T12:00:00Z" \
-H "X-Signature: signature_here" \
-d "company_slug=myrestaurant&company_name=My+Restaurant+Sdn+Bhd&business_type=RESTAURANT"
{
"success": true,
"group_id": 123,
"group_code": "myrestaurant_a1b2c3d4",
"tenant_db": "kiriloyalty_myrestaurant_a1b2c3d4"
}
Add a new location/site to an existing loyalty group. A loyalty group can have multiple sites (e.g., main restaurant, branch outlet).
/group/sites/add
| Parameter | Type | Required | Description |
|---|---|---|---|
site_code |
string | Required | Unique site code (uppercase letters and numbers only, min 3 chars) |
site_name |
string | Required | Display name for the location |
site_type |
string | Required | RESTAURANT or RETAIL |
address |
string | Optional | Street address |
city |
string | Optional | City |
state |
string | Optional | State/province |
postal_code |
string | Optional | Postal code |
phone |
string | Optional | Contact phone (include country code) |
curl -X POST http://localhost/api/loyalty/group/sites/add \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "X-API-Key: your_api_key" \
-H "X-Timestamp: 2024-01-01T12:00:00Z" \
-H "X-Signature: signature_here" \
-d "site_code=MAIN01&site_name=Main+Restaurant&site_type=RESTAURANT&address=123+Main+St&city=Kuala+Lumpur&phone=%2B60123456789"
{
"success": true,
"site_id": 456,
"site_code": "MAIN01"
}
Register a new customer in the loyalty program. Customers can be identified by phone number or QR code.
/customers/register
| Parameter | Type | Required | Description |
|---|---|---|---|
phone |
string | Required | Phone number with country code (e.g., +60123456789) |
name |
string | Optional | Customer name |
email |
string | Optional | Email address (also adds the customer to the merchant's email audience when provided) |
birthdate |
string | Optional | Birthday for birthday rewards (YYYY-MM-DD format) |
curl -X POST http://localhost/api/loyalty/customers/register \
-H "Content-Type: application/json" \
-H "X-API-Key: your_api_key" \
-H "X-Timestamp: 2024-01-01T12:00:00Z" \
-H "X-Signature: signature_here" \
-d '{"phone": "+60123456789", "name": "John Doe", "email": "john@example.com", "birthdate": "1990-05-15"}'
{
"success": true,
"data": {
"id": 123,
"site_id": 456,
"phone": "+60123456789",
"name": "John Doe",
"email": "john@example.com",
"status": "ACTIVE",
"balance_points": 0,
"balance_version": 1,
"created_at": "2026-05-11T10:00:00Z"
}
}
Find an existing customer by phone number, QR code, or promo code. Returns customer profile with current points balance.
/customers/lookup
| Parameter | Type | Required | Description |
|---|---|---|---|
phone |
string | Conditional* | Phone number with country code |
identifier |
string | Conditional* | QR code or promo code |
*At least one parameter must be provided
curl -X GET "http://localhost/api/loyalty/customers/lookup?phone=%2B60123456789" \
-H "X-API-Key: your_api_key" \
-H "X-Timestamp: 2024-01-01T12:00:00Z" \
-H "X-Signature: signature_here"
{
"success": true,
"data": {
"id": 123,
"site_id": 456,
"phone": "+60123456789",
"name": "John Doe",
"email": "john@example.com",
"status": "ACTIVE",
"balance_points": 1500,
"joined_at": "2026-05-01T10:00:00Z",
"last_earn_at": "2026-05-10T12:00:00Z",
"last_redeem_at": "2026-05-09T15:30:00Z",
"last_seen_at": "2026-05-10T14:00:00Z"
}
}
Find an existing customer by email address. Returns customer profile with current points balance.
/customers/lookup-by-email
| Parameter | Type | Required | Description |
|---|---|---|---|
email |
string | Required | Email address to lookup |
curl -X GET "http://localhost/api/loyalty/customers/lookup-by-email?email=john%40example.com" \
-H "X-API-Key: your_api_key" \
-H "X-Timestamp: 2024-01-01T12:00:00Z" \
-H "X-Signature: signature_here"
{
"success": true,
"data": {
"id": 123,
"site_id": 456,
"phone": "+60123456789",
"name": "John Doe",
"email": "john@example.com",
"status": "ACTIVE",
"balance_points": 1500,
"joined_at": "2026-05-01T10:00:00Z",
"last_earn_at": "2026-05-10T12:00:00Z",
"last_redeem_at": "2026-05-09T15:30:00Z",
"last_seen_at": "2026-05-10T14:00:00Z"
}
}
Award points to a customer after a purchase. A wallet is created automatically for wallet-less members on first earn.
/points/earn
| Parameter | Type | Required | Description |
|---|---|---|---|
customer_id |
integer | Required | Customer ID from registration or lookup |
points |
integer | Required | Points to award (must be positive) |
reference_type |
string | Required | Reference type, e.g., TRANSACTION, VOUCHER |
reference_id |
string | Required | POS-side unique reference (receipt/transaction ID) |
notes |
string | Optional | Human-readable note โ use the receipt number here (e.g., Sale #R-00123); shown in the merchant's transaction history |
The tuple (customer_id, transaction_type, reference_type, reference_id) is unique at the storage layer. If your POS retries an earn with the same reference, KiriMel returns the original ledger entry โ no double credit. Always send a stable receipt/transaction ID as reference_id.
curl -X POST http://localhost/api/loyalty/points/earn \
-H "Content-Type: application/json" \
-H "X-API-Key: your_api_key" \
-H "X-Timestamp: 2024-01-01T12:00:00Z" \
-H "X-Signature: signature_here" \
-d '{"customer_id": 123, "points": 50, "reference_type": "TRANSACTION", "reference_id": "TXN-2026-0829-00123", "notes": "Sale #R-00123"}'
{
"success": true,
"data": {
"id": 456,
"customer_id": 123,
"transaction_type": "EARN",
"points_delta": 50,
"balance_after": 983,
"reference_type": "TRANSACTION",
"reference_id": "TXN-2026-0829-00123",
"notes": "Sale #R-00123",
"metadata": null,
"created_at": "2026-08-29T10:00:00Z"
}
}
Redemption rules for building POS redemption UIs. Read once at terminal startup or settings sync.
/points/redeem-config
{
"success": true,
"data": {
"earn_conversion_value": 1.0,
"redeem_conversion_value": 0.01,
"min_redeem_points": 100,
"max_redeem_percent": 100
}
}
Redemption math: max_points = min(balance, floor(bill_amount ร max_redeem_percent / 100 รท redeem_conversion_value))
Validate a redemption against the customer's balance and your redemption rules without committing. Always preview before committing at the till.
/points/preview-redeem
| Parameter | Type | Required | Description |
|---|---|---|---|
customer_id |
integer | Required | Customer ID |
amount |
decimal | Required | Bill amount (must be positive) |
requested_points |
integer | Required | Points the customer wants to redeem |
{
"success": true,
"data": {
"success": true,
"points": 500,
"currency_value": 5.00,
"balance": 983,
"balance_after": 483,
"min_points": 100,
"max_points": 5000
}
}
Failures carry the bounds so your UI can show "min 100 pts / max RM 50.00" without a second round-trip:
{
"success": false,
"error": {
"message": "Exceeds maximum redeemable points",
"preview": {
"success": false,
"error": "Exceeds maximum redeemable points",
"max_points": 2500,
"balance": 983
}
}
}
Deduct points for a transaction. Only call this after a successful preview. Same deduplication semantics as earn โ a retried commit with the same reference returns the original ledger entry.
/points/commit-redeem
| Parameter | Type | Required | Description |
|---|---|---|---|
customer_id |
integer | Required | Customer ID |
points |
integer | Required | Points to redeem (must be positive) |
reference_type |
string | Required | e.g., TRANSACTION |
reference_id |
string | Required | POS-side unique reference |
notes |
string | Optional | e.g., Redeemed RM5.00 off Sale #R-00124 |
Ledger entry with transaction_type: "REDEEM" and negative points_delta (same shape as the earn response).
Void/refund all point movements for a reference (e.g., a refunded sale). Aggregates all EARN/REDEEM movements for that reference into a single REVERSE ledger row per customer. Idempotent โ re-reversing returns the existing REVERSE entry. Returns 404 if no earn/redeem entries exist for the reference.
/points/reverse
| Parameter | Type | Required | Description |
|---|---|---|---|
original_reference_type |
string | Required | Reference type of the original transaction |
original_reference_id |
string | Required | Reference ID of the original transaction |
notes |
string | Optional | e.g., Transaction voided at register 2 |
{
"success": true,
"data": {
"id": 458,
"customer_id": 123,
"transaction_type": "REVERSE",
"points_delta": -50,
"balance_after": 933,
"reference_type": "TRANSACTION",
"reference_id": "TXN-2026-0829-00123",
"notes": "Transaction voided at register 2",
"metadata": {
"original_ledger_ids": [456, 457]
},
"created_at": "2026-08-29T11:00:00Z"
}
}
Update a customer's profile. Fields are tiered by security:
name, birthdate โ applied immediatelyphone, email โ identity fields, rejected with 403 VERIFICATION_REQUIRED; use the Identity Change Verification endpoints (OTP emailed to the member's email on file)status โ not allowed via the POS API (merchant dashboard only)/customers/{id}
curl -X PUT http://localhost/api/loyalty/customers/123 \
-H "Content-Type: application/json" \
-H "X-API-Key: your_api_key" \
-H "X-Timestamp: 2024-01-01T12:00:00Z" \
-H "X-Signature: signature_here" \
-d '{"name": "John Smith", "birthdate": "1990-05-15"}'
{
"success": false,
"error": {
"message": "Identity fields require OTP verification",
"code": "VERIFICATION_REQUIRED",
"pending_fields": ["phone"],
"applied_fields": ["name"],
"next_step": "POST /api/loyalty/customers/123/identity-change/request"
}
}
Phone and email are the member's account credentials. Changing either requires a 6-digit OTP emailed to the customer's existing email on file โ the change applies only after confirmation. Rules: 10-minute expiry, max 5 wrong attempts (then VERIFICATION_LOCKED), max 3 requests per customer per hour, one active verification per customer+field. Duplicate values are rejected (409); members with no email on file return 422 IDENTITY_NO_EMAIL (merchant admin override required).
/customers/{id}/identity-change/request
| Parameter | Type | Required | Description |
|---|---|---|---|
field |
string | Required | phone or email |
new_value |
string | Required | The requested new value |
{
"success": true,
"data": {
"verification_id": 42,
"verification_token": "b7cb41bbf6d065559a405952b1372b65",
"field": "phone",
"expires_at": "2026-08-29 03:04:16",
"masked_email": "j***n@example.com"
}
}
/customers/{id}/identity-change/confirm
| Parameter | Type | Required | Description |
|---|---|---|---|
verification_token |
string | Required | Token from the request response |
code |
string | Required | 6-digit code from the member's email |
The updated customer object (same shape as lookup). Errors: INVALID_CODE 401 (attempts remaining in message), VERIFICATION_EXPIRED 410, VERIFICATION_LOCKED 429, DUPLICATE_VALUE 409.
Attempt PUT /customers/{id} first. On 403 VERIFICATION_REQUIRED, ask the member for the 6-digit code from their email, then call request + confirm.
Retrieve a customer's current points balance.
/wallet/balance
| Parameter | Type | Required | Description |
|---|---|---|---|
customer_id |
integer | Required | Customer ID from registration or lookup |
curl -X GET "http://localhost/api/loyalty/wallet/balance?customer_id=123" \
-H "X-API-Key: your_api_key" \
-H "X-Timestamp: 2024-01-01T12:00:00Z" \
-H "X-Signature: signature_here"
{
"success": true,
"data": {
"balance": 983
}
}
/wallet/recalculate
Repair operation: rebuilds the wallet balance from the ledger sum. Request body: {"customer_id": 123}. Response: {"success": true, "data": {"balance": 983, "recalculated": true}}
Generate a batch of unique vouchers for distribution. Each voucher has a unique 12-character code.
/vouchers/batches/create
| Parameter | Type | Required | Description |
|---|---|---|---|
name |
string | Required | Batch name (e.g., "Grand Opening Promo") |
type |
string | Required | Voucher type: PERCENT, FIXED, or BOGO |
value |
decimal | Required | Voucher value (percentage or amount) |
quantity |
integer | Required | Number of vouchers to generate (max 1000 per batch) |
valid_from |
string | Optional | Valid from date (YYYY-MM-DD format) |
valid_until |
string | Optional | Valid until date (YYYY-MM-DD format) |
min_purchase |
decimal | Optional | Minimum purchase amount for voucher use |
curl -X POST http://localhost/api/loyalty/vouchers/batches/create \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "X-API-Key: your_api_key" \
-H "X-Timestamp: 2024-01-01T12:00:00Z" \
-H "X-Signature: signature_here" \
-d "name=Grand+Opening+Promo&type=PERCENT&value=10&quantity=100&valid_from=2024-06-01&valid_until=2024-12-31&min_purchase=50"
{
"success": true,
"batch_id": "BATCH_001",
"vouchers_created": 100,
"voucher_codes": ["VOUCHER_A1B2C3D4E5F6", "VOUCHER_G7H8I9J0K1L2", ...]
}
Redeem a voucher code, applying the discount or reward to the customer's transaction. Validates voucher expiry and usage status.
/vouchers/redeem
| Parameter | Type | Required | Description |
|---|---|---|---|
code |
string | Required | Voucher code (12-character base62 string) |
customer_id |
string | Required | Customer ID redeeming the voucher |
purchase_amount |
decimal | Optional | Purchase amount for validation (for vouchers with min_purchase) |
reference_id |
string | Optional | External reference ID (e.g., order ID, transaction ID) |
curl -X POST http://localhost/api/loyalty/vouchers/redeem \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "X-API-Key: your_api_key" \
-H "X-Timestamp: 2024-01-01T12:00:00Z" \
-H "X-Signature: signature_here" \
-d "code=VOUCHER_A1B2C3D4E5F6&customer_id=CUST_123456&purchase_amount=75.00&reference_id=ORDER_456"
{
"success": true,
"redemption_id": "REDEEM_123",
"voucher_type": "PERCENT",
"voucher_value": 10,
"discount_applied": 7.50,
"new_purchase_amount": 67.50
}
The API uses standard HTTP response codes and returns detailed error information in JSON format.
{
"success": false,
"error": {
"code": "INSUFFICIENT_BALANCE",
"message": "Customer does not have enough points to complete this redemption",
"details": {
"requested": 100,
"available": 50,
"shortfall": 50
}
}
}
| Code | Description |
|---|---|
200 |
Request successful |
400 |
Bad Request - Invalid parameters |
401 |
Unauthorized - Invalid API key or signature |
404 |
Not Found - Resource doesn't exist |
429 |
Too Many Requests - Rate limit exceeded |
500 |
Internal Server Error |
| Error Code | Description |
|---|---|
INVALID_API_KEY |
API key is invalid or expired |
SIGNATURE_INVALID |
HMAC signature verification failed |
VALIDATION_ERROR |
Request validation failed (422, with per-field errors) |
VERIFICATION_REQUIRED |
Identity field change (phone/email) requires OTP verification (403) |
INVALID_CODE |
Wrong OTP code โ attempts remaining in the message (401) |
VERIFICATION_EXPIRED |
OTP code expired or already used (410) |
VERIFICATION_LOCKED |
Too many wrong OTP codes โ request a new verification (429) |
IDENTITY_NO_EMAIL |
Member has no email on file; merchant admin override required (422) |
DUPLICATE_VALUE |
Another member already uses this phone/email (409) |
CUSTOMER_NOT_FOUND |
Customer ID does not exist |
VOUCHER_EXPIRED |
Voucher has passed expiry date |
VOUCHER_ALREADY_REDEEMED |
Voucher has already been used |
VOUCHER_NOT_FOUND |
Voucher code does not exist |
TOO_MANY_REQUESTS |
Too many OTP requests or rate limit exceeded (429) |
SITE_LIMIT_REACHED |
Maximum number of sites reached for your plan |