Skip to content

Rate Limits ​

The FundlyHub API applies rate limiting at two layers: one global limiter that sees every request, and a set of per-route limiters mounted on specific endpoint families. Both are implemented with express-rate-limitv8 and share the same header and error-body conventions.

Response Headers ​

Every limiter is configured with standardHeaders: true and legacyHeaders: false.

In express-rate-limit v8, standardHeaders: true resolves to the IETF draft-6 header set. That means the API sends:

HeaderMeaning
RateLimit-LimitThe cap for the current window.
RateLimit-RemainingRequests left in the current window. Floors at 0.
RateLimit-ResetSeconds remaining until the window resets — not a Unix timestamp.
RateLimit-PolicyThe policy in <limit>;w=<window-seconds> form, e.g. 300;w=60.
Retry-AfterSent only on a 429. Seconds to wait before retrying.

X-RateLimit-* headers are not sent

Because legacyHeaders: false is set on every limiter, the API does not emit X-RateLimit-Limit, X-RateLimit-Remaining, or X-RateLimit-Reset. If your client reads those names it will get null on every response and will never see a budget. Read the unprefixed RateLimit-* family instead.

RateLimit-Reset is a delta, not a timestamp

Draft-6 defines RateLimit-Reset as the number of seconds from now. Do not multiply it by 1000 and pass it to new Date() — for a one-minute window you would get a date in January 1970. Use Date.now() + reset * 1000 if you need an absolute time.

A successful response looks like this:

http
HTTP/1.1 200 OK
RateLimit-Policy: 300;w=60
RateLimit-Limit: 300
RateLimit-Remaining: 295
RateLimit-Reset: 43
Content-Type: application/json

{ "data": [ /* ... */ ] }

How the Two Layers Interact ​

The global limiter runs before the API router, so on most endpoints it is the first bucket a request touches. It skips a defined set of read-heavy public paths, which are budgeted by their own per-route limiter instead.

Two consequences worth designing for:

  1. When the global limiter skips a path, the per-route limiter is the only bucket. For example GET /fundraisers and GET /organizations/:id are budgeted at 300 requests/minute, not 100.
  2. When it does not skip, both buckets count and the lower cap wins. A handful of endpoints carry the public limiter but are not on the global skip list — for instance GET /locations, GET /leaderboard, GET /achievements/* and GET /platform/*. Their effective ceiling is the global 100/minute, even though RateLimit-Limit on the response reads 300.

That second point is a real gotcha: when several limiters run on one request, each one overwrites the RateLimit-* headers as it goes, so the values you receive come from the last limiter in the chain — the route-specific one. Treat the headers as advisory and always handle a 429 that arrives earlier than the advertised remaining count suggested.

The Limiters ​

LimiterApplies toWindowCapCounted per
GlobalEvery request that is not on the skip list described above.1 min100IP
PublicPublic read surfaces, including: GET /fundraisers and its detail, slug, stats, OG-image and share-card routes; GET /organizations plus /:id, /:id/stats, /:id/members, /:id/updates, /:id/documents/public; GET /fundraisers/:id/comments; GET /gifs/trending, GET /gifs/search; GET /ambassadors; the public user sub-resources /users/:id/impact, /campaigns, /followers, /following, /og-image, /achievements, /activity; GET /achievements/*; GET /donations/recent; GET /home/activity; GET /locations; GET /leaderboard; GET /platform/*; GET /unsubscribe/preview; GET /cognito/verify-email, and the remaining public read routes under /fundraisers/:id.1 min300IP
AuthPOST /cognito/signup, /cognito/confirm, /cognito/signin, /cognito/forgot-password, /cognito/reset-password, /cognito/change-password; POST /unsubscribe, /unsubscribe/resubscribe, /unsubscribe/request; POST /platform/tips.1 min10IP
Verification resendPOST /cognito/resend-verification, PUT /users/me/private-contact, POST /users/me/private-contact/resend.1 min5Authenticated user (falls back to IP when the caller is anonymous)
StrictSensitive public writes: POST /donations/receipt/email, POST /donations/receipt/:receiptId/note, POST /platform/tips/:id/receipt/email, POST /ambassador-applications, POST /dmca/notice.1 min5IP
AuthenticatedMoney and account surfaces: GET /payouts/earnings, /payouts/earnings/pending-breakdown, /stripe/accounts, the /stripe/connect/* family; POST/DELETE /fundraisers/:id/endorse; POST /fundraisers/:id/endorsement-requests; the likes, PUT/DELETE /comments/:commentId/like and PUT/DELETE /projects/:fundraiserId/updates/:updateId/like.1 min100IP — see the note below
Referral clicksPOST /referrals/clicks (called by the website when a referral link is opened; not for integrations).1 min120Caller
PresencePOST /presence/fundraisers/:id (the open-campaign heartbeat). Skipped by the global limiter.1 min4IP + campaign
Card shareGET /cards/:code/share/:asset, GET /cards/:code/qr.svg.1 min120 (configurable)IP
Organization creationPOST /organizations.1 hour5IP + user ID
Organization member lookupGET /org-admin/:slug/users/search, POST /org-admin/:slug/members (one bucket for both).1 min30User ID
Media uploadPOST /fundraisers/:id/media, PUT /fundraisers/:id/outcome-report.15 min30IP + user ID
AI image generationPOST /images/generate. Two buckets; both apply.1 hour / 1 day10 / 30User
GIFsGET /gifs/trending, GET /gifs/search, and POST /fundraisers/:fundraiserId/comments when the body carries gif_id (one bucket for all three). The two reads also take the Public limit.1 min60User (falls back to IP for a guest)

The authenticated bucket is shared per IP

Accounts behind one IP (a household, an office, a mobile carrier's NAT) currently share 100 requests a minute across all the routes in the Authenticated row. Budget for that.

Per-user buckets need a session

The limiters keyed by user ID fall back to anonymous or to the caller's IP when no authenticated user is attached to the request. Send your session cookies (credentials: 'include') so you get your own bucket rather than sharing one. See Authentication.

Shared buckets behind proxies

In deployed environments requests arrive through a CDN, so IP-keyed buckets can be shared between clients that egress through the same edge node. Practically: you may see a 429 well before you have personally made 100 requests in a minute. Build for it — retry on Retry-After rather than assuming the nominal cap is yours alone.

The 429 Response ​

Every limiter answers with HTTP 429 and a JSON body of the same shape: an error string and a retryAfter value in seconds.

http
HTTP/1.1 429 Too Many Requests
Retry-After: 37
RateLimit-Policy: 100;w=60
RateLimit-Limit: 100
RateLimit-Remaining: 0
RateLimit-Reset: 37
Content-Type: application/json

{
  "error": "Too many requests, please try again later.",
  "retryAfter": 60
}

The error string identifies which limiter tripped:

MessageLimiterretryAfter
Too many requests, please try again later.Global or public60
Too many authentication attempts, please try again later.Auth60
You have requested several verification emails in a row. Please wait a minute and try again.Verification resend60
Rate limit exceeded for sensitive operation.Strict60
Too many requests from this account, please try again later.Authenticated60
Too many organization registrations. Please try again later.Organization creation3600
Too many member lookups. Please wait a minute and try again.Organization member lookup60
Too many media uploads. Please try again in a few minutes.Media upload900
You have generated several images in a short time. Please try again later.AI image generation (hourly)3600
You have reached today's limit for AI image generation. Please try again tomorrow.AI image generation (daily)86400
Too many GIF requests. Please wait a minute and try again.GIFs60

The referral-click, presence and card-share limiters answer with the global message, Too many requests, please try again later., and retryAfter: 60.

Prefer the Retry-After header over the body

retryAfter in the body is a static value describing the full window length. The Retry-After header is computed from the live reset time and tells you how many seconds are actually left. Back off on the header; fall back to the body only if the header is missing.

Handling 429 in a Client ​

javascript
const API_BASE = 'https://api.fundlyhub.org/api/v1';

async function fetchWithBackoff(path, options = {}, maxRetries = 3) {
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    const response = await fetch(`${API_BASE}${path}`, {
      credentials: 'include',
      ...options
    });

    if (response.status !== 429) {
      return response;
    }

    // Retry-After is in seconds. Fall back to the body's retryAfter,
    // then to a conservative default.
    const header = response.headers.get('Retry-After');
    let waitSeconds = header ? parseInt(header, 10) : NaN;

    if (Number.isNaN(waitSeconds)) {
      const body = await response.clone().json().catch(() => ({}));
      waitSeconds = typeof body.retryAfter === 'number' ? body.retryAfter : 60;
    }

    if (attempt === maxRetries) {
      return response;
    }

    // Add jitter so a fleet of clients does not retry in lockstep.
    const delayMs = waitSeconds * 1000 + Math.random() * 1000;
    await new Promise(resolve => setTimeout(resolve, delayMs));
  }
}

Monitoring Your Budget ​

javascript
function readRateLimit(response) {
  const limit = response.headers.get('RateLimit-Limit');
  const remaining = response.headers.get('RateLimit-Remaining');
  const reset = response.headers.get('RateLimit-Reset');

  // Endpoints without an attached limiter send no RateLimit-* headers.
  if (limit === null) return null;

  return {
    limit: parseInt(limit, 10),
    remaining: parseInt(remaining, 10),
    // RateLimit-Reset is seconds from now, not an epoch timestamp.
    resetsAt: new Date(Date.now() + parseInt(reset, 10) * 1000)
  };
}

Missing headers do not mean "unlimited"

Not every endpoint has a named limiter attached, and those responses carry no RateLimit-* headers at all. Read that as "no per-request budget is published for this route", not as an open door: limiter coverage changes as the platform evolves, and clients generating abusive load may be throttled or blocked without a 429 first. Write your client to honour whatever headers are present and to back off on any 429.

Reducing Request Volume ​

Paginate ​

GET /fundraisers accepts limit and offset. Each page is one request against your budget, so pull pages you will actually use rather than walking the whole collection.

javascript
const response = await fetch(
  `${API_BASE}/fundraisers?limit=20&offset=0`
);

Cache on the client ​

Public listings change slowly. A short in-memory TTL removes most repeat traffic.

javascript
const cache = new Map();
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes

async function getCached(path) {
  const hit = cache.get(path);
  if (hit && Date.now() - hit.at < CACHE_TTL) return hit.data;

  const data = await (await fetch(`${API_BASE}${path}`)).json();
  cache.set(path, { data, at: Date.now() });
  return data;
}

Avoid tight polling loops ​

The public read budget is 300 requests/minute per IP and the global budget is 100. A poller running once per second against several endpoints will exhaust either one. Poll on the order of tens of seconds, and stop polling entirely while a tab is hidden.

Built with VitePress