Credits and rate limits
Credits measure what you use in a billing period; the rate limit caps how fast one key uses it. There is no daily cap. Every response says where you stand.
What each call costs
Calls are priced by what they are, not by how many rows they return: a page of 100 cards costs the same 2 credits as a page of one. Each endpoint’s page states its cost, and X-Credits-Cost on every 2xx response says what that call was charged.
| Operation | Notes | Credits |
|---|---|---|
| Card lookup by id | GET /v1/cards/{id}, any game | 1 |
| Current prices for one card | GET /v1/prices/{cardId}: every source, graded and per language | 1 |
| Search or filter | GET /v1/cards, or a set with its cards; per page of up to 100 | 2 |
| GraphQL request | One request, whatever it selects; pages cap at 100 cards | 2 |
| Price history or sales | One card's series, up to five years per call | 3 |
| Games and set lists | GET /v1/games, /v1/games/{slug}, /v1/sets | 1 |
| Image delivery | Never metered, on any plan | Free |
| Cached response (304) | Send If-None-Match and pay nothing | Free |
| Errors | A 4xx or 5xx is never billed | Free |
| Webhooks and price watches | Managing them and every delivery; watches are capped per plan instead | Free |
A GraphQL request is 2 credits however many root fields it has; a document that fails validation is not billed. Card recognition is metered in scans, not credits, on Card recognition.
Per-plan allowances
| Plan | Credits / period | Requests / minute | Overage |
|---|---|---|---|
| Starter | 10,000 | 60 req/min | $5 per 1,000 |
| Growth | 100,000 | 300 req/min | $1.50 per 1,000 |
| Scale | 500,000 | 1,000 req/min | $0.80 per 1,000 |
The period is your billing month, counted from the day the plan started, not the calendar month. A yearly plan draws twelve months’ credits over its year. The rate limit is per key, counted in clock minutes; every request with a valid key counts, including free ones and ones that fail. There is no daily cap: spend your credits at whatever pace you need. Past the allowance, requests keep succeeding as overage, drawn from your prepaid balance at a higher rate than the plan's own. If you are paying overage two months running, the next plan up is cheaper. Set a hard cap if you would rather stop at the allowance.
Response headers
On every metered response. The same headers are exposed to browser JavaScript through CORS.
X-Credits-Cost: 2 # what this call was charged
X-Credits-Limit: 150000 # the billing period's allowance
X-Credits-Used: 48213 # used this period, this call included
X-Credits-Remaining: 101787 # allowance left
X-Daily-Limit: unlimited # there is no daily cap; kept for older clients
X-RateLimit-Limit: 300 # requests per minute for this key
X-RateLimit-Remaining: 287
X-RateLimit-Reset: 1790190420 # unix seconds: the next minute boundary
X-Credits-Overage: 0 # only once past the allowance: overage credits this periodWhen a limit is reached
| What | Status | details.limit | Until |
|---|---|---|---|
| Requests per minute, per key | 429 rate_limited | rate_limit | The next minute. Retry-After: 60; X-RateLimit-Reset is the exact second. |
| Monthly allowance, with a hard cap set | 429 rate_limited | monthly_allowance | The next billing period, or until you raise the plan or turn the hard cap off. details.hardCap is true. |
| Monthly allowance, without a hard cap | 200 | — | Calls keep working and overage is drawn from your balance. If the balance cannot cover it: 402 balance_empty, with details.balanceCents and details.requiredCents, until you top up. |
The per-minute limit:
{
"error": {
"code": "rate_limited",
"message": "Rate limit of 300 requests per minute exceeded on the Growth plan.",
"details": { "limit": "rate_limit", "plans": "https://tcggraph.com/pricing" }
}
}Only rate_limit clears within a minute, so only it is worth retrying automatically:
async function call(url, init) {
for (let attempt = 0; ; attempt++) {
const response = await fetch(url, init);
if (response.status !== 429 || attempt === 3) return response;
const body = await response.clone().json();
// A hard monthly cap will not lift in seconds: stop, do not spin.
if (body.error.details?.limit !== "rate_limit") return response;
const wait = Number(response.headers.get("Retry-After") ?? 60);
await new Promise((resolve) => setTimeout(resolve, wait * 1000));
}
}What is never billed
- Card images from the CDN, on every plan.
- A
304: send theETagback asIf-None-Matchand an unchanged response costs nothing. - Any error, 4xx or 5xx, including the limit responses above.
- Webhook and price-watch management, and every webhook delivery.