Skip to main content
The v2 API enforces per-IP and per-customer rate limits to keep the platform healthy. When you exceed a limit you’ll get a 429 Too Many Requests response — back off and respect the Retry-After header.

429 response shape

When rate-limited, the API returns the standard error envelope with code: "RATE_LIMITED" and includes IETF-standard rate limit headers (draft-7):
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 60
RateLimit-Limit: 100
RateLimit-Remaining: 0
RateLimit-Reset: 60

{
  "data": null,
  "error": {
    "code": "RATE_LIMITED",
    "message": "Too many requests"
  }
}
HeaderMeaning
Retry-AfterSeconds to wait before retrying. Always honor this value.
RateLimit-LimitMax requests allowed in the current window
RateLimit-RemainingRequests remaining in the current window
RateLimit-ResetSeconds until the window resets
Read RateLimit-Remaining on every response — not just on 429s. Slow down preemptively when it’s low rather than waiting for a hard rejection.

General limits

ScopeWindowLimit
General API endpoints (per IP)1 minute100 requests
Auth endpoints (/auth/*)15 minutes40 requests (production)
Signup endpoints1 hour20 requests (production)
Password reset1 hour12 requests (production)
These are baseline limits. Per-customer quotas are tier-based and may differ from the per-IP defaults shown above. If you hit RATE_LIMITED consistently during normal operation, contact support@scope3.com for a higher-tier quota.
Several v2 endpoints expose long-running async work (discovery, ADCP comply checks, media-buy execution). Don’t tight-loop these — pick a cadence that matches the typical latency of the underlying operation.
EndpointRecommended cadenceNotes
GET /campaigns/:id/media-buy-statusevery 30s (min)Media-buy state changes are minute-scale; 30s is plenty
GET /discovery/:id/discover-productsevery 5–10s while pendingSlower if the agent set is large; back off to 15s after 1 minute of pending state
GET /storefront/readinessevery 60sADCP comply check has a 60s upstream timeout, so faster polling adds no signal
GET /campaigns/:id after POST /executeevery 15–30sUse webhooks if available — they remove the need to poll
POST /measurement-data/syncbatch up to API limit, throttleGroup rows up to the documented batch size; don’t fire one request per row
If you’re polling more aggressively than the cadence above and seeing RATE_LIMITED, the answer is almost always to slow down — not to ask for a higher quota.

Polling pattern

async function pollMediaBuyStatus(campaignId: string, signal: AbortSignal) {
  let delay = 30_000; // start at 30s

  while (!signal.aborted) {
    const res = await fetch(
      `https://api.agentic.scope3.com/api/buyer/campaigns/${campaignId}/media-buy-status`,
      { headers: { Authorization: `Bearer ${process.env.SCOPE3_API_KEY}` } },
    );

    if (res.status === 429) {
      // Honor server's Retry-After
      const retryAfter = Number(res.headers.get("Retry-After") ?? "60");
      await sleep(retryAfter * 1000);
      continue;
    }

    const { data } = await res.json();
    if (data.status === "active" || data.status === "failed") return data;

    // Mild backoff — never tighter than 30s
    await sleep(delay);
    delay = Math.min(delay * 1.5, 120_000);
  }
}

Backoff strategy

When you receive a 429 (or a transient INTERNAL_ERROR / SERVICE_UNAVAILABLE):
  1. Honor Retry-After. It’s the source of truth for when to retry.
  2. Add jitter. If many clients retry at the same moment you’ll thunder onto a recovering server. Add ±25% random jitter to the wait.
  3. Cap retries. After 3–5 attempts, surface the error to the user rather than retrying silently.
  4. Be careful retrying mutations. Retrying POST /campaigns after a 5xx may create duplicates — for creates, prefer to surface the error to the user rather than auto-retry.
async function withBackoff<T>(fn: () => Promise<Response>, max = 5): Promise<T> {
  for (let attempt = 0; attempt < max; attempt++) {
    const res = await fn();
    if (res.ok) return res.json().then((j) => j.data);

    if (res.status === 429 || res.status >= 500) {
      const retryAfter = Number(res.headers.get("Retry-After") ?? 2 ** attempt);
      const jitter = retryAfter * (0.75 + Math.random() * 0.5);
      await new Promise((r) => setTimeout(r, jitter * 1000));
      continue;
    }
    // 4xx other than 429 — don't retry
    throw await res.json();
  }
  throw new Error("max retries exceeded");
}

Webhooks vs polling

For long-running operations where state changes are sparse, prefer webhooks where they’re offered (campaigns, media buys, notifications). Webhook delivery removes the polling tax entirely. See Notifications for the supported event topics.