Skip to main content

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:

CredentialAllowanceWindow
None — unauthenticated request100 requests60 minutes
OAuth 2.0 access token or Personal Access Token2,000 requests60 minutes
Subscriber API key — trial10,000 requests24 hours
Subscriber API key — Starter1,000 requests24 hours
Subscriber API key — Pro10,000 requests24 hours
Enterprise / customBy agreementBy 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.

Authentication endpoints have their own limits

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​

HeaderDescriptionExample
X-RateLimit-LimitMaximum requests allowed in the current window1000
X-RateLimit-RemainingRequests remaining in the current window847
X-RateLimit-ResetUnix timestamp at which the current window resets1757308800
Retry-AfterSeconds to wait before retrying; sent on a 429 only900
Derive your window from X-RateLimit-Reset

The 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.

Handle 402 and 429 differently

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-Reset on 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-After on a 429 first, with exponential backoff and jitter as the fallback
  • Derive your window from X-RateLimit-Reset rather 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:

  1. Contact Support: Email [email protected]
  2. Provide Details:
    • Your API key ID or OAuth client ID
    • Current usage patterns
    • Expected future needs
    • Application description and business case
  3. 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​

  1. Log Rate Limit Headers: Track your usage patterns
  2. Set Up Alerts: Warn when approaching limits
  3. Monitor Response Times: Detect performance issues
  4. Track Error Rates: Identify problematic endpoints
  5. 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​

CredentialAllowanceWindow
None — unauthenticated request100 requests60 minutes
OAuth 2.0 access token or Personal Access Token2,000 requests60 minutes
Subscriber API key — trial10,000 requests24 hours
Subscriber API key — Starter1,000 requests24 hours
Subscriber API key — Pro10,000 requests24 hours
Enterprise / customBy agreementBy 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.