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.

LayerLimitScopeCurrently enforced
Pre-auth (IP)120 requests/secondPer source IP, per top-level path (/v1, /partner, /admin, /widget-api)✅ Yes
Per-credential30 requests/secondEach gspk_live_* credential✅ Yes
Global upstream10 requests/secondAggregate 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
HeaderMeaning
RateLimit-LimitCap for the current window
RateLimit-RemainingRequests you have left before throttling
RateLimit-ResetSeconds 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/currencies for ~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/:id every 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 for hold.
  • Stop polling at terminal states. finished, failed, refunded, overdue, expired — never poll these again.

What gets skipped

  • /health and /ready are 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/currencies for ~60 seconds and GET /v1/pairs for ~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.