coal
coal

Rate Limits

Coal enforces rate limits on all API endpoints to protect the platform against abuse and ensure fair access for all merchants. This page explains the limits in effect, how to read rate limit headers, and how to handle 429 responses gracefully.


Limits by Endpoint

CategoryEndpoint patternLimitWindow
AuthenticationPOST /api/auth/*5 requests1 minute
Checkout initPOST /api/checkouts10 requests1 minute
Pay confirmPOST /api/pay/confirm10 requests1 minute
Pay statusGET /api/pay/status/:id30 requests1 minute
Console (Privy)GET/POST/PUT/DELETE /api/console/*60 requests1 minute
Paywall verifyGET /api/paywalls/:id/verify30 requests1 minute
Paywall payPOST /api/paywalls/:id/pay10 requests1 minute
Public (all)All unauthenticated endpoints30 requests1 minute

Limits are applied per IP address for unauthenticated routes, per merchant API key for public merchant routes, and per console user/session for Privy-authenticated console routes.


Rate Limit Headers

Every API response includes rate limit headers so you can monitor your usage before hitting a limit:

HeaderDescription
X-RateLimit-LimitThe total number of requests allowed in the current window.
X-RateLimit-RemainingThe number of requests remaining in the current window.
X-RateLimit-ResetUnix timestamp (seconds) of when the current window resets.

Example response headers:

text
1X-RateLimit-Limit: 60
2X-RateLimit-Remaining: 47
3X-RateLimit-Reset: 1742641260

When X-RateLimit-Remaining reaches 0, the next request in the same window will be rate limited.


What Happens When You Are Rate Limited

When you exceed a rate limit, the API returns:

text
1HTTP/1.1 429 Too Many Requests
2Content-Type: application/json
3Retry-After: 38
4X-RateLimit-Limit: 10
5X-RateLimit-Remaining: 0
6X-RateLimit-Reset: 1742641260
json
1{
2 "error": "RATE_LIMITED",
3 "message": "Too many requests. Please wait 38 seconds before retrying.",
4 "retryAfter": 38
5}

The Retry-After header contains the number of seconds to wait before your next request will succeed. The X-RateLimit-Reset header contains the absolute Unix timestamp when your window resets.


Sliding Window Algorithm

In production, Coal uses Upstash Redis sliding window rate limiting via the @upstash/ratelimit library. The sliding window algorithm is more forgiving than a fixed window — instead of allowing a full burst at the exact moment a window resets, it smooths the allowance across time.

In development and local environments, an in-memory fallback is used when UPSTASH_REDIS_REST_URL is not set. The in-memory store is not shared across processes, so you may observe different behavior in clustered environments compared to production.


Handling 429 in Your Code

Basic Retry with Exponential Backoff

typescript
1async function callCoalApi<T>(
2 url: string,
3 init: RequestInit,
4 maxRetries = 3
5): Promise<T> {
6 for (let attempt = 0; attempt <= maxRetries; attempt++) {
7 const res = await fetch(url, init);
8
9 if (res.status !== 429) {
10 if (!res.ok) {
11 const body = await res.json().catch(() => ({}));
12 throw new Error(`Coal API error ${res.status}: ${body.error ?? 'Unknown'}`);
13 }
14 return res.json();
15 }
16
17 // Rate limited — read the Retry-After header
18 const retryAfter = parseInt(res.headers.get('Retry-After') ?? '5', 10);
19 const waitMs = retryAfter * 1000;
20
21 if (attempt < maxRetries) {
22 console.warn(`Rate limited. Retrying in ${retryAfter}s (attempt ${attempt + 1}/${maxRetries})`);
23 await new Promise((resolve) => setTimeout(resolve, waitMs));
24 } else {
25 throw new Error(`Rate limit exceeded after ${maxRetries} retries. Wait ${retryAfter}s.`);
26 }
27 }
28
29 // TypeScript narrowing: this line is unreachable
30 throw new Error('Unexpected state');
31}
32
33// Usage
34const data = await callCoalApi<{ data: { sessionId: string } }>(
35 'https://api.usecoal.xyz/api/checkouts',
36 {
37 method: 'POST',
38 headers: {
39 'Content-Type': 'application/json',
40 'x-api-key': process.env.COAL_API_KEY!,
41 },
42 body: JSON.stringify({ slug: 'premium-membership' }),
43 }
44);

Proactive Rate Limit Monitoring

Monitor headers before hitting the limit:

typescript
1async function checkoutWithRateLimitGuard(slug: string) {
2 const res = await fetch('https://api.usecoal.xyz/api/checkouts', {
3 method: 'POST',
4 headers: {
5 'Content-Type': 'application/json',
6 'x-api-key': process.env.COAL_API_KEY!,
7 },
8 body: JSON.stringify({ slug }),
9 });
10
11 // Log remaining quota for observability
12 const remaining = res.headers.get('X-RateLimit-Remaining');
13 const resetAt = res.headers.get('X-RateLimit-Reset');
14
15 if (remaining !== null && parseInt(remaining) < 5) {
16 console.warn(
17 `Low rate limit budget: ${remaining} requests remaining. Resets at ${new Date(parseInt(resetAt!) * 1000).toISOString()}`
18 );
19 }
20
21 if (!res.ok) throw new Error(`Checkout failed: ${res.status}`);
22 return res.json();
23}

Best Practices to Stay Within Limits

  • Cache checkout sessions — don't re-initialize a new session on every page load if the user hasn't interacted yet. Create sessions on demand (e.g. when the user clicks "Buy").
  • Poll status efficiently — when checking GET /api/pay/status/:id, use exponential backoff starting at 2 seconds, not 100ms. The cron job runs every minute, so polling faster than every 2–5 seconds is wasted.
  • Use webhooks over polling — for production fulfillment logic, receive checkout.session.completed via webhook instead of polling the status endpoint.
  • Batch Console reads — use the limit and offset query parameters to paginate large result sets rather than making many small requests.