Rate Limits & Quotas
Complete guide to Loyalteez API rate limits and best practices.
Overview
Loyalteez implements rate limits to ensure fair usage and system stability.
Key Points:
- Limits are per endpoint and resource
- Exceeded limits return HTTP 429
- Headers indicate remaining quota
- Automatic deduplication prevents waste
Rate Limits by Endpoint
Event Handler API
| Endpoint | Limit | Scope | Reset Period |
|---|---|---|---|
/manual-event | 1 per event type | Per user email | Daily (24 hours) |
/create-checkout | 10 requests | Per user | Per hour |
/health | Unlimited | - | - |
Example:
// User can receive each reward type ONCE per day
await trackEvent('account_creation', '[email protected]'); // ✅ Success
await trackEvent('newsletter_subscribe', '[email protected]'); // ✅ Success (different event)
await trackEvent('account_creation', '[email protected]'); // ❌ 429 (same event, same day)
Gas Relayer API
| Limit | Value | Scope | Reset Period |
|---|---|---|---|
| Transactions | 35 | Per wallet address | Per hour |
| Max Gas Limit | 1,000,000 | Per transaction | - |
| Max Gas Price | 100 Gwei | Per transaction | - |
Example:
// User can make 35 gasless transactions per hour
for (let i = 0; i < 35; i++) {
await executeGaslessTransaction(...); // ✅ Success
}
await executeGaslessTransaction(...); // ❌ 429 Rate limit
Pregeneration API
| Limit | Value | Scope | Reset Period |
|---|---|---|---|
| Requests | 100 | Per brand | Per minute |
| Unique Users | Unlimited | - | - |
Idempotent: Same OAuth ID returns same wallet (not counted as new request).
Rate Limit Headers
When you make a request, response headers tell you your quota status:
HTTP/1.1 200 OK
X-RateLimit-Limit: 35
X-RateLimit-Remaining: 32
X-RateLimit-Reset: 1699999999
| Header | Description | Example |
|---|---|---|
X-RateLimit-Limit | Maximum requests allowed | 35 |
X-RateLimit-Remaining | Requests left in current window | 32 |
X-RateLimit-Reset | Unix timestamp when limit resets | 1699999999 |
Handling Rate Limits
Detect Rate Limits
async function trackEventWithRateLimitCheck(eventType, userEmail) {
try {
const response = await fetch(
'https://api.loyalteez.app/loyalteez-api/manual-event',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
brandId: 'your-brand-id',
eventType,
userEmail,
}),
}
);
// Check rate limit headers
const limit = response.headers.get('X-RateLimit-Limit');
const remaining = response.headers.get('X-RateLimit-Remaining');
const reset = response.headers.get('X-RateLimit-Reset');
console.log(`Rate Limit: ${remaining}/${limit} remaining`);
if (response.status === 429) {
const resetDate = new Date(parseInt(reset) * 1000);
console.warn(`Rate limited. Resets at ${resetDate.toLocaleString()}`);
return {
success: false,
error: 'rate_limited',
resetAt: resetDate,
};
}
if (!response.ok) {
throw new Error('Request failed');
}
return await response.json();
} catch (error) {
console.error('Event tracking failed:', error);
throw error;
}
}
Retry After Delay
async function trackEventWithRetry(eventType, userEmail, maxRetries = 1) {
try {
return await trackEvent(eventType, userEmail);
} catch (error) {
if (error.status === 429 && maxRetries > 0) {
// Get retry delay from header
const retryAfter = error.headers.get('Retry-After') || 60;
console.log(`Rate limited. Retrying in ${retryAfter} seconds...`);
// Wait and retry
await new Promise(resolve => setTimeout(resolve, retryAfter * 1000));
return trackEventWithRetry(eventType, userEmail, maxRetries - 1);
}
throw error;
}
}
Client-Side Deduplication
Prevent hitting rate limits by tracking locally:
class EventTracker {
constructor() {
this.trackedEvents = new Map();
}
canTrackEvent(eventType, userEmail) {
const key = `${userEmail}:${eventType}`;
const lastTracked = this.trackedEvents.get(key);
if (!lastTracked) {
return true; // Never tracked
}
// Check if 24 hours have passed
const hoursSinceTracked = (Date.now() - lastTracked) / (1000 * 60 * 60);
return hoursSinceTracked >= 24;
}
async trackEvent(eventType, userEmail) {
// Check local cache first
if (!this.canTrackEvent(eventType, userEmail)) {
return {
success: false,
error: 'already_tracked_today',
message: 'You already received this reward today',
};
}
try {
// Call API
const result = await fetch('https://api.loyalteez.app/loyalteez-api/manual-event', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
brandId: process.env.BRAND_ID,
eventType,
userEmail,
}),
});
if (!result.ok) {
throw new Error('Tracking failed');
}
// Mark as tracked
const key = `${userEmail}:${eventType}`;
this.trackedEvents.set(key, Date.now());
// Persist to localStorage
this.saveToStorage();
return await result.json();
} catch (error) {
console.error('Event tracking error:', error);
throw error;
}
}
saveToStorage() {
const data = Array.from(this.trackedEvents.entries());
localStorage.setItem('loyalteez_tracked_events', JSON.stringify(data));
}
loadFromStorage() {
const data = localStorage.getItem('loyalteez_tracked_events');
if (data) {
this.trackedEvents = new Map(JSON.parse(data));
}
}
}
// Usage
const tracker = new EventTracker();
tracker.loadFromStorage();
await tracker.trackEvent('account_creation', '[email protected]');
Display Rate Limit Status
Show users when they can claim next reward:
function RateLimitStatus({ eventType, userEmail }) {
const [nextAvailable, setNextAvailable] = useState(null);
useEffect(() => {
const lastTracked = getLastTrackedTime(eventType, userEmail);
if (lastTracked) {
const next = new Date(lastTracked + 24 * 60 * 60 * 1000);
setNextAvailable(next);
}
}, [eventType, userEmail]);
if (!nextAvailable) {
return <div>✅ Reward available now!</div>;
}
const now = new Date();
if (now >= nextAvailable) {
return <div>✅ Reward available now!</div>;
}
return (
<div>
⏱️ Next reward available at {nextAvailable.toLocaleString()}
</div>
);
}
Gas Relayer Rate Limits
Track Transaction Count
class GasRelayerClient {
constructor() {
this.txCount = new Map();
}
canMakeTransaction(walletAddress) {
const key = `${walletAddress}:${this.getCurrentHour()}`;
const count = this.txCount.get(key) || 0;
return count < 35; // 35 transactions per hour
}
async executeTransaction(walletAddress, txData) {
// Check rate limit
if (!this.canMakeTransaction(walletAddress)) {
return {
success: false,
error: 'rate_limited',
message: 'Maximum 35 transactions per hour. Please try again later.',
resetAt: this.getNextHourTimestamp(),
};
}
try {
// Execute transaction
const result = await fetch('https://relayer.loyalteez.app/relay', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${await getPrivyToken()}`,
},
body: JSON.stringify(txData),
});
if (result.status === 429) {
return {
success: false,
error: 'rate_limited',
message: 'Transaction limit reached',
};
}
// Increment count
const key = `${walletAddress}:${this.getCurrentHour()}`;
this.txCount.set(key, (this.txCount.get(key) || 0) + 1);
return await result.json();
} catch (error) {
console.error('Transaction failed:', error);
throw error;
}
}
getCurrentHour() {
const now = new Date();
return `${now.getFullYear()}-${now.getMonth()}-${now.getDate()}-${now.getHours()}`;
}
getNextHourTimestamp() {
const next = new Date();
next.setHours(next.getHours() + 1, 0, 0, 0);
return next.getTime();
}
}
Batch Operations
Event Tracking Batch
For bulk operations, use batching to stay within limits:
async function batchTrackEvents(events) {
// Deduplicate events (same email + event type)
const uniqueEvents = new Map();
for (const event of events) {
const key = `${event.userEmail}:${event.eventType}`;
if (!uniqueEvents.has(key)) {
uniqueEvents.set(key, event);
}
}
// Track each unique event
const results = [];
for (const event of uniqueEvents.values()) {
try {
const result = await trackEvent(event.eventType, event.userEmail);
results.push({ success: true, ...result });
} catch (error) {
if (error.status === 429) {
// Skip rate limited events
results.push({
success: false,
error: 'rate_limited',
...event,
});
} else {
throw error;
}
}
// Add delay between requests (100ms)
await new Promise(resolve => setTimeout(resolve, 100));
}
return {
total: events.length,
processed: uniqueEvents.size,
successful: results.filter(r => r.success).length,
rateLimited: results.filter(r => r.error === 'rate_limited').length,
results,
};
}
Quotas
Free Tier
| Resource | Limit | Notes |
|---|---|---|
| Events per day | Unlimited | Rate limited per user |
| Gasless transactions | 35/hour per user | Resets hourly |
| OAuth pregeneration | 100/minute | Per brand |
| LTZ distribution | Unlimited | Subject to brand balance |
Enterprise Tier
Contact [email protected] for:
- Higher rate limits
- Custom quotas
- Dedicated infrastructure
- SLA guarantees
Best Practices
1. Cache Locally
// Cache user's tracked events
const cachedEvents = JSON.parse(
localStorage.getItem('tracked_events') || '{}'
);
if (cachedEvents[eventKey]) {
// Already tracked, don't call API
return;
}
2. Exponential Backoff
async function retryWithBackoff(fn, maxRetries = 5) {
for (let i = 0; i < maxRetries; i++) {
try {
return await fn();
} catch (error) {
if (error.status === 429 && i < maxRetries - 1) {
const delay = Math.min(1000 * Math.pow(2, i), 30000);
await new Promise(resolve => setTimeout(resolve, delay));
continue;
}
throw error;
}
}
}
3. Queue Failed Requests
const failedRequests = [];
async function trackWithQueue(eventType, userEmail) {
try {
return await trackEvent(eventType, userEmail);
} catch (error) {
if (error.status === 429) {
// Add to queue
failedRequests.push({ eventType, userEmail, timestamp: Date.now() });
// Retry later
setTimeout(() => retryFailedRequests(), 60 * 1000);
}
throw error;
}
}
4. Monitor Usage
function logRateLimitStatus(response) {
const limit = response.headers.get('X-RateLimit-Limit');
const remaining = response.headers.get('X-RateLimit-Remaining');
const usagePercent = ((limit - remaining) / limit) * 100;
console.log(`Rate Limit Usage: ${usagePercent.toFixed(1)}%`);
// Alert if usage is high
if (usagePercent > 80) {
console.warn('⚠️ Approaching rate limit!');
}
}
Error Responses
429 Too Many Requests
Event Handler:
{
"error": "Rate limit exceeded",
"message": "Too many events from this email. Maximum 1 reward per event type per day per user."
}
Gas Relayer:
{
"error": "Rate limit exceeded. Max 35 transactions per hour."
}
Pregeneration:
{
"error": "Rate limit exceeded",
"message": "Maximum 100 requests per minute per brand",
"retryAfter": 60
}
Troubleshooting
Why Am I Being Rate Limited?
Check:
- Are you tracking same event multiple times?
- Are you using unique emails per user?
- Are you testing with production traffic?
Solutions:
- Implement client-side deduplication
- Use unique test emails
- Switch to test Brand ID
Rate Limit Not Resetting
Check:
- Verify current time zone
- Check
X-RateLimit-Resetheader - Clear browser cache/cookies
Solution:
// Get exact reset time
const resetTimestamp = response.headers.get('X-RateLimit-Reset');
const resetDate = new Date(parseInt(resetTimestamp) * 1000);
console.log('Resets at:', resetDate.toISOString());
Related Documentation
Need Help? Join our Discord or email [email protected]