Rate Limits
Bag API rate limit tiers, response headers, and retry strategies with code examples.
Rate Limits
Bag enforces rate limits to protect the API from abuse and ensure fair usage across all merchants. Limits are applied per IP address using a sliding window algorithm.
Rate limit tiers
Every request passes through the global limiter first. If the request matches a more specific tier, that tier's limit is checked as well — both must pass.
| Tier | Routes | Limit | Window |
|---|---|---|---|
| Global | All /api/* endpoints | 100 requests | 10 seconds |
| Checkout | /api/checkout/* | 5 requests | 1 minute |
| Key Management | /api/developer/keys | 5 requests | 1 minute |
| Webhook Management | /api/webhooks | 10 requests | 1 minute |
| Read | All GET requests | 30 requests | 10 seconds |
| Auth Failures | Failed authentication attempts | 10 attempts | 5 minutes |
The Auth Failures tier is special — it counts failed authentication attempts (invalid or revoked API keys) rather than total requests. After 10 failures from the same IP within 5 minutes, all subsequent requests are blocked regardless of whether the key is valid.
Response headers
Successful responses from rate-limited endpoints include headers showing your current usage:
| Header | Type | Description |
|---|---|---|
X-RateLimit-Limit | integer | Maximum requests allowed in the current window |
X-RateLimit-Remaining | integer | Requests remaining before you hit the limit |
X-RateLimit-Reset | integer | Unix timestamp in milliseconds when the window resets |
HTTP/1.1 200 OK
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 87
X-RateLimit-Reset: 1709290810000429 response
When you exceed a rate limit, Bag returns a 429 Too Many Requests response:
{
"status": "error",
"message": "Too many requests. Please slow down.",
"retryAfter": 10
}The retryAfter field is the number of seconds to wait before retrying. The rate limit headers are also included in 429 responses.
Retry strategies
Exponential backoff with jitter
The recommended approach is exponential backoff with random jitter. This prevents thundering herd problems when multiple clients hit the limit simultaneously.
async function fetchWithRetry(
url: string,
options: RequestInit,
maxRetries = 3
): Promise<Response> {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const response = await fetch(url, options);
if (response.status !== 429) {
return response;
}
if (attempt === maxRetries) {
throw new Error(`Rate limited after ${maxRetries} retries`);
}
const retryAfter = await response.json().then(
(body) => body.retryAfter ?? 10
);
const baseDelay = retryAfter * 1000;
const jitter = Math.random() * 1000;
const delay = baseDelay + jitter;
console.log(
`Rate limited. Retrying in ${Math.round(delay / 1000)}s (attempt ${attempt + 1}/${maxRetries})`
);
await new Promise((resolve) => setTimeout(resolve, delay));
}
throw new Error("Unreachable");
}
const response = await fetchWithRetry(
"https://api.justusebag.xyz/api/payment-links",
{
headers: {
Authorization: `Bearer ${process.env.BAG_API_KEY}`,
},
}
);import time
import random
import requests
def fetch_with_retry(url: str, headers: dict, max_retries: int = 3) -> requests.Response:
for attempt in range(max_retries + 1):
response = requests.get(url, headers=headers)
if response.status_code != 429:
return response
if attempt == max_retries:
raise Exception(f"Rate limited after {max_retries} retries")
body = response.json()
retry_after = body.get("retryAfter", 10)
base_delay = retry_after
jitter = random.uniform(0, 1)
delay = base_delay + jitter
print(f"Rate limited. Retrying in {delay:.1f}s (attempt {attempt + 1}/{max_retries})")
time.sleep(delay)
response = fetch_with_retry(
"https://api.justusebag.xyz/api/payment-links",
headers={"Authorization": f"Bearer {os.environ['BAG_API_KEY']}"},
)#!/bin/bash
MAX_RETRIES=3
URL="https://api.justusebag.xyz/api/payment-links"
for attempt in $(seq 0 $MAX_RETRIES); do
HTTP_CODE=$(curl -s -o /tmp/bag_response.json -w "%{http_code}" \
-H "Authorization: Bearer $BAG_API_KEY" \
"$URL")
if [ "$HTTP_CODE" != "429" ]; then
cat /tmp/bag_response.json
exit 0
fi
if [ "$attempt" -eq "$MAX_RETRIES" ]; then
echo "Rate limited after $MAX_RETRIES retries" >&2
exit 1
fi
RETRY_AFTER=$(jq -r '.retryAfter // 10' /tmp/bag_response.json)
echo "Rate limited. Retrying in ${RETRY_AFTER}s..." >&2
sleep "$RETRY_AFTER"
doneUsing rate limit headers proactively
Instead of waiting for a 429, you can read the X-RateLimit-Remaining header and throttle your requests before hitting the limit:
async function throttledFetch(url: string, options: RequestInit): Promise<Response> {
const response = await fetch(url, options);
const remaining = parseInt(response.headers.get("X-RateLimit-Remaining") ?? "100");
const reset = parseInt(response.headers.get("X-RateLimit-Reset") ?? "0");
if (remaining <= 5) {
const waitMs = Math.max(0, reset - Date.now()) + 100;
console.log(`Approaching rate limit (${remaining} remaining). Pausing ${waitMs}ms.`);
await new Promise((resolve) => setTimeout(resolve, waitMs));
}
return response;
}Best practices
- Respect
retryAfter. Always wait at least the number of seconds indicated before retrying. Retrying immediately will extend your rate limit window. - Add jitter. Random jitter (0–1 second) prevents multiple clients from retrying at the exact same moment.
- Cache GET responses. If you're polling for transaction status, cache the response and reduce your polling frequency as the transaction progresses.
- Use webhooks instead of polling. Instead of repeatedly calling
GET /api/checkout/session/{id}, register a webhook endpoint to receive real-time notifications when payment status changes. - Batch where possible. The list endpoints (
GET /api/payment-links,GET /api/transactions) return all resources in a single call. Avoid fetching resources one by one. - Monitor headers in production. Log
X-RateLimit-Remainingto catch rate limit pressure before it causes 429s.
Sandbox vs. production
Rate limits are disabled in local development (localhost). In the sandbox environment, the same limits apply as production to help you test your retry logic.