API Rate Limits
Comprehensive guide to Trading Card API rate limits, response headers, and best practices for efficient API usage.
📊 Quick Overview
Your rate limit depends on which credential you authenticate with. Subscribers authenticate with the API key issued with their plan, and that key carries their plan's daily allowance:
| Credential | Allowance | Window |
|---|---|---|
| None — unauthenticated request | 100 requests | 60 minutes |
| OAuth 2.0 access token or Personal Access Token | 2,000 requests | 60 minutes |
| Subscriber API key — trial | 10,000 requests | 24 hours |
| Subscriber API key — Starter | 1,000 requests | 24 hours |
| Subscriber API key — Pro | 10,000 requests | 24 hours |
| Enterprise / custom | By agreement | By agreement |
Plan pricing is on our pricing page.
🚦 Rate Limiting Details
Which credential am I using?
Three kinds of credential can call the API, and they are limited differently.
Subscriber API key. Every subscription — including the 14-day trial — issues
an opaque API key prefixed tc_live_ (or tc_test_ against staging). The
plaintext key is shown once, when it is issued or regenerated — store it in a
secret manager at that moment. It cannot be retrieved afterwards:
GET /v1/user/api-key returns masked metadata only, and your account portal
shows the same masked view. If you lose the key, regenerate it. This is the
credential your plan's allowance is attached to: when you start a trial,
subscribe, change plan, or regenerate the key, the new allowance is written onto
the key and applies to your very next request.
OAuth 2.0 access tokens and Personal Access Tokens. Both are covered by the authentication guide, and both are limited separately from your API key, at the standard authenticated allowance of 2,000 requests per hour. Use them for the integration patterns that guide describes — they are not a way to serve plan-level traffic, and spreading one workload across credentials to exceed the volume your plan provides falls outside our fair use guidelines.
No credential. Unauthenticated requests are limited to 100 per hour, counted per client IP address.
Request Rate Limits
Each credential gets a single allowance covering all general API traffic:
- Unauthenticated: 100 requests per 60 minutes, counted per IP address
- OAuth token or Personal Access Token: 2,000 requests per 60 minutes, counted per token
- Subscriber API key: your plan's allowance per 24 hours, counted per key
- Enterprise / custom limits: set directly on your key or OAuth client (see Need Higher Limits? below)
There is no separate per-minute burst limit and no monthly quota — the allowance above is the only budget that applies to general API endpoints. You are free to spend it as quickly or as slowly as you like.
OAuth token endpoints and account signup carry their own, tighter limits that are tracked separately from your general API allowance. Token generation allows 10 requests per minute, token refresh 30 per 5 minutes, authorization 50 per 10 minutes, and client management 5 per hour. A 429 on /oauth/token therefore does not mean you have exhausted your /v1/cards budget, and vice versa. Retry logic should treat the two independently. These endpoints report their own usage in X-OAuth-RateLimit-* headers — -Limit, -Remaining and -Reset, also named in Access-Control-Expose-Headers. That family is counted separately from the general X-RateLimit-* triple, so a usage dashboard must not mix the two.
Rate Limit Enforcement
Rate limits are enforced using a fixed window:
- The window opens on your first counted request and runs for its full length — 60 minutes or 24 hours, depending on your credential
- Every request inside that window shares one reset boundary; the counter resets when the window expires, not continuously
- The 24-hour plan window is not a calendar-day quota. It does not reset at midnight, in any timezone — it resets 24 hours after the request that opened it
📋 Rate Limit Headers
Every throttled response — successful ones included, not just a 429 — reports
your current usage in three headers:
HTTP/1.1 200 OK
Content-Type: application/vnd.api+json
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 847
X-RateLimit-Reset: 1757308800
You no longer have to trip a limit to learn your window.
Header Descriptions
| Header | Description | Example |
|---|---|---|
X-RateLimit-Limit | Maximum requests allowed in the current window | 1000 |
X-RateLimit-Remaining | Requests remaining in the current window | 847 |
X-RateLimit-Reset | Unix timestamp at which the current window resets | 1757308800 |
Retry-After | Seconds to wait before retrying; sent on a 429 only | 900 |
X-RateLimit-ResetThe window length is not published as a header — derive it from
X-RateLimit-Reset rather than assuming a value. The quota and the window both
vary by credential (anonymous, OAuth/PAT, per-client override, internal pool), so
reading the timestamp is what makes the response self-describing, instead of
dependent on published copy staying in sync.
All of these headers are named in Access-Control-Expose-Headers, so
browser-side JavaScript can read them cross-origin — a usage dashboard in the
browser needs no server-side proxy.
⚠️ Rate Limit Responses
When you exceed your allowance, the API returns a 429 Too Many Requests. The
response carries the full X-RateLimit-* triple plus Retry-After:
HTTP/1.1 429 Too Many Requests
Content-Type: application/vnd.api+json
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1757308800
Retry-After: 900
{
"errors": [
{
"status": "429",
"code": "TOO-MANY-REQUESTS",
"title": "Too many requests.",
"detail": "You have sent too many requests in a given amount of time. Please retry after a short delay."
}
],
"meta": {
"request_id": "01234567-89ab-cdef-0123-456789abcdef"
}
}
Include the meta.request_id value when contacting support about a specific response.
Retry-After is the server's own answer to "how long do I wait", so prefer it
over a computed backoff whenever it is present. See
Implement Exponential Backoff for the
fallback to use when it is not, or when its delay exceeds your retry budget.
💳 Payment Required Responses
A 429 is not the only way access can stop. If your subscription is not in good
standing, the data endpoints return 402 Payment Required instead:
{
"errors": [
{
"status": "402",
"code": "SUBSCRIPTION_REQUIRED",
"title": "Payment Required",
"detail": "An active subscription is required to access this endpoint. Subscribe or update your billing to restore access; your API key is preserved and access resumes automatically once your subscription is active."
}
]
}
When you get a 402. Your subscription is anything other than active or
trialing — most commonly an expired trial, but also a failed payment
(past_due), a cancellation, or a paused subscription. This is a distinct
condition from rate limiting: waiting will not clear it, and retrying will
return 402 every time until the billing problem is resolved.
Your API key survives. Nothing is revoked and nothing needs reissuing. Once the subscription is active again, the same key resumes working on the next request.
What stays reachable. The 402 applies to the /v1 and /v2 data
endpoints. Your billing, subscription, account, and API-key endpoints are
deliberately left reachable so you can fix the problem with the credentials you
already have.
Treat 429 as retryable with backoff, and 402 as terminal until a human
acts — surface it to your operators or your user rather than retrying it. Code
that lumps all 4xx responses into one retry path will spin against a 402
indefinitely.
💻 Code Examples
JavaScript (Node.js)
const axios = require('axios');
class TradingCardAPI {
constructor(apiKey) {
this.apiKey = apiKey;
this.baseURL = 'https://api.tradingcardapi.com';
}
async makeRequest(endpoint, options = {}, attempt = 0) {
try {
const response = await axios({
url: `${this.baseURL}${endpoint}`,
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Accept': 'application/vnd.api+json',
...options.headers
},
...options
});
// Log rate limit status
this.logRateLimit(response.headers);
return response.data;
} catch (error) {
// 402 is terminal — the subscription needs attention, so retrying
// will never clear it. Surface it instead of backing off.
if (error.response?.status === 402) {
throw new Error(
`Subscription inactive: ${error.response.data?.errors?.[0]?.detail ?? 'payment required'}`
);
}
if (error.response?.status === 429) {
return this.handleRateLimit(endpoint, options, attempt, error.response.headers);
}
throw error;
}
}
logRateLimit(headers) {
// Every throttled response carries these, successful ones included.
const limit = headers['x-ratelimit-limit'];
const remaining = headers['x-ratelimit-remaining'];
const reset = parseInt(headers['x-ratelimit-reset'], 10);
if (limit === undefined || remaining === undefined) return;
// Guard the timestamp: an absent or unparseable header yields NaN, and
// `new Date(NaN).toISOString()` throws RangeError: Invalid time value.
const resetsAt = Number.isFinite(reset)
? new Date(reset * 1000).toISOString()
: 'an unreported time';
console.log(
`Rate Limit: ${remaining}/${limit} requests remaining, window resets at ` +
`${resetsAt}`
);
}
async handleRateLimit(endpoint, options, attempt, headers = {}, maxAttempts = 5) {
if (attempt >= maxAttempts) {
throw new Error(`Rate limited: giving up after ${maxAttempts} attempts`);
}
// Prefer the server's own Retry-After; fall back to capped exponential
// backoff with jitter when it is absent.
const maxWaitMs = 60000;
const retryAfter = parseInt(headers['retry-after'], 10);
const delay = Number.isFinite(retryAfter)
? retryAfter * 1000
: Math.min(1000 * 2 ** attempt, maxWaitMs) + Math.random() * 1000;
// Give up rather than sleep past your retry budget — on a 24-hour window
// Retry-After can be hours.
if (delay > maxWaitMs) {
throw new Error(`Rate limited: window reopens in ${Math.round(delay / 1000)}s`);
}
console.warn(`Rate limited. Retrying in ${Math.round(delay / 1000)}s...`);
await new Promise(resolve => setTimeout(resolve, delay));
return this.makeRequest(endpoint, options, attempt + 1);
}
}
// Usage
const api = new TradingCardAPI('your-api-key');
// Get cards with automatic rate limit handling
const cards = await api.makeRequest('/cards', {
params: { 'page[limit]': 25 }
});
Python
import random
import requests
import time
class SubscriptionInactive(Exception):
"""Raised on a 402 — terminal until the billing problem is fixed."""
class TradingCardAPI:
def __init__(self, api_key):
self.api_key = api_key
self.base_url = 'https://api.tradingcardapi.com'
self.session = requests.Session()
self.session.headers.update({
'Authorization': f'Bearer {api_key}',
'Accept': 'application/vnd.api+json'
})
def make_request(self, endpoint, attempt=0, **kwargs):
response = self.session.get(f'{self.base_url}{endpoint}', **kwargs)
# Log rate limit status
self._log_rate_limit(response.headers)
# 402 is terminal — retrying will never clear it.
if response.status_code == 402:
detail = response.json()['errors'][0]['detail']
raise SubscriptionInactive(detail)
if response.status_code == 429:
return self._handle_rate_limit(endpoint, attempt, response.headers, **kwargs)
response.raise_for_status()
return response.json()
def _log_rate_limit(self, headers):
# Every throttled response carries these, successful ones included.
limit = headers.get('X-RateLimit-Limit')
remaining = headers.get('X-RateLimit-Remaining')
reset = headers.get('X-RateLimit-Reset')
if limit and remaining:
print(f"Rate Limit: {remaining}/{limit} requests remaining")
# Derive the window from the reset timestamp rather than assuming one.
if reset:
resets_at = time.strftime('%H:%M:%S', time.localtime(int(reset)))
print(f"Window resets at {resets_at}")
def _handle_rate_limit(self, endpoint, attempt, headers=None, max_attempts=5, **kwargs):
if attempt >= max_attempts:
raise RuntimeError(f"Rate limited: giving up after {max_attempts} attempts")
# Prefer the server's own Retry-After; fall back to capped exponential
# backoff with jitter when it is absent.
max_wait = 60
retry_after = (headers or {}).get('Retry-After')
if retry_after:
delay = float(retry_after)
else:
delay = min(2 ** attempt, max_wait) + random.random()
# Give up rather than sleep past your retry budget — on a 24-hour
# window Retry-After can be hours.
if delay > max_wait:
raise RuntimeError(f"Rate limited: window reopens in {delay:.0f}s")
print(f"Rate limited. Retrying in {delay:.1f}s...")
time.sleep(delay)
return self.make_request(endpoint, attempt=attempt + 1, **kwargs)
# Usage
api = TradingCardAPI('your-api-key')
# Get cards with automatic rate limit handling
cards = api.make_request('/cards', params={'page[limit]': 25})
PHP
<?php
class TradingCardAPI
{
private $apiKey;
private $baseUrl = 'https://api.tradingcardapi.com';
public function __construct($apiKey)
{
$this->apiKey = $apiKey;
}
public function makeRequest($endpoint, $params = [], $attempt = 0)
{
$url = $this->baseUrl . $endpoint;
if (!empty($params)) {
$url .= '?' . http_build_query($params);
}
$context = stream_context_create([
'http' => [
'method' => 'GET',
'header' => [
'Authorization: Bearer ' . $this->apiKey,
'Accept: application/vnd.api+json'
],
// Return the body on 4xx instead of false, so the error
// payload is readable.
'ignore_errors' => true
]
]);
$response = file_get_contents($url, false, $context);
if ($response === false) {
throw new Exception('API request failed');
}
$status = $this->statusCode($http_response_header);
// 402 is terminal — retrying will never clear it.
if ($status === 402) {
$error = json_decode($response, true)['errors'][0]['detail'] ?? 'payment required';
throw new Exception("Subscription inactive: {$error}");
}
if ($status === 429) {
return $this->handleRateLimit($endpoint, $params, $attempt, $http_response_header);
}
$this->logRateLimit($http_response_header);
return json_decode($response, true);
}
private function statusCode($headers)
{
preg_match('{HTTP/\S+ (\d{3})}', $headers[0], $match);
return (int) ($match[1] ?? 0);
}
private function logRateLimit($headers)
{
// Every throttled response carries these, successful ones included.
$rateHeaders = [];
foreach ($headers as $header) {
if (strpos($header, 'X-RateLimit-') === 0) {
list($key, $value) = explode(': ', $header, 2);
$rateHeaders[$key] = $value;
}
}
if (isset($rateHeaders['X-RateLimit-Remaining'], $rateHeaders['X-RateLimit-Limit'])) {
$remaining = $rateHeaders['X-RateLimit-Remaining'];
$limit = $rateHeaders['X-RateLimit-Limit'];
echo "Rate Limit: {$remaining}/{$limit} requests remaining\n";
}
// Derive the window from the reset timestamp rather than assuming one.
if (isset($rateHeaders['X-RateLimit-Reset'])) {
$resetsAt = date('H:i:s', (int) $rateHeaders['X-RateLimit-Reset']);
echo "Window resets at {$resetsAt}\n";
}
}
private function handleRateLimit($endpoint, $params, $attempt, $headers = [], $maxAttempts = 5)
{
if ($attempt >= $maxAttempts) {
throw new Exception("Rate limited: giving up after {$maxAttempts} attempts");
}
// Prefer the server's own Retry-After; fall back to capped exponential
// backoff with jitter when it is absent.
$retryAfter = null;
foreach ($headers as $header) {
if (stripos($header, 'Retry-After:') === 0) {
$retryAfter = (float) trim(substr($header, strlen('Retry-After:')));
}
}
$maxWait = 60;
$delay = $retryAfter ?? (min(2 ** $attempt, $maxWait) + (mt_rand(0, 1000) / 1000));
// Give up rather than sleep past your retry budget — on a 24-hour
// window Retry-After can be hours.
if ($delay > $maxWait) {
throw new Exception(sprintf('Rate limited: window reopens in %ds', $delay));
}
printf("Rate limited. Retrying in %.1fs...\n", $delay);
usleep((int) ($delay * 1_000_000));
return $this->makeRequest($endpoint, $params, $attempt + 1);
}
}
// Usage
$api = new TradingCardAPI('your-api-key');
// Get cards with automatic rate limit handling
$cards = $api->makeRequest('/cards', ['page[limit]' => 25]);
?>
🎯 Best Practices
1. Monitor Rate Limit Headers
All three headers arrive on your successful responses, which makes them an
early warning rather than an after-the-fact one. X-RateLimit-Reset tells you
when the pressure lifts, so you can wait out the remainder of a window instead of
guessing at it. Watch them and slow down before you are throttled:
function checkRateLimit(response) {
const remaining = parseInt(response.headers['x-ratelimit-remaining'], 10);
const limit = parseInt(response.headers['x-ratelimit-limit'], 10);
const reset = parseInt(response.headers['x-ratelimit-reset'], 10);
// Absent or unparseable headers parse to NaN — there is nothing to compare.
if (!Number.isFinite(remaining) || !Number.isFinite(limit)) return;
if (remaining < limit * 0.1) { // Less than 10% remaining
if (!Number.isFinite(reset)) {
console.warn('Approaching rate limit.');
return;
}
const seconds = Math.max(0, reset - Math.floor(Date.now() / 1000));
console.warn(`Approaching rate limit. Window resets in ${seconds}s.`);
}
}
2. Implement Exponential Backoff
Prefer Retry-After when the 429 carries one — it is the server's own answer.
Fall back to capped exponential backoff when it is absent, or when its delay
exceeds your retry budget. Add jitter so multiple workers do not retry in
lockstep:
async function exponentialBackoff(attempt, maxAttempts = 5) {
if (attempt >= maxAttempts) {
throw new Error('Max retry attempts exceeded');
}
const base = Math.min(1000 * Math.pow(2, attempt), 60000); // Max 60 seconds
const delay = base + Math.random() * 1000; // Jitter
await new Promise(resolve => setTimeout(resolve, delay));
}
Bear in mind how long a window is when you size maxAttempts. On a 24-hour plan
window, exhausting your allowance means backoff will not recover you within any
reasonable retry budget — fail the job and resume later rather than sleeping
against a window that reopens tomorrow.
3. Cache Responses Intelligently
Reduce API calls by caching responses when appropriate:
class APICache {
constructor(ttl = 300000) { // 5 minutes default
this.cache = new Map();
this.ttl = ttl;
}
get(key) {
const item = this.cache.get(key);
if (!item) return null;
if (Date.now() > item.expires) {
this.cache.delete(key);
return null;
}
return item.data;
}
set(key, data) {
this.cache.set(key, {
data,
expires: Date.now() + this.ttl
});
}
}
4. Batch Requests When Possible
Use includes to fetch related data in single requests:
// ❌ Multiple requests
const card = await api.get('/cards/123');
const set = await api.get(`/sets/${card.relationships.set.data.id}`);
const player = await api.get(`/players/${card.relationships.oncard.data[0].id}`);
// ✅ Single request with includes
const cardWithRelations = await api.get('/cards/123?include=set,oncard');
5. Use Appropriate Page Sizes
Balance between fewer requests and manageable response sizes:
// ❌ Too many small requests
for (let page = 1; page <= 100; page++) {
await api.get(`/cards?page[number]=${page}&page[limit]=10`);
}
// ✅ Fewer larger requests
for (let page = 1; page <= 10; page++) {
await api.get(`/cards?page[number]=${page}&page[limit]=100`);
}
📜 Usage Policies
Fair Use Guidelines
These guidelines apply to every caller, on every plan.
Acceptable Use
- Building trading card applications and tools
- Personal collection management
- Educational and research purposes
- Commercial applications operating within their allowance
Prohibited Uses
- Excessive automated scraping without proper rate limiting
- Reselling raw API data without value addition
- Bypassing rate limits through multiple accounts
- Using the API for spam or malicious purposes
Commercial Use
Commercial and production applications are welcome on any plan. If your application needs a higher sustained volume, an SLA, or dedicated support, see our pricing page for available plans, or request a custom limit.
🔧 Troubleshooting
Common Rate Limiting Issues
Issue: Constant 429 Errors
// Problem: retrying on a fixed delay, ignoring what the server told you
setTimeout(() => retry(), 1000); // ❌ hammers the window
// Solution: honour Retry-After when present, else exponential backoff with
// jitter and a retry ceiling
const retryAfter = parseInt(response.headers['retry-after'], 10);
const delay = Number.isFinite(retryAfter)
? retryAfter * 1000
: Math.min(1000 * Math.pow(2, attempt), 60000) + Math.random() * 1000;
setTimeout(() => retry(attempt + 1), delay); // ✅
Issue: Exhausting Your Allowance Too Quickly
// Problem: Sending requests too quickly
Promise.all(urls.map(url => api.get(url))); // ❌ All at once
// Solution: Control concurrency
async function limitConcurrency(urls, limit = 5) {
const results = [];
for (let i = 0; i < urls.length; i += limit) {
const batch = urls.slice(i, i + limit);
const batchResults = await Promise.all(batch.map(url => api.get(url)));
results.push(...batchResults);
// Small delay between batches
if (i + limit < urls.length) {
await new Promise(resolve => setTimeout(resolve, 100));
}
}
return results;
}
Issue: Unexpected Rate Limit Resets
- Rate limits use a fixed window, not a rolling average
- The window starts on your first counted request, so it rarely aligns with the top of the hour — and a 24-hour plan window never aligns with midnight
X-RateLimit-Reseton every throttled response tells you exactly when the current window ends — read it rather than tracking when your own window opened
Issue: A 402, not a 429
- Retrying will not help — see Payment Required Responses
- Most often an expired trial or a failed payment; your API key is still valid
Error Handling Checklist
- Check for 429 status codes in all API calls
- Handle 402 separately — surface it, do not retry it
- Read
Retry-Afteron a429first, with exponential backoff and jitter as the fallback - Derive your window from
X-RateLimit-Resetrather than assuming a published value - Log rate limit headers from successful responses for monitoring
- Set up alerts for approaching rate limits
- Cache responses to reduce API calls
- Use batch operations when available
📞 Need Higher Limits?
Upgrade your plan
If you are on Starter and hitting 1,000 requests per 24 hours, Pro raises that to 10,000. See the pricing page — a plan change takes effect on your existing key immediately, with no code change on your side.
Requesting a custom limit
Above Pro, we set a custom limit directly on your credentials. If 10,000 requests per 24 hours is not enough:
- Contact Support: Email [email protected]
- Provide Details:
- Your API key ID or OAuth client ID
- Current usage patterns
- Expected future needs
- Application description and business case
- We Adjust Your Credentials: approved accounts get a higher allowance applied to their existing key or client — again, no code change required
📊 Monitoring Your Usage
Usage Tracking Tips
- Log Rate Limit Headers: Track your usage patterns
- Set Up Alerts: Warn when approaching limits
- Monitor Response Times: Detect performance issues
- Track Error Rates: Identify problematic endpoints
- Run It in the Browser: the rate-limit headers are named in
Access-Control-Expose-Headers, so this monitor works unchanged in browser-side JavaScript — no server-side proxy required
Sample Monitoring Code
class UsageMonitor {
constructor() {
this.requests = [];
this.errors = [];
}
recordRequest(endpoint, headers) {
this.requests.push({
endpoint,
timestamp: Date.now(),
remaining: parseInt(headers['x-ratelimit-remaining']),
limit: parseInt(headers['x-ratelimit-limit']),
reset: parseInt(headers['x-ratelimit-reset'])
});
}
recordError(endpoint, status, error) {
this.errors.push({
endpoint,
status,
error: error.message,
timestamp: Date.now()
});
}
getUsageStats() {
const now = Date.now();
const lastHour = now - 3600000;
const recentRequests = this.requests.filter(r => r.timestamp > lastHour);
const recentErrors = this.errors.filter(e => e.timestamp > lastHour);
return {
requestsLastHour: recentRequests.length,
errorsLastHour: recentErrors.length,
errorRate: recentErrors.length / recentRequests.length,
mostRecentLimit: recentRequests[recentRequests.length - 1]?.remaining
};
}
}
🔄 Rate Limit Changes
Notification Process
We'll notify users of rate limit changes through:
- Email announcements to registered users
- API changelog documentation updates
- GitHub releases for SDK updates
- Blog posts for major changes
Backwards Compatibility
- Rate limit increases: Applied immediately
- Rate limit decreases: 30-day advance notice
- A plan change you make yourself: applied to your existing key immediately
- Emergency changes: Immediate with explanation
Quick Reference
| Credential | Allowance | Window |
|---|---|---|
| None — unauthenticated request | 100 requests | 60 minutes |
| OAuth 2.0 access token or Personal Access Token | 2,000 requests | 60 minutes |
| Subscriber API key — trial | 10,000 requests | 24 hours |
| Subscriber API key — Starter | 1,000 requests | 24 hours |
| Subscriber API key — Pro | 10,000 requests | 24 hours |
| Enterprise / custom | By agreement | By agreement |
X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset on every
throttled response, successful ones included; Retry-After on a 429 only. All
are named in Access-Control-Expose-Headers. 429 means back off; 402 means
check your subscription.
Need help? Contact [email protected] or check our support documentation.