Loyalty Points Accrual via EventBridge
This guide explains the complete technical flow for how loyalty points are calculated and credited when customers scan products, make purchases, or complete other campaign actions.
Architecture Overview
The loyalty points system uses AWS EventBridge for asynchronous, event-driven processing:
graph TB
A[Customer Action] --> B{Event Type}
B -->|Product Scan| C[scan-auth Event]
B -->|Order Placed| D[order-complete Event]
B -->|Profile Update| E[log-user-profile-update Event]
C --> F[EventBridge]
D --> F
E --> F
F --> G[Lambda/API Handler]
G --> H[Validation]
H --> I[Points Calculation]
I --> J[Transaction Creation]
J --> K[Balance Update]
K --> L[Campaign Progress]
L --> M[Tier Check]
M --> N[Wallet Update]
N --> O[Notification]
style F fill:#FF9900
style G fill:#4CAF50
style O fill:#2196F3
Key Components:
- EventBridge - Central event bus for routing events
- Lambda Functions - Process events asynchronously
- Admin Backend API - Handles business logic
- PostgreSQL - Stores transactions and balances
- Redis - Caches user data and brand configs
Complete Flow Timeline
End-to-End Timing: 600ms - 1.8 seconds
gantt
title Loyalty Points Accrual via EventBridge (Complete Flow)
dateFormat HH:mm:ss.SSS
axisFormat %S.%Ls
section Event Trigger
Product Scan Completed :milestone, m1, 00:00:00.000, 0ms
Publish "scan-auth" Event :event1, 00:00:00.000, 50ms
EventBridge Receives Event :event2, 00:00:00.050, 30ms
Route to Lambda/Endpoint :event3, 00:00:00.080, 40ms
Lambda Cold Start (if needed) :event4, 00:00:00.120, 150ms
Event Received by Handler :milestone, m2, 00:00:00.270, 0ms
section User & Brand Validation
Parse Event Payload :val1, 00:00:00.270, 10ms
Query User by ID :val2, 00:00:00.280, 30ms
Fetch User's Loyalty Status :val3, 00:00:00.310, 25ms
Query Brand & Brand Level :val4, 00:00:00.335, 35ms
Verify Loyalty Active :val5, 00:00:00.370, 10ms
Validation Complete :milestone, m3, 00:00:00.380, 0ms
section Points Calculation
Get scan_reward_type (fixed/%) :calc1, 00:00:00.380, 10ms
Get scan_reward_value :calc2, 00:00:00.390, 5ms
Fetch Product Retail Price :calc3, 00:00:00.395, 20ms
Calculate Points (value * price) :calc4, 00:00:00.415, 15ms
Apply Product Override (if exists) :calc5, 00:00:00.430, 10ms
Points Calculated :milestone, m4, 00:00:00.440, 0ms
section Duplicate Check
Query loyalty_transaction Table :dup1, 00:00:00.440, 35ms
Check authCode in reason_id :dup2, 00:00:00.475, 15ms
Verify Not Previously Claimed :dup3, 00:00:00.490, 10ms
Duplicate Check Passed :milestone, m5, 00:00:00.500, 0ms
section WPLoyalty Integration (Optional)
Check for WPLoyalty Config :wp1, 00:00:00.500, 15ms
Build WPLoyalty API Request :wp2, 00:00:00.515, 20ms
Call WordPress API :wp3, 00:00:00.535, 100ms
Receive External Points Confirm :wp4, 00:00:00.635, 15ms
WPLoyalty Sync Complete :milestone, m6, 00:00:00.650, 0ms
section Transaction Creation
Begin Database Transaction :trans1, 00:00:00.650, 5ms
Insert loyalty_transaction Record :trans2, 00:00:00.655, 80ms
Set points_earned, reason, note :trans3, 00:00:00.735, 20ms
Set reason_id = authCode :trans4, 00:00:00.755, 10ms
Link to user_point_id :trans5, 00:00:00.765, 15ms
Commit Transaction :trans6, 00:00:00.780, 20ms
Transaction Created :milestone, m7, 00:00:00.800, 0ms
section Point Balance Update
Query user_points Record :bal1, 00:00:00.800, 25ms
Increment points by earned :bal2, 00:00:00.825, 30ms
Increment points_earned (lifetime) :bal3, 00:00:00.855, 20ms
Update Database :bal4, 00:00:00.875, 25ms
Balance Updated :milestone, m8, 00:00:00.900, 0ms
section Campaign Progress Check
Query Active Loyalty Campaigns :camp1, 00:00:00.900, 60ms
Filter by "authenticator_scan" Type :camp2, 00:00:00.960, 20ms
Get User's Campaign Progress :camp3, 00:00:00.980, 50ms
Calculate New Progress :camp4, 00:00:01.030, 30ms
Check Milestone Achievement :camp5, 00:00:01.060, 40ms
Update user_loyalty_campaign :camp6, 00:00:01.100, 80ms
Check Auto-Claim Rewards :camp7, 00:00:01.180, 50ms
Campaign Progress Updated :milestone, m9, 00:00:01.230, 0ms
section Level/Tier Progression
Calculate Total Lifetime Points :level1, 00:00:01.230, 20ms
Query Brand Level Thresholds :level2, 00:00:01.250, 35ms
Check for Tier Upgrade :level3, 00:00:01.285, 40ms
Update user.current_level (if needed) :level4, 00:00:01.325, 45ms
Level Check Complete :milestone, m10, 00:00:01.370, 0ms
section Wallet Pass Update (Async)
Generate Apple Wallet Payload :wallet1, 00:00:01.370, 60ms
Update via Apple Push Notification :wallet2, 00:00:01.430, 150ms
Generate Google Wallet Payload :wallet3, 00:00:01.580, 40ms
Update Google Wallet Pass :wallet4, 00:00:01.620, 100ms
Wallet Passes Updated :milestone, m11, 00:00:01.720, 0ms
section Notification Trigger (Async)
Build Notification Payload :notif1, 00:00:01.720, 20ms
Publish to EventBridge :notif2, 00:00:01.740, 40ms
Template: NOTIFY_CAMPAIGN_SUCCESS :notif3, 00:00:01.780, 15ms
Include Points Earned :notif4, 00:00:01.795, 10ms
Send Push/Email/SMS :notif5, 00:00:01.805, 60ms
User Notified :milestone, m12, 00:00:01.865, 0ms
Performance Metrics
| Phase | Duration | Optimization Notes |
|---|---|---|
| Event Publishing | 50ms | AWS SDK PutEvents call |
| EventBridge Routing | 70ms | Rule evaluation + Lambda invocation |
| Lambda Cold Start | 0-150ms | Cached after first run - use provisioned concurrency for critical paths |
| User/Brand Validation | 110ms | Can be optimized - use Redis caching for brand_level data |
| Points Calculation | 60ms | Pure computation, minimal overhead |
| Duplicate Check | 60ms | Database query with index on reason_id |
| WPLoyalty API (optional) | 150ms | External API call - can be skipped if not configured |
| Transaction Creation | 150ms | Database transaction with ACID guarantees |
| Balance Update | 100ms | Atomic increment operation |
| Campaign Progress | 330ms | Most expensive - queries multiple campaigns |
| Level Update | 140ms | Tier threshold checks |
| Wallet Update (async) | 350ms | APN/Google Wallet API calls - non-blocking |
| Notification (async) | 145ms | Multi-channel delivery - non-blocking |
| Total (avg) | ~1 second | Acceptable for async operation |
The total time of ~1 second is acceptable because this is an asynchronous operation. The customer receives immediate feedback from the scan, and points appear within 1-2 seconds.
For further optimization:
- ✅ Enable provisioned concurrency for Lambda functions (eliminates cold starts)
- ✅ Cache brand_level data in Redis (reduces DB queries by 80%)
- ✅ Use database connection pooling (reduces connection overhead)
- ✅ Batch campaign progress updates (update every 5 scans instead of every scan)
Event Structure
EventBridge Event Payload
When a customer scans a product, this event is published:
{
"version": "0",
"id": "a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d",
"detail-type": "scan-auth",
"source": "batch.customer-app",
"account": "123456789012",
"time": "2025-11-04T10:30:00Z",
"region": "us-west-2",
"resources": [],
"detail": {
"arguments": {
"brand_id": 123,
"user_id": 456,
"authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
},
"payload": {
"type": "authenticator_scan",
"product": {
"productId": 789,
"authCode": "ABC123XYZ",
"title": "Blue Dream 3.5g",
"retailPrice": 35.00,
"redeem_point_value": 100
},
"location": {
"latitude": 34.0522,
"longitude": -118.2437,
"city": "Los Angeles",
"region": "California"
},
"timestamp": "2025-11-04T10:30:00.000Z"
}
}
}
Event Types Matrix
| Event Type | DetailType | Trigger | Processing Time |
|---|---|---|---|
| scan-auth | "scan-auth" | Product QR code scan | 600ms-1.8s |
| order-complete | "order-complete" | Order placed/completed | 500ms-1.2s |
| log-user-profile-update | "log-user-profile-update" | Profile change | 80-200ms |
| log-user-login | "log-user-login" | User login | 100-250ms |
API Integration
Endpoint: Check and Update Campaign Progress
POST /api/v1/brand/loyalty/campaign/event
Authentication: Required (JWT Bearer token + API key)
Rate Limit: 100 requests/minute per user
Request Headers
Authorization: Bearer {JWT_TOKEN}
api-key: {HUB_API_KEY}
Content-Type: application/json
origin: {BRAND_HOSTNAME}
Request Body
{
"user_id": 456,
"brand_id": 123,
"type": "authenticator_scan",
"product": {
"productId": 789,
"authCode": "ABC123XYZ",
"title": "Blue Dream 3.5g",
"retailPrice": 35.00,
"redeem_point_value": 100
}
}
Response (200 OK)
{
"success": true,
"data": {
"points_earned": 35,
"new_balance": 1235,
"transaction_id": "txn_abc123def456",
"campaign_progress": {
"campaign_id": 50,
"campaign_name": "Summer Scan Challenge",
"progress": 7,
"target": 10,
"milestone_achieved": false,
"next_reward": "10% off discount"
},
"level_updated": false,
"current_level": "Gold",
"next_level_threshold": 2000
},
"message": "Points credited successfully"
}
Error Responses
400 Bad Request - Invalid auth code
{
"success": false,
"error": "AUTH_CODE_INVALID",
"message": "The auth code 'ABC123XYZ' is not valid or has expired"
}
409 Conflict - Duplicate scan
{
"success": false,
"error": "POINTS_ALREADY_CLAIMED",
"message": "Points have already been claimed for this auth code"
}
404 Not Found - User not found
{
"success": false,
"error": "USER_NOT_FOUND",
"message": "User with ID 456 does not exist"
}
cURL Example
curl -X POST https://admin-staging-api.batchsys.com/api/v1/brand/loyalty/campaign/event \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjQ1NiwiYnJhbmRJZCI6MTIzfQ..." \
-H "api-key: hub_1234567890abcdef1234567890abcdef" \
-H "Content-Type: application/json" \
-H "origin: https://yourbrand.com" \
-d '{
"user_id": 456,
"brand_id": 123,
"type": "authenticator_scan",
"product": {
"productId": 789,
"authCode": "ABC123XYZ",
"retailPrice": 35.00
}
}'
JavaScript SDK Example
import { ADMIN } from './services/APISDK';
const admin = new ADMIN();
const response = await admin.checkAndUpdateProgressForCampaign({
user_id: 456,
brand_id: 123,
type: 'authenticator_scan',
product: {
productId: 789,
authCode: 'ABC123XYZ',
retailPrice: 35.00
}
});
if (response.success) {
console.log(`User earned ${response.data.points_earned} points!`);
console.log(`New balance: ${response.data.new_balance}`);
if (response.data.campaign_progress.milestone_achieved) {
console.log('🎉 Milestone achieved!');
}
}
Python Example
import requests
url = "https://admin-staging-api.batchsys.com/api/v1/brand/loyalty/campaign/event"
headers = {
"Authorization": f"Bearer {jwt_token}",
"api-key": api_key,
"Content-Type": "application/json"
}
payload = {
"user_id": 456,
"brand_id": 123,
"type": "authenticator_scan",
"product": {
"productId": 789,
"authCode": "ABC123XYZ",
"retailPrice": 35.00
}
}
response = requests.post(url, json=payload, headers=headers)
data = response.json()
if data["success"]:
print(f"Points earned: {data['data']['points_earned']}")
print(f"New balance: {data['data']['new_balance']}")
Points Calculation Logic
Calculation Methods
1. Fixed Points Reward
Configuration:
const brandLevel = {
scan_reward_type: "points",
scan_reward_value: 10 // Fixed 10 points per scan
}
Calculation:
const pointsEarned = brandLevel.scan_reward_value;
// Result: 10 points
Use case: Simple, predictable rewards. Every scan = 10 points.
2. Percentage-based Reward
Configuration:
const brandLevel = {
scan_reward_type: "percentage",
scan_reward_value: 10 // 10% of retail price
}
const product = {
retailPrice: 35.00
}
Calculation:
const pointsEarned = Math.round(
(product.retailPrice * brandLevel.scan_reward_value) / 100
);
// Result: Math.round((35.00 * 10) / 100) = 4 points
Use case: Reward proportional to product value. Higher-priced products = more points.
3. Product-specific Override
Configuration:
const product = {
retailPrice: 35.00,
redeem_point_value: 100 // Override: always 100 points
}
Calculation:
const pointsEarned = product.redeem_point_value || calculateFromBrandLevel();
// Result: 100 points (override takes precedence)
Use case: Promotional products, premium items, limited editions.
Code Implementation
Service: /Batch-Admin-Backend/app/v1/service/userPoint_service.ts
function calculatePointsEarned(
brandLevel: BrandLevel,
product: Product
): number {
// Product override takes precedence
if (product.redeem_point_value) {
return product.redeem_point_value;
}
// Fixed points
if (brandLevel.scan_reward_type === 'points') {
return brandLevel.scan_reward_value;
}
// Percentage-based
if (brandLevel.scan_reward_type === 'percentage') {
return Math.round(
(product.retailPrice * brandLevel.scan_reward_value) / 100
);
}
// Default fallback
return 1;
}
Duplicate Prevention
Idempotency Strategy
The system prevents duplicate point accrual using the reason_id field in the loyalty_transaction table:
// Check for existing transaction
const existingTransaction = await loyaltyTransaction.findOne({
where: {
user_point_id: userPoint.id,
reason: 'earned',
reason_id: authCode // Unique auth code per product
}
});
if (existingTransaction) {
throw new Error('POINTS_ALREADY_CLAIMED');
}
Key Implementation Details:
- ✅ Each auth code is unique per product/batch
- ✅
reason_idis indexed for fast lookups - ✅ Database constraint ensures atomicity
- ✅ Operation is idempotent - safe to retry
Database Schema
CREATE TABLE loyalty_transaction (
loyalty_transaction_id SERIAL PRIMARY KEY,
user_point_id INTEGER NOT NULL REFERENCES user_points(user_point_id),
points_earned DECIMAL(10,2),
points_redeemed DECIMAL(10,2),
reason VARCHAR(50), -- 'earned', 'redeemed', 'expired'
reason_id VARCHAR(255), -- authCode for deduplication
note TEXT,
createdAt TIMESTAMP DEFAULT NOW(),
updatedAt TIMESTAMP DEFAULT NOW(),
UNIQUE(user_point_id, reason_id) -- Prevents duplicates
);
CREATE INDEX idx_reason_id ON loyalty_transaction(reason_id);
Database Operations
Tables Involved
1. loyalty_transaction
Insert new transaction:
INSERT INTO loyalty_transaction (
user_point_id,
points_earned,
reason,
reason_id,
note,
createdAt,
updatedAt
) VALUES (
123, -- user_point_id
35, -- points_earned
'earned', -- reason
'ABC123XYZ', -- authCode for deduplication
'Scanned a Blue Dream 3.5g', -- customer-facing note
NOW(),
NOW()
) RETURNING loyalty_transaction_id;
Query transactions:
SELECT * FROM loyalty_transaction
WHERE user_point_id = 123
AND createdAt >= NOW() - INTERVAL '30 days'
ORDER BY createdAt DESC
LIMIT 50;
2. user_points
Update point balance:
UPDATE user_points
SET
points = points + 35, -- Current balance
points_earned = points_earned + 35, -- Lifetime earned
updatedAt = NOW()
WHERE user_point_id = 123
RETURNING points, points_earned;
Query user balance:
SELECT
user_point_id,
points,
points_earned,
points_redeemed,
current_level
FROM user_points
WHERE user_id = 456;
3. user_loyalty_campaign
Update campaign progress:
INSERT INTO user_loyalty_campaign (
user_id,
campaign_id,
progress,
milestones_achieved,
createdAt,
updatedAt
) VALUES (
456,
50,
1, -- First scan
'{}', -- Empty milestone array
NOW(),
NOW()
)
ON CONFLICT (user_id, campaign_id)
DO UPDATE SET
progress = user_loyalty_campaign.progress + 1,
updatedAt = NOW()
RETURNING progress;
Testing
Unit Test Example
File: userPoint_service.test.ts
import { checkAndUpdateProgressForCampaign } from './userPoint_service';
import { createMockApp, createMockUser, createMockProduct } from '../test/helpers';
describe('Loyalty Points Accrual', () => {
let app, user, brand, product;
beforeEach(async () => {
app = createMockApp();
user = await createMockUser({ user_id: 456, brand_id: 123 });
brand = await createMockBrand({ brand_id: 123 });
product = await createMockProduct({
productId: 789,
retailPrice: 35.00,
authCode: 'TEST123'
});
});
it('should credit points for valid product scan', async () => {
const payload = {
type: 'authenticator_scan',
product: {
productId: 789,
authCode: 'TEST123',
retailPrice: 35.00
}
};
const result = await checkAndUpdateProgressForCampaign(
app,
user.user_id,
brand.brand_id,
payload
);
expect(result.success).toBe(true);
expect(result.data.points_earned).toBeGreaterThan(0);
expect(result.data.transaction_id).toBeDefined();
expect(result.data.new_balance).toBe(user.points + result.data.points_earned);
});
it('should prevent duplicate point accrual', async () => {
const payload = {
type: 'authenticator_scan',
product: { authCode: 'TEST123' }
};
// First scan - should succeed
await checkAndUpdateProgressForCampaign(app, 456, 123, payload);
// Second scan - should fail
await expect(
checkAndUpdateProgressForCampaign(app, 456, 123, payload)
).rejects.toThrow('POINTS_ALREADY_CLAIMED');
});
it('should update campaign progress', async () => {
const campaign = await createMockCampaign({
type: 'authenticator_scan',
target_value: 10
});
const payload = {
type: 'authenticator_scan',
product: { authCode: 'TEST456' }
};
const result = await checkAndUpdateProgressForCampaign(app, 456, 123, payload);
expect(result.data.campaign_progress).toBeDefined();
expect(result.data.campaign_progress.progress).toBe(1);
expect(result.data.campaign_progress.target).toBe(10);
});
it('should trigger tier upgrade when threshold reached', async () => {
// Set user close to tier upgrade
await user.update({ points: 1995 }); // 5 points away from Gold tier (2000)
const payload = {
type: 'authenticator_scan',
product: { authCode: 'TEST789', retailPrice: 50.00 }
};
// Assuming 10% reward = 5 points
const result = await checkAndUpdateProgressForCampaign(app, 456, 123, payload);
expect(result.data.level_updated).toBe(true);
expect(result.data.current_level).toBe('Gold');
});
});
Integration Test Example
describe('Loyalty Points Integration', () => {
it('should handle end-to-end scan flow', async () => {
// 1. Customer scans product
const scanResponse = await request(app)
.post('/api/v1/verify/product/ABC123XYZ')
.set('Authorization', `Bearer ${userToken}`)
.send({ latitude: 34.0522, longitude: -118.2437 });
expect(scanResponse.status).toBe(200);
// 2. Points event triggered
await new Promise(resolve => setTimeout(resolve, 2000)); // Wait for async processing
// 3. Verify points credited
const pointsResponse = await request(app)
.get('/api/v1/customer/loyalty/points')
.set('Authorization', `Bearer ${userToken}`);
expect(pointsResponse.body.data.points).toBeGreaterThan(initialPoints);
// 4. Verify transaction created
const transactionsResponse = await request(app)
.get('/api/v1/customer/loyalty/transactions')
.set('Authorization', `Bearer ${userToken}`);
const latestTransaction = transactionsResponse.body.data[0];
expect(latestTransaction.reason).toBe('earned');
expect(latestTransaction.reason_id).toBe('ABC123XYZ');
});
});
Monitoring & Debugging
CloudWatch Logs
Log Group: /aws/lambda/loyalty-points-processor
Key Log Events:
{
"timestamp": "2025-11-04T10:30:01.500Z",
"level": "INFO",
"message": "Points accrual started",
"user_id": 456,
"brand_id": 123,
"auth_code": "ABC123XYZ",
"event_id": "a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d"
}
{
"timestamp": "2025-11-04T10:30:02.100Z",
"level": "INFO",
"message": "Points credited successfully",
"user_id": 456,
"points_earned": 35,
"new_balance": 1235,
"duration_ms": 600
}
Metrics
CloudWatch Metrics:
LoyaltyPointsAccrued- Count of successful point accrualsLoyaltyPointsFailure- Count of failuresLoyaltyProcessingDuration- Average processing timeDuplicateScanAttempts- Count of duplicate scan attempts
Custom Dashboard:
┌─────────────────────────────────────────┐
│ Loyalty Points - Last 24 Hours │
├─────────────────────────────────────────┤
│ Total Accruals: 15,432 │
│ Avg Processing Time: 847ms │
│ Success Rate: 98.5% │
│ Duplicate Attempts: 234 (1.5%) │
└─────────────────────────────────────────┘
Related Documentation
For Brand Managers
For Developers
Troubleshooting
Points Not Credited
Symptoms: Customer scanned product but points didn't appear
Diagnosis:
- Check CloudWatch Logs for errors
- Verify EventBridge event was published:
aws events list-rule-names-by-target - Check Lambda function invocation:
aws lambda get-function-concurrency - Query loyalty_transaction table for duplicate entries
Common Causes:
- Duplicate scan (auth code already used)
- User not enrolled in loyalty program
- Brand loyalty settings disabled
- EventBridge rule misconfigured
Slow Processing
Symptoms: Points take >3 seconds to appear
Diagnosis:
- Check Lambda cold start metrics
- Review database connection pool utilization
- Analyze campaign progress query performance
Solutions:
- Enable provisioned concurrency for Lambda
- Optimize database indexes
- Cache brand_level configuration in Redis
- Batch campaign progress updates
Next Steps
Now that you understand the points accrual flow, explore:
- Batch Range Creation - How auth codes are generated
- Product Verification Flow - How QR code scanning works
- Order Processing Flow - Points from purchases
- EventBridge Setup Guide - Configure event processing