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"
}
}
| Header | Meaning |
|---|
Retry-After | Seconds to wait before retrying. Always honor this value. |
RateLimit-Limit | Max requests allowed in the current window |
RateLimit-Remaining | Requests remaining in the current window |
RateLimit-Reset | Seconds 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
| Scope | Window | Limit |
|---|
| General API endpoints (per IP) | 1 minute | 100 requests |
Auth endpoints (/auth/*) | 15 minutes | 40 requests (production) |
| Signup endpoints | 1 hour | 20 requests (production) |
| Password reset | 1 hour | 12 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.
Recommended polling cadences
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.
| Endpoint | Recommended cadence | Notes |
|---|
GET /campaigns/:id/media-buy-status | every 30s (min) | Media-buy state changes are minute-scale; 30s is plenty |
GET /discovery/:id/discover-products | every 5–10s while pending | Slower if the agent set is large; back off to 15s after 1 minute of pending state |
GET /storefront/readiness | every 60s | ADCP comply check has a 60s upstream timeout, so faster polling adds no signal |
GET /campaigns/:id after POST /execute | every 15–30s | Use webhooks if available — they remove the need to poll |
POST /measurement-data/sync | batch up to API limit, throttle | Group 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):
- Honor
Retry-After. It’s the source of truth for when to retry.
- Add jitter. If many clients retry at the same moment you’ll thunder onto a recovering server. Add ±25% random jitter to the wait.
- Cap retries. After 3–5 attempts, surface the error to the user rather than retrying silently.
- 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.