The Future Demand API is rate-limited per partner. The exact numbers are not exposed in headers; below is the practical guidance.
The numeric limits below are operational guidance, not contractual. Future Demand adjusts them per partner. Confirm the current limits with your account contact if your integration needs sustained high-throughput.

What the platform does internally

Future Demand’s internal services retry transient failures across the stack — this is useful context when designing your own retry policy:
AspectInternal behaviour
Retry attempts5
Retryable status codes500, 502, 503, 504, 429
Request timeout (selected upstreams, e.g. recommender)5 s
All-retries-failedInternal alert posted to Future Demand’s Teams channel
Your client should mirror this approach for the same status codes. The 5-attempt cap with backoff is a reasonable default; don’t go higher than 5 — at that point something is genuinely wrong, not transient.
Rate-limit responses do not currently include Retry-After or X-RateLimit-* headers. Treat any 429 as “back off and try again later.”

Detecting a rate limit

async function fdRequest<T>(path: string, init?: RequestInit): Promise<T> {
  const resp = await fetch(`${BASE}${path}`, init);
  if (resp.status === 429) {
    const retryAfter = Number(resp.headers.get("Retry-After")) || 5;
    throw Object.assign(new Error("rate_limited"), { status: 429, retryAfter });
  }
  if (!resp.ok) throw new Error(`${resp.status}`);
  return resp.json();
}
If Retry-After is missing (it often is), default to 5–10 seconds.

Exponential backoff with jitter

async function withBackoff<T>(
  fn: () => Promise<T>,
  { attempts = 5, baseMs = 500, maxMs = 30_000 } = {},
): Promise<T> {
  for (let i = 0; i < attempts; i++) {
    try {
      return await fn();
    } catch (e: any) {
      if (e.status !== 429 && (e.status < 500 || e.status >= 600)) throw e;
      if (i === attempts - 1) throw e;

      const expo = Math.min(baseMs * 2 ** i, maxMs);
      const jitter = Math.random() * expo * 0.3;
      const delay = (e.retryAfter ? e.retryAfter * 1000 : expo) + jitter;
      await sleep(delay);
    }
  }
  throw new Error("unreachable");
}
The jitter matters when multiple clients (or multiple tabs) all hit the limit simultaneously — without it they retry in lockstep and trip the limiter again.

Client-side rate limiting

For workloads that walk many resources (e.g. “for each event, fetch the benchmark”), throttle yourself rather than rely on retry. Use a simple semaphore:
class Semaphore {
  private queue: Array<() => void> = [];
  constructor(private permits: number) {}

  async acquire() {
    if (this.permits > 0) { this.permits--; return; }
    await new Promise<void>(r => this.queue.push(r));
  }
  release() {
    this.permits++;
    const next = this.queue.shift();
    if (next) { this.permits--; next(); }
  }
}

const sem = new Semaphore(8);   // 8 concurrent calls

async function withConcurrency<T>(fn: () => Promise<T>): Promise<T> {
  await sem.acquire();
  try     { return await fn(); }
  finally { sem.release(); }
}

// usage
await Promise.all(events.map(e => withConcurrency(() => fetchBenchmark(e.id))));
8 concurrent calls is a reasonable default. Drop to 4 if you see 429s.

What gets rate-limited

Endpoint familyNotes
/auth/*The tightest budget. Don’t sign in on every request — cache the session.
Bulk reads (/events/, /campaigns/, …)Pagination is fine. Don’t ask for limit=10000.
Writes (/setup_processes/*, /campaigns_setup, …)Debounce autosaves (the reference webapp uses 1s debounce + AbortController).
Status pollsStay at 3s+ intervals; for task state polling, retry only after a PUT, not in a continuous loop.

Common mistakes

Tight loops hit the limiter and don’t make the task complete any faster. Use 2s with backoff.
A burst of failed requests creates a burst of refresh attempts. Use single-flight refresh (see Token refresh).
for (const e of events) await fetchDetail(e.id) looks innocent but sustained at 100ms-per-request hits the limit in 6 seconds. Use the semaphore pattern above.
POST /campaigns/, POST /setup_processes/{eid} are not idempotent. Retrying after a 429 is fine — but make sure the previous call really didn’t succeed (check status) before re-issuing the same write.