Skip to content

Rate Limiting

The API enforces rate limits to protect platform stability. Two limits run on every request and both must pass: a global one and, on some endpoints, a stricter per-endpoint one.

What Shares a Bucket

Which requests count against the same counter depends on how you authenticate.

Personal access tokens

Requests authenticated with a personal access token are counted per Beyond user. That budget is not additive: every PAT you create for the same Beyond user draws on the same counter. Issuing a second token to run a second job in parallel does not buy more capacity — it splits the capacity you already had. Pace the work instead.

Partner applications

Requests from a partner integration are counted per OAuth2 application, across every user you act on behalf of. Adding users to your integration does not raise the limit, so a partner's effective throughput never multiplies with their user count — budget for your busiest moment across the whole tenant base, not per user.

How It Works

Application-Level Rate Limit

Every application has a configurable rate limit, applied across all endpoints: any request counts toward it regardless of what it calls. For personal access tokens this bucket is per user, as described above.

View-Level Rate Limit

Some endpoints enforce a stricter per-endpoint limit on top of the global one, so heavy traffic on one endpoint cannot crowd out the rest of the API. This counter is scoped the same way as the global one — per user for personal access tokens, per application for partners — but is separate for each endpoint. Both limits must pass for a request to succeed, and the X-RateLimit-* headers describe whichever of the two is closest to being exhausted.

Which limit did you hit?

Compare X-RateLimit-Limit on the 429 against your application-level limit. A lower value means an endpoint-specific limit stopped you, and slowing down calls to that endpoint is enough — the rest of your integration can keep running at full speed.

Response Headers

Responses from an endpoint include rate-limit headers reflecting the most constrained active limit:

Header Description
X-RateLimit-Limit Maximum requests allowed in the current window
X-RateLimit-Remaining Requests remaining in the current window
X-RateLimit-Reset Seconds until the window resets

Not every response carries them

Requests turned away before they reach an endpoint — 401 unauthorized and 403 insufficient scope among them — come back without any X-RateLimit-* headers, as do applications with no rate limit configured.

Check a header is present before reading it. A missing header reads back as null or an empty string in most clients, and numeric parsing quietly turns that into 0 — which is indistinguishable from a genuinely exhausted budget, so a client that skips the check can put itself to sleep waiting for a window that was never full.

Rate Limit Exceeded (429)

When the rate limit is exceeded, the API responds with HTTP 429 and a Retry-After header:

{
  "errors": [
    {
      "status": "429",
      "detail": "Request was throttled. Expected available in 42 seconds.",
      "source": {"pointer": "/data"},
      "code": "throttled"
    }
  ]
}

Headers on 429 responses:

Retry-After: 42
X-RateLimit-Limit: 600
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 42

Usage Limits (429)

Accounts on usage-based plans also have a monthly usage allocation. When it is exhausted, the API responds with HTTP 429 as well -- but this is a different limit than the rate limits above, and retrying within the rate window will not help.

Responses for usage-metered accounts include usage headers:

Header Description
X-Usage-Limit Units included in the current month
X-Usage-Used Units consumed so far this month
X-Usage-Remaining Units remaining this month
X-Usage-Reset ISO 8601 timestamp of the next monthly reset

Telling The Two 429s Apart

Both limits answer with 429, so branch on the error's code:

Signal Rate limit 429 Usage limit 429
Error code throttled quota_exceeded or overage_budget_exhausted
X-RateLimit-Remaining 0 Usually nonzero (the rate window is not the problem)
X-Usage-Remaining -- 0
Retry-After Seconds until the rate window resets Seconds until the monthly usage reset

The X-RateLimit-* headers always describe the rate window and the X-Usage-* headers always describe the monthly allocation; each family stays accurate regardless of which limit triggered the 429. Retry-After is the universal backoff signal -- it is correct in both cases.

If your plan does not include API access at all, the API responds with HTTP 403 and error code plan_not_included instead.

Best Practices

  • Monitor headers: check X-RateLimit-Remaining (and X-Usage-Remaining where present) to throttle requests client-side before hitting the limit — and confirm the header is present before parsing it, since not every response carries them
  • Respect Retry-After: always wait the indicated time before retrying after a 429 -- for usage-limit 429s this points at the monthly reset, so do not retry sooner
  • Exponential backoff: use exponential backoff when retrying after rate-limit errors
  • Request only what you need: filter results and use the largest page size that suits you, to reduce the number of calls. Page-size defaults differ per endpoint — the calendar already returns a full year in one response, so do not page it into small chunks
  • Pace portfolio-wide sweeps: a job that walks every listing and fetches each one's calendar issues one request per listing. Above a few hundred listings that will exceed a per-minute limit long before the job finishes — spread the sweep over time rather than firing it as fast as the client can loop
  • Retry the 429s, do not drop them: a throttled request returned no data. Skipping it leaves a silent gap in whatever you were building, so honor Retry-After and re-issue it

Putting It Together

The client below walks every page of the listings endpoint without tripping the rate limit — and recovers, instead of losing data, if a 429 does happen. The same wrapper works unchanged for any other endpoint. It applies the practices above:

  1. Pace from the headers instead of hard-coding your limit. Spreading the advertised limit over the minute (with 10% spare) keeps working when your limit changes, and automatically slows down for the stricter per-endpoint limits, because the headers always describe the tightest active limit. Only when X-RateLimit-Remaining hits zero does it stop and wait out the window.
  2. Check a header is present before reading it — not every response carries them (see the warning above).
  3. Branch on the error code before honoring Retry-After. A usage-limit 429 puts the monthly reset in Retry-After, so sleeping on it indiscriminately can stall a job for weeks.
const BASE = "https://developers.beyondpricing.com/api/v1";

// One paced client. The pacing state lives in this closure, so a second
// client for another job never shares it.
function createClient(token) {
  const headers = { Authorization: `Bearer ${token}` };
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
  let nextSlot = 0;

  // How long to hold off before the next request, read off the response.
  const pauseAfter = (res) => {
    // `has` before `get`: a missing header reads back as null, and
    // Number(null) is 0 — indistinguishable from "no budget left".
    const num = (h) => (res.headers.has(h) ? Number(res.headers.get(h)) : NaN);
    const limit = num("X-RateLimit-Limit");
    const left = num("X-RateLimit-Remaining");
    const reset = num("X-RateLimit-Reset");
    if (!Number.isFinite(limit) || !Number.isFinite(left) || limit <= 0) return 0;
    if (left <= 0) return Math.max(reset, 1) * 1000; // out of budget: wait it out
    return 60_000 / (limit * 0.9); // spread the minute's allowance, 10% spare
  };

  return async function get(path) {
    for (let attempt = 0; attempt < 5; attempt++) {
      const wait = nextSlot - Date.now();
      if (wait > 0) await sleep(wait);

      const res = await fetch(`${BASE}${path}`, { headers });
      nextSlot = Date.now() + pauseAfter(res);

      if (res.ok) return res.json();
      if (res.status !== 429) throw new Error(`HTTP ${res.status} on ${path}`);

      const error = (await res.json()).errors?.[0];
      // Only a rate-limit 429 is worth waiting out: a usage-limit 429
      // puts the monthly reset in Retry-After.
      if (error?.code !== "throttled") throw new Error(`${error.code}: ${error.detail}`);
      await sleep(Number(res.headers.get("Retry-After") ?? 5) * 1000);
    }
    throw new Error(`giving up on ${path}`);
  };
}

const get = createClient(process.env.BEYOND_TOKEN);

const listings = [];
for (let page = 1; ; page++) {
  const body = await get(`/listings/?page[size]=100&page[number]=${page}`);
  listings.push(...body.data);
  if (page >= body.meta.pagination.pages) break;
}
import os
import time

import requests

BASE = "https://developers.beyondpricing.com/api/v1"


class PacedClient:
    def __init__(self, token: str) -> None:
        self.session = requests.Session()
        self.session.headers["Authorization"] = f"Bearer {token}"
        self.next_slot = 0.0

    def _pause_after(self, response: requests.Response) -> float:
        """Seconds to hold off before the next request, read off the response."""
        headers = response.headers
        # Not every response carries the rate-limit headers — check first.
        if "X-RateLimit-Limit" not in headers or "X-RateLimit-Remaining" not in headers:
            return 0.0
        limit = int(headers["X-RateLimit-Limit"])
        remaining = int(headers["X-RateLimit-Remaining"])
        reset = int(headers.get("X-RateLimit-Reset", "1"))
        if limit <= 0:
            return 0.0
        if remaining <= 0:
            return float(max(reset, 1))  # out of budget: wait it out
        return 60.0 / (limit * 0.9)  # spread the minute's allowance, 10% spare

    def get(self, path: str) -> dict:
        for _ in range(5):
            wait = self.next_slot - time.monotonic()
            if wait > 0:
                time.sleep(wait)

            response = self.session.get(f"{BASE}{path}")
            self.next_slot = time.monotonic() + self._pause_after(response)

            if response.ok:
                return response.json()
            if response.status_code != 429:
                response.raise_for_status()

            error = response.json()["errors"][0]
            # Only a rate-limit 429 is worth waiting out: a usage-limit 429
            # puts the monthly reset in Retry-After.
            if error["code"] != "throttled":
                raise RuntimeError(f"{error['code']}: {error['detail']}")
            time.sleep(int(response.headers.get("Retry-After", "5")))
        raise RuntimeError(f"giving up on {path}")


client = PacedClient(os.environ["BEYOND_TOKEN"])

listings = []
page = 1
while True:
    body = client.get(f"/listings/?page[size]=100&page[number]={page}")
    listings.extend(body["data"])
    if page >= body["meta"]["pagination"]["pages"]:
        break
    page += 1