Rate limits and quotas
Handle CompSniper per-minute rate limits and monthly quota exhaustion without retry storms.
Two independent limits protect every account.
The Shipping Rates API is a separate product with its own quota and per-minute limit. Its usage does not change the sold-comps limits described below. See Shipping Rates API.
Per-minute rate limit
The Basic plan allows 30 requests per minute. Starter, Growth, and Scale allow 60 requests per minute. High-volume plans allow 120 requests per minute. Paid subscriptions and accounts using purchased credits also receive priority in the live-search queue during busy periods. When a per-minute limit is exceeded, the API returns:
{
"error": "Per-minute rate limit exceeded.",
"code": "rate_limited",
"retry_after": 12
}Wait for Retry-After, add a small random jitter, and retry with a strict maximum attempt count.
Sold history searches
/v1/research/sold also has its own cap of 20 searches per minute per account,
on top of the plan limit above. Over it, the API answers 429 with code history_rate_limited and a
Retry-After header. Every history response includes X-History-RateLimit-Limit and
X-History-RateLimit-Remaining.
Carryover, monthly quota, and purchased credits
When an existing subscriber pays the full price to upgrade, the new billing cycle starts immediately
with the full new allowance. Unused monthly requests from the previous plan carry over until the next
renewal and are used first. CompSniper then uses the new monthly allowance. If both are exhausted and the
account has purchased credits, each successful request automatically uses one credit. Credits cost $5 per 1,000,
can be purchased in increments of 100, and never expire. No header is needed for this. Optionally, send
X-Credit-Source: credits to spend purchased credits before your monthly allowance. When your credits run out,
those requests automatically fall back to your monthly allowance instead of failing.
When both selected billing sources are empty, the API returns:
{
"error": "Free plan quota exhausted. Upgrade your CompSniper plan to continue now, or wait for your monthly reset.",
"code": "quota_exceeded",
"plan": "basic",
"quota": 100,
"used": 100,
"remaining": 0,
"rollover_balance": 0,
"reset_at": "2026-09-18T05:30:36.000Z",
"upgrade_url": "https://compsniper.com/dashboard/subscription",
"credit_balance": 0,
"purchase_credits_url": "https://compsniper.com/dashboard/subscription#credits"
}Do not retry quota_exceeded. Stop the job, show purchase_credits_url or upgrade_url, or wait until
reset_at.
Sending many requests at once
The synchronous GET /v1/scrape endpoint is built for interactive, one-keyword-at-a-time calls. Behind
it we fetch live data straight from eBay, and eBay's systems do not like being hammered: a sudden burst of
concurrent requests looks like abuse to them and gets rate-limited or challenged at the source. When that
happens you'll see 503 server_busy (we're briefly holding traffic back to stay under eBay's limits) or
502 upstream_blocked (eBay challenged that request). These are transient, but retrying them immediately
just adds more load and makes it worse, not better.
So if you need to run many keywords or send a high-volume burst, do not fan them out at /v1/scrape.
Submit them as an async job instead. The queue paces the work at a rate eBay accepts, spreads it safely
across the pool, and returns every result, so a large workload completes reliably without the burst errors:
- Scrape jobs (
POST /v1/scrape/jobs): send a list of keyword queries, get ajobId, then pollGET /v1/scrape/jobs/{jobId}for results as each one finishes. Submit your whole batch as one job (up to 100 queries), and when it's done submit the next. This is the endpoint for any bulk or bursty workload. - Card Batch (
/docs/card-batch): same engine with structured card fields (player, year, set, grade) for trading-card sellers. - Max Mode (
/docs/max-mode): deep pagination of a single keyword, with CSV export.
Rule of thumb: a handful of interactive calls is fine on /v1/scrape. Anything bulk or bursty belongs on
/v1/scrape/jobs, because eBay punishes bursts and the job queue is what keeps you under its radar.
Response headers
| Header | Meaning |
|---|---|
X-RateLimit-Limit | Requests allowed in the current minute |
X-RateLimit-Remaining | Requests left in the current minute |
X-RateLimit-Reset | Unix timestamp when the minute window resets |
X-Usage-Limit | Monthly plan allowance |
X-Usage-Remaining | Monthly requests remaining |
X-Credit-Source | Billing source used: rollover, subscription, credits, or none |
X-Rollover-Balance | Temporary carried-over requests remaining |
X-Credit-Balance | Purchased credits remaining |
Retry-After | Seconds before a temporary retry |
Shipping responses use X-Shipping-Plan, X-Shipping-Usage-Limit,
X-Shipping-Usage-Used, X-Shipping-Usage-Remaining, and X-Shipping-Usage-Reset so the two products
cannot be confused.
Bounded retry example
async function searchWithRetries(url, apiKey) {
for (let attempt = 0; attempt < 5; attempt += 1) {
const response = await fetch(url, {
headers: { Authorization: `Bearer ${apiKey}` },
});
const body = await response.json();
if (response.ok) return body;
if (response.status === 429 && body.code === "quota_exceeded") {
throw new Error(`Quota exhausted. Buy credits: ${body.purchase_credits_url}`);
}
const temporary =
(response.status === 429 && body.code === "rate_limited") ||
[500, 502, 503].includes(response.status);
if (!temporary || attempt === 4) throw new Error(body.error);
const delay = Number(response.headers.get("Retry-After") || body.retry_after || 2 ** attempt);
await new Promise((resolve) => setTimeout(resolve, (delay + Math.random() * 0.5) * 1000));
}
}