Rate limits
We use generous limits so integration work and normal traffic never feel them. Real abuse — credential stuffing, runaway loops, scraping — gets caught before it can hurt anyone else.
| Layer | Limit | Scope | Currently enforced |
|---|---|---|---|
| Pre-auth (IP) | 120 requests/second | Per source IP, per top-level path (/v1, /partner, /admin, /widget-api) | ✅ Yes |
| Per-credential | 30 requests/second | Each gspk_live_* credential | ✅ Yes |
| Global upstream | 10 requests/second | Aggregate across all GhostSwap partners (we absorb this) | ✅ Yes |
The pre-auth IP cap is sized for partners hosted on shared egress (Vercel, Cloudflare Workers, AWS NAT pools, Render) where many origin servers can leave through one source IP. Integration test suites that fire 50+ concurrent requests will almost never see a 429 from us. If you do, it's almost always a runaway loop.
RateLimit headers on every response
Every response includes RFC-9112 standard headers so your client can self-throttle before ever hitting a 429:
RateLimit-Limit: 120
RateLimit-Remaining: 87
RateLimit-Reset: 1
RateLimit-Policy: 120;w=1| Header | Meaning |
|---|---|
RateLimit-Limit | Cap for the current window |
RateLimit-Remaining | Requests you have left before throttling |
RateLimit-Reset | Seconds until the bucket resets |
RateLimit-Policy | <limit>;w=<window-seconds> |
If RateLimit-Remaining drops below 10, slow down. Don't wait for a 429 to react.
Recognizing 429s
If you do exceed the limit, the response is HTTP 429 with our standard envelope plus a Retry-After header:
HTTP/1.1 429 Too Many Requests
Retry-After: 1
RateLimit-Limit: 120
RateLimit-Remaining: 0
RateLimit-Reset: 1
Content-Type: application/json
X-Request-Id: 7b3c1e9f-...
{
"error": {
"type": "rate_limit_error",
"code": "rate_limited",
"message": "Rate limit exceeded",
"retry_after_ms": 1000
}
}Backing off
Read Retry-After (in seconds) and sleep at least that long before retrying. Use the same Idempotency-Key if you're retrying a POST /v1/swaps — that way the retry is safe even if the original request succeeded server-side and we return the cached response.
async function withBackoff(fn, attempts = 5) {
for (let i = 0; i < attempts; i++) {
const res = await fn();
if (res.status !== 429) return res;
const retryAfter = Number(res.headers.get('Retry-After')) || 1;
await new Promise((r) => setTimeout(r, retryAfter * 1000));
}
throw new Error('Rate limit retries exhausted');
}Avoiding rate limits in normal operation
You shouldn't have to think about rate limits in normal traffic, but a few habits make the API faster too:
- Cache
GET /v1/currenciesfor ~10 minutes. The list rarely changes minute-to-minute. - Quote once per user attempt. Don't re-quote on every keystroke; debounce by ~500 ms.
- Poll
GET /v1/swaps/:idevery 10 seconds while the user is watching, then back off to 30 seconds when the page is backgrounded or when polling from a server-side worker. Use a much slower cadence forhold. - Stop polling at terminal states.
finished,failed,refunded,overdue,expired— never poll these again.
What gets skipped
/healthand/readyare never rate-limited. Use them for uptime monitoring without worrying about quota.
Need higher limits?
Email support@ghostswap.io (or ping us on Telegram) with your credential id and the sustained RPS you need. We adjust on a case-by-case basis.
How GhostSwap absorbs the global cap
Our liquidity layer enforces a 10 RPS aggregate cap shared across all GhostSwap partners. We handle this for you:
- The catalog read endpoints are cached in-process:
GET /v1/currenciesfor ~60 seconds andGET /v1/pairsfor ~30 seconds, so partners don't see those 429s. - Quote and swap-creation calls hit the upstream 1:1 and serialize internally to fit under the cap.
If we ever need to surface a global upstream 429 to you, the error type is rate_limit_error with code upstream_rate_limited — distinct from your per-credential rate_limited.