API Rate Limiting Policy Template
A template for defining API rate limits per consumer tier, including burst allowances, quota periods, and escalation paths.
Overview
Unlimited API access is a recipe for abuse, accidental DDoS, and unpredictable costs. Rate limiting shields your infrastructure while consumers get predictable access. Without a documented policy, consumers discover limits only when their requests start failing with 429 Too Many Requests. This template sets rate limits per tier, tells consumers exactly what they are, and provides an escalation path for consumers who need more. Related resources: the API rate limiting guide for the algorithms behind this policy, OpenAPI documentation for publishing limits, and API logging and audit trails for tracking violations.
When to Use
Use this resource when:
- Launching a public or partner API
- Defining pricing tiers for API access
- Experiencing traffic spikes that degrade service for others
- Negotiating SLAs with enterprise clients who need higher limits
- Choosing between limiting and queueing — the throttling pattern covers the queueing side
Skip it when the API serves a single known internal consumer: quotas and backpressure inside the codebase beat a public-facing policy. And it’s premature before your first real consumers exist — write it the week you open the API, not the month you start building it.
Solution
# API Rate Limiting Policy
## Tiers & Limits
| Tier | Requests / Minute | Requests / Hour | Burst | Cost |
|------|-------------------|-------------------|-------|------|
| Free | 60 | 1,000 | 10 | $0 |
| Starter | 300 | 10,000 | 50 | $49/mo |
| Pro | 1,000 | 100,000 | 200 | $199/mo |
| Enterprise | 10,000 | 1,000,000 | 2,000 | Custom |
## Limit Scope
Limits are applied per **API key** at the following scopes:
- **Global:** All endpoints combined count toward the same limit
- **Endpoint-specific:** `POST /orders` has its own limit separate from `GET /products`
- **IP-based (Free tier only):** Fallback enforcement when API key is absent
## Quota Periods
- **Per-minute limit:** Resets at the top of each minute (UTC)
- **Per-hour limit:** Resets at the top of each hour (UTC)
- **Rolling window:** A 60-second sliding window (more precise but computationally expensive)
## Response Headers
Every API response includes the following headers:
X-RateLimit-Limit: 1000 X-RateLimit-Remaining: 847 X-RateLimit-Reset: 1719398400 X-RateLimit-Policy: pro;w=3600
| Header | Description |
|--------|-------------|
| `X-RateLimit-Limit` | Maximum requests allowed in the current window |
| `X-RateLimit-Remaining` | Requests remaining in the current window |
| `X-RateLimit-Reset` | Unix timestamp when the current window resets |
| `X-RateLimit-Policy` | Tier and window size identifier |
## Exceeded Limit Response
```json
{
"error": {
"code": "RATE_LIMIT_EXCEEDED",
"message": "You've exceeded your rate limit. Please retry after 2026-06-26T11:00:00Z.",
"retryAfter": 3600,
"documentationUrl": "https://docs.example.com/rate-limits"
}
}
HTTP Status: 429 Too Many Requests
Required Header: Retry-After: 3600
Burst Behavior
Burst limits allow short spikes above the sustained rate:
- Pro tier: 200 requests in 1 second, then throttled to 1,000/hour average
- Algorithm: Token bucket with refill rate = sustained limit / window size
- Penalty: No penalty for burst usage within configured limits
Increasing Limits
- Upgrade tier: Change your plan in the developer dashboard
- Request exception: Contact api-support@example.com with:
- Current usage patterns (requests per endpoint, peak hours)
- Business justification (product launch, integration partner)
- Expected timeline and volume
- Enterprise negotiation: Dedicated capacity, custom SLA, private endpoints
Monitoring & Alerts
| Alert | Condition | Action |
|---|---|---|
| Approaching Limit | 80% of hourly quota consumed | Email notification to admin |
| Limit Exceeded | 429 responses > 1% of traffic | PagerDuty alert to on-call |
| Abuse Pattern | 10x normal volume from single key | Auto-throttle + manual review |
Fair Use Policy
- No automated evasion: Using several API keys to circumvent limits violates terms
- Cache aggressively: Responses marked
Cache-Control: publicshould be cached - Batch operations: Use bulk endpoints instead of individual calls
- Webhook preference: Subscribe to webhooks instead of polling for state changes
## Explanation
A note on the implementations below: the Redis pipelines shown are for clarity — the read-modify-write is not atomic across instances. In production, run the token bucket as a Lua `EVAL` script on Redis (see the [rate limiting guide](/guides/api-rate-limiting-guide/) for the script) so two instances cannot read the same token count concurrently.
The policy separates **sustained limits** (average over time) from **burst limits** (short-term spikes). The token bucket algorithm is the industry standard because it allows bursts while enforcing long-term averages. Response headers tell consumers in real time so they can back off before hitting the cap. The escalation path prevents support tickets from consumers who simply need a higher tier.
## Token Bucket Implementation
Token bucket is the workhorse of rate limiting. Here is a Redis-based implementation:
### Redis Token Bucket in Node.js
```javascript
const redis = require("redis");
async function rateLimit(redisClient, key, options) {
const { capacity, refillIntervalSec } = options;
const now = Date.now();
const bucketKey = `ratelimit:${key}`;
const result = await redisClient
.multi()
.hGetAll(bucketKey)
.hSet(bucketKey, {
tokens: capacity,
lastRefill: now,
})
.expire(bucketKey, refillIntervalSec * 2)
.exec();
const bucket = result[0];
let tokens = parseFloat(bucket.tokens) || capacity;
let lastRefill = parseInt(bucket.lastRefill) || now;
const elapsed = (now - lastRefill) / 1000;
const refillAmount = elapsed * (capacity / refillIntervalSec);
tokens = Math.min(capacity, tokens + refillAmount);
if (tokens >= 1) {
tokens -= 1;
await redisClient.hSet(bucketKey, {
tokens: tokens.toString(),
lastRefill: now.toString(),
});
return { allowed: true, remaining: Math.floor(tokens) };
} else {
const retryAfter = Math.ceil((1 - tokens) / (capacity / refillIntervalSec));
await redisClient.hSet(bucketKey, {
tokens: tokens.toString(),
lastRefill: now.toString(),
});
return { allowed: false, remaining: 0, retryAfter };
}
}
Express.js Middleware
const TIERS = {
free: { capacity: 10, refillRate: 60, refillIntervalSec: 60 },
starter: { capacity: 50, refillRate: 300, refillIntervalSec: 60 },
pro: { capacity: 200, refillRate: 1000, refillIntervalSec: 60 },
enterprise: { capacity: 2000, refillRate: 10000, refillIntervalSec: 60 },
};
async function rateLimitMiddleware(req, res, next) {
const apiKey = req.headers["x-api-key"];
const tier = await getTierForApiKey(apiKey);
const options = TIERS[tier] || TIERS.free;
const result = await rateLimit(redisClient, apiKey, options);
res.setHeader("X-RateLimit-Limit", options.refillRate);
res.setHeader("X-RateLimit-Remaining", result.remaining);
res.setHeader("X-RateLimit-Policy", `${tier};w=${options.refillIntervalSec}`);
if (!result.allowed) {
res.setHeader("Retry-After", result.retryAfter);
return res.status(429).json({
error: {
code: "RATE_LIMIT_EXCEEDED",
message: "Rate limit exceeded. Retry after the specified delay.",
retryAfter: result.retryAfter,
},
});
}
next();
}
Python Implementation with Redis
import time
import redis
def rate_limit(redis_client, key, capacity, refill_rate, interval_sec):
bucket_key = f"ratelimit:{key}"
now = time.time()
pipe = redis_client.pipeline()
pipe.hgetall(bucket_key)
pipe.hset(bucket_key, tokens=capacity, lastRefill=now)
pipe.expire(bucket_key, interval_sec * 2)
results = pipe.execute()
bucket = results[0]
tokens = float(bucket.get(b"tokens", capacity))
last_refill = float(bucket.get(b"lastRefill", now))
elapsed = now - last_refill
refill_amount = elapsed * (capacity / interval_sec)
tokens = min(capacity, tokens + refill_amount)
if tokens >= 1:
tokens -= 1
redis_client.hset(bucket_key, tokens=tokens, lastRefill=now)
return {"allowed": True, "remaining": int(tokens)}
else:
retry_after = int((1 - tokens) / (capacity / interval_sec)) + 1
redis_client.hset(bucket_key, tokens=tokens, lastRefill=now)
return {"allowed": False, "remaining": 0, "retryAfter": retry_after}
Client-Side Retry Pattern
Consumers should implement exponential backoff with jitter when receiving 429 responses:
async function fetchWithRetry(url, options, maxRetries = 3) {
let attempt = 0;
while (attempt < maxRetries) {
const response = await fetch(url, options);
if (response.status !== 429) {
return response;
}
const retryAfter = parseInt(response.headers.get("Retry-After") || "1");
const jitter = Math.random() * 0.5;
const delay = (retryAfter + jitter) * 1000;
console.warn(`Rate limited. Retrying in ${delay}ms (attempt ${attempt + 1})`);
await new Promise((resolve) => setTimeout(resolve, delay));
attempt++;
}
throw new Error(`Max retries (${maxRetries}) exceeded`);
}
Distributed Rate Limiting
For multi-instance deployments, use a shared store (Redis, Memcached) instead of in-memory counters:
| Approach | Pros | Cons |
|---|---|---|
| In-memory | Fastest, no external dependency | Not shared across instances |
| Redis | Shared state, atomic operations | Network latency, Redis dependency |
| Memcached | Simple, fast | No persistence, less flexible |
| Database | Persistent, queryable | Slow, not suitable for high throughput |
Rolling Out the Policy
A rate limiting policy that lives only in code is a rumor. Publish the document before you enforce it: announce limits in the changelog, give existing consumers a grace window (two to four weeks is common), and version the policy itself — v1.1 that tightens the free tier is a breaking change and deserves the same treatment as a removed endpoint. Keep a short revision history at the bottom: date, what changed, who approved it. When a number moves, the audit trail answers “since when” without digging through git blame.
Decide who owns each field before the first violation. Tier prices belong to product, enforcement to platform, and exception approvals to whoever can actually raise a limit — write the name, not the team. A policy with “contact support” for everything produces a queue of tickets; a policy with an owner produces a decision.
Sizing Tiers from Capacity
Work the numbers backwards. Suppose the backend sustains 1,500 RPS and reads outnumber writes 9:1. A write costs roughly five times a read, so the effective write budget is about 1,500 / (9 + 5·1) ≈ 107 writes per second across all consumers. The Pro tier at 1,000/minute (≈16/s) plus a hundred active keys already consumes more than the service can offer — that is why public tiers are aspirational ceilings, not guarantees, and why the burst column exists to absorb reality.
Sanity-check against real traffic before shipping: pull a month of per-key request counts, find the p95 consumer, and place the paid tier just above it. The free tier should sit low enough to stop scrapers but high enough that a curious developer finishes their integration without hitting the wall on day one — that is usually the first thirty minutes of honest usage.
What the Consumer Actually Sees
The document consumers read is shorter than the one you wrote. What survives contact: the tier table, the 429 body, and the headers on every response. If any of the three disagrees with the rest, the consumer trusts whatever their logs say — which means drift between docs and enforcement isn’t a documentation bug, it is a broken contract. Cheap insurance: a nightly job that hits one endpoint per tier and diffs the returned X-RateLimit-Limit against the published table. When the two disagree, page the policy owner, not the consumer.
Also decide how consumers discover their tier before they need to. A GET /v1/me/limits endpoint that returns the caller’s tier, current window usage, and reset time saves a support ticket per confused integration — Stripe and GitHub both expose an equivalent, and it costs an afternoon to build.
Handling Exceptions and Overrides
Real consumers need real escape valves, and the policy should name them. Three belong in every document: a per-key override flag that lets support raise one account’s limit for 30 days without touching the tier table (a product launch, an enterprise pilot); an emergency kill switch that tightens limits globally when the backend is on fire — the difference between a controlled brownout and an uncontrolled outage; and a temporary raise tied to a date, because an exception without expiry becomes the new floor and nobody remembers to roll it back.
Log every override: who asked, who approved, what the new number was, and when it expires. When the exception list is longer than five keys, the tier table is wrong — fix the table instead of stacking exceptions.
Comparing Published Policies
Looking at how established APIs document limits helps calibrate yours. Stripe hides most numbers but publishes the 429 shape and the escalation path; GitHub publishes exact per-minute numbers per auth type plus a secondary rate limiter for concurrent requests; Cloudflare publishes per-plan limits on its docs site with the same table consumers see in the dashboard. The pattern that matters: every one of them treats the limit document as a product surface, not an internal memo — consumers read it before signing a contract, and support links to it instead of writing essays.
Two details worth stealing from those references: every published policy separates the per-second safety valve from the per-hour budget, and every one documents what happens between them — burst allowance, queueing behavior, and the reset semantics. Copy that separation into yours and the tier table starts answering questions instead of raising them. Also note what they leave out: none of them publishes the algorithm’s exact parameters, because tuning numbers change faster than any document can track. Publish the contract (numbers, headers, error shape, escalation) and keep the implementation knobs internal — that split is what lets you tune the bucket without renegotiating the policy.
Adapting the Template
Not every API needs all seven sections. A first public API can ship with tiers, headers, and the 429 body — monitoring and the fair-use clause can follow once real consumers exist. A partner API needs the opposite emphasis: the escalation path and the SLA language move to the top, because the document functions as an annex to a contract rather than public docs. And an internal platform API can drop pricing columns entirely while keeping the scope and quota sections, which is where the “one team starves the others” failure actually lives.
Two fields teams forget until someone asks: the effective date each limit takes effect (consumers plan integrations around it, and your own support needs it to answer “why did this suddenly start failing”), and the measurement window identity — whether X-RateLimit-Reset reports a fixed boundary or a rolling window changes client retry logic completely. Put both in the header section, not in footnotes.
One more adaptation worth an explicit decision: who the document is written for. A policy aimed at consumers reads like the template — tables, headers, escalation. The same policy aimed at your own engineers needs an extra paragraph per tier explaining where each number came from, because the next person asked “can we raise Pro to 2,000?” will dig for the capacity math that justified 1,000. Keep both audiences in one document by linking the internal rationale to an appendix, not by writing two policies that drift apart the first time someone edits one.
And revisit the whole document after the first real incident that touches the limiter — whatever the post-mortem reveals about who paged whom and which number was wrong usually exposes the one row the template was missing — write it in while the memory is fresh, and add the incident to the revision history at the bottom of the document itself — future reviewers will thank you for it later — and so will your consumers.
Variants
| Context | Approach | Notes |
|---|---|---|
| Public SaaS | Tiered pricing with free tier | Conversion-oriented, limits drive upgrades |
| Internal platform | Per-team quotas with shared pool | Prevents one team from starving others |
| Partner API | Negotiated limits per contract | Limits defined in legal agreements |
| GraphQL API | Query complexity-based limits | Cost = field count, depth, and resolver weight |
What Works
- Return headers on every response — not just when limits are close
- Use a standard algorithm (token bucket or leaky bucket) — custom logic confuses consumers
- Document the reset behavior — consumers need to know when to retry
- Provide bulk endpoints — one
POST /orders/bulkis better than 100POST /orders - Monitor 429 rates — high 429 rates indicate poorly configured limits or consumer abuse
- Use Redis for distributed deployments — in-memory limits are inaccurate with several instances
- Separate read and write limits — writes are more expensive and should have tighter limits
Common Mistakes
- Returning 403 instead of 429 — consumers can’t distinguish auth failure from rate limiting
- Not documenting the algorithm — consumers can’t predict when they will be throttled
- Inconsistent limits across endpoints — same key, different rules, consumer confusion
- No burst allowance — legitimate traffic spikes get blocked
- Hard limits without escalation — enterprise clients can’t negotiate higher capacity
- Using in-memory counters with several instances — each instance tracks separately, allowing N x the limit
- Not returning Retry-After header — consumers guess when to retry, causing thundering herd
- Rate limiting by IP only — NAT and proxies make IP-based limits unreliable for paid tiers
Further Reading
- RFC 6585 — 429 Too Many Requests — the status code and Retry-After
- Stripe — Rate Limiting — production policy reference
Frequently Asked Questions
What happens if I exceed both per-minute and per-hour limits?
Whichever limit is tighter wins. Blow the per-minute cap and you get a 429 right away, even with hourly quota left. Blow the hourly cap and everything waits for the reset.
Should rate limits be the same for read and write operations?
No — writes cost more, so they deserve tighter caps. The usual split: GET highest, POST/PUT/PATCH middle, DELETE lowest.
How do I test my integration without hitting limits?
Point your tests at a sandbox environment with raised or no limits — most API providers offer one. Alternatively, mock the responses in your suite and verify you actually parse the rate limit headers.
Should I use a fixed window or sliding window?
Fixed window is cheaper to run but tolerates a 2x burst at the boundary — a spike at the end of one window plus another at the start of the next. Sliding window closes that hole at the cost of more memory. Fixed window with a burst allowance covers most APIs.
How do I handle rate limiting for GraphQL?
Count cost, not requests. Assign each field a weight based on resolver complexity and cap the total cost per query — graphql-cost-analysis and similar tools enforce this out of the box.
What is the difference between rate limiting and throttling?
Rate limiting caps requests per window and rejects the excess with a 429. Throttling queues the excess and makes it wait instead. Most APIs pick the former — simpler, and consumers get a clear signal.
Should I rate limit authenticated and unauthenticated requests differently?
Yes — unauthenticated requests get lower caps (or flat rejection) since you can't tie them to an account. Authenticated calls map to the consumer's tier and can be billed against it.
Related Resources
API Performance Budget Template
A template for setting and tracking API latency and throughput performance budgets.
DocEscalation Policy Template
A template for defining incident severity levels and on-call escalation paths.
DocAPI Security Review Template (OWASP Checklist)
A fillable checklist template for reviewing API authentication, authorization, rate limiting, and OWASP API Security Top 10 coverage.
GuideAPI Rate Limiting — Design Fair and Useful Throttling
A practical guide to API rate limiting: token bucket, leaky bucket, sliding window algorithms, choosing limits, and implementing resilient throttling for APIs.
DocAPI Changelog Template
A template for documenting API changes including breaking changes, new features, deprecations, and bug fixes.