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:
| Header | Meaning |
|---|---|
RateLimit-Limit | The cap for the current window. |
RateLimit-Remaining | Requests left in the current window. Floors at 0. |
RateLimit-Reset | Seconds remaining until the window resets — not a Unix timestamp. |
RateLimit-Policy | The policy in <limit>;w=<window-seconds> form, e.g. 300;w=60. |
Retry-After | Sent 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/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:
- When the global limiter skips a path, the per-route limiter is the only bucket. For example
GET /fundraisersandGET /organizations/:idare budgeted at 300 requests/minute, not 100. - 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/*andGET /platform/*. Their effective ceiling is the global 100/minute, even thoughRateLimit-Limiton the response reads300.
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
| Limiter | Applies to | Window | Cap | Counted per |
|---|---|---|---|---|
| Global | Every request that is not on the skip list described above. | 1 min | 100 | IP |
| Public | Public 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 min | 300 | IP |
| Auth | POST /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 min | 10 | IP |
| Verification resend | POST /cognito/resend-verification, PUT /users/me/private-contact, POST /users/me/private-contact/resend. | 1 min | 5 | Authenticated user (falls back to IP when the caller is anonymous) |
| Strict | Sensitive public writes: POST /donations/receipt/email, POST /donations/receipt/:receiptId/note, POST /platform/tips/:id/receipt/email, POST /ambassador-applications, POST /dmca/notice. | 1 min | 5 | IP |
| Authenticated | Money 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 min | 100 | IP — see the note below |
| Referral clicks | POST /referrals/clicks (called by the website when a referral link is opened; not for integrations). | 1 min | 120 | Caller |
| Presence | POST /presence/fundraisers/:id (the open-campaign heartbeat). Skipped by the global limiter. | 1 min | 4 | IP + campaign |
| Card share | GET /cards/:code/share/:asset, GET /cards/:code/qr.svg. | 1 min | 120 (configurable) | IP |
| Organization creation | POST /organizations. | 1 hour | 5 | IP + user ID |
| Organization member lookup | GET /org-admin/:slug/users/search, POST /org-admin/:slug/members (one bucket for both). | 1 min | 30 | User ID |
| Media upload | POST /fundraisers/:id/media, PUT /fundraisers/:id/outcome-report. | 15 min | 30 | IP + user ID |
| AI image generation | POST /images/generate. Two buckets; both apply. | 1 hour / 1 day | 10 / 30 | User |
| GIFs | GET /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 min | 60 | User (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/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:
| Message | Limiter | retryAfter |
|---|---|---|
Too many requests, please try again later. | Global or public | 60 |
Too many authentication attempts, please try again later. | Auth | 60 |
You have requested several verification emails in a row. Please wait a minute and try again. | Verification resend | 60 |
Rate limit exceeded for sensitive operation. | Strict | 60 |
Too many requests from this account, please try again later. | Authenticated | 60 |
Too many organization registrations. Please try again later. | Organization creation | 3600 |
Too many member lookups. Please wait a minute and try again. | Organization member lookup | 60 |
Too many media uploads. Please try again in a few minutes. | Media upload | 900 |
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. | GIFs | 60 |
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
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
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.
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.
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.