Skip to main content

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

EndpointLimitScopeReset Period
/manual-event1 per event typePer user emailDaily (24 hours)
/create-checkout10 requestsPer userPer hour
/healthUnlimited--

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

LimitValueScopeReset Period
Transactions35Per wallet addressPer hour
Max Gas Limit1,000,000Per transaction-
Max Gas Price100 GweiPer 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

LimitValueScopeReset Period
Requests100Per brandPer minute
Unique UsersUnlimited--

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
HeaderDescriptionExample
X-RateLimit-LimitMaximum requests allowed35
X-RateLimit-RemainingRequests left in current window32
X-RateLimit-ResetUnix timestamp when limit resets1699999999

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

ResourceLimitNotes
Events per dayUnlimitedRate limited per user
Gasless transactions35/hour per userResets hourly
OAuth pregeneration100/minutePer brand
LTZ distributionUnlimitedSubject 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:

  1. Are you tracking same event multiple times?
  2. Are you using unique emails per user?
  3. Are you testing with production traffic?

Solutions:

  1. Implement client-side deduplication
  2. Use unique test emails
  3. Switch to test Brand ID

Rate Limit Not Resetting

Check:

  1. Verify current time zone
  2. Check X-RateLimit-Reset header
  3. 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());


Need Help? Join our Discord or email [email protected]