> ## Documentation Index
> Fetch the complete documentation index at: https://docs.interchange.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Rate Limits

> API rate limits, 429 responses, and recommended polling cadences for v2 endpoints

The v2 API enforces per-IP and per-account 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 theme={null}
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                               |

<Tip>
  Read `RateLimit-Remaining` on every response — not just on 429s. Slow down
  preemptively when it's low rather than waiting for a hard rejection.
</Tip>

## 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) |

<Note>
  These are baseline limits. Per-account quotas are tier-based and may differ
  from the per-IP defaults shown above. If you hit `RATE_LIMITED` consistently
  during normal operation, contact <a href="mailto:support@scope3.com">
  [support@scope3.com](mailto:support@scope3.com)</a> for a higher-tier quota.
</Note>

## 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 /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        |

<Warning>
  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.
</Warning>

### Polling pattern

The `media-buy-status` endpoint returns one entry per media buy under `data.media_buys[]` (each with `internal_status` and `adcp_status`). Poll until every media buy reaches a terminal state.

<CodeGroup>
  ```typescript TypeScript theme={null}
  const TERMINAL = new Set(["active", "completed", "failed", "rejected", "canceled"]);

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

    while (!signal.aborted) {
      const res = await fetch(
        `https://api.interchange.io/api/v2/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();
      const allTerminal = data.media_buys.length > 0 &&
        data.media_buys.every((mb: { internal_status: string }) => TERMINAL.has(mb.internal_status));
      if (allTerminal) return data;

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

  ```python Python theme={null}
  import time, requests

  TERMINAL = {"active", "completed", "failed", "rejected", "canceled"}

  def poll_media_buy_status(campaign_id):
      delay = 30
      while True:
          res = requests.get(
              f"https://api.interchange.io/api/v2/buyer/campaigns/{campaign_id}/media-buy-status",
              headers={"Authorization": f"Bearer {SCOPE3_API_KEY}"},
          )
          if res.status_code == 429:
              time.sleep(int(res.headers.get("Retry-After", "60")))
              continue
          data = res.json()["data"]
          if data["media_buys"] and all(mb["internal_status"] in TERMINAL for mb in data["media_buys"]):
              return data
          time.sleep(delay)
          delay = min(delay * 1.5, 120)
  ```
</CodeGroup>

## 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.

```typescript theme={null}
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](/v2/guides/notifications) for the supported event topics.
