Pagination, rate limits & webhooks
Once an API has real traffic, three operational concerns appear: lists grow too big to return at once, some clients call too often, and polling for changes wastes everyone's resources. Pagination, rate limiting and webhooks solve these.
Pagination styles
| Style | Request | Pros | Cons |
|---|---|---|---|
| Offset/limit | ?limit=50&offset=100 |
Simple, jump to any page | Slow for deep pages; rows shift when data changes |
| Page number | ?page=3&page_size=50 |
Friendly for UIs | Same issues as offset |
| Cursor (keyset) | ?limit=50&cursor=eyJpZCI6MTIzNH0 |
Fast and stable on huge tables | No random page jumps |
A cursor response typically looks like this:
{
"data": [{"id": 1235, "invoice_no": "GST/26-27/0457"}],
"next_cursor": "eyJpZCI6MTI4NH0",
"has_more": true
}
The cursor is an opaque, encoded pointer to the last item returned (for example its id). The server runs
WHERE id > last_id ORDER BY id LIMIT 50, which uses an index no matter how deep you go.
A client should loop until there are no more pages:
import requests
def fetch_all_invoices(base_url: str, token: str):
cursor = None
while True:
params = {"limit": 100, **({"cursor": cursor} if cursor else {})}
r = requests.get(f"{base_url}/v1/invoices", params=params,
headers={"Authorization": f"Bearer {token}"}, timeout=10)
r.raise_for_status()
body = r.json()
yield from body["data"]
if not body["has_more"]:
break
cursor = body["next_cursor"]
Rate limiting
Rate limits protect your servers and keep one noisy client from hurting others. Common algorithms are fixed window (100 requests per minute), sliding window and token bucket (a bucket refills at a steady rate and each request spends a token, allowing short bursts). Limits are usually tracked per API key or user in a fast store such as Redis.
Tell clients where they stand with headers, and return 429 Too Many Requests when they exceed the limit:
HTTP/1.1 429 Too Many Requests
Retry-After: 30
RateLimit-Limit: 100
RateLimit-Remaining: 0
Well-behaved clients respect Retry-After and use exponential backoff with jitter: wait 1s, 2s, 4s, 8s plus a
small random amount, so thousands of clients don't retry at the same instant.
Webhooks: the API calls you
Instead of polling "has the payment succeeded yet?" every few seconds, you register a URL and the provider sends an HTTP POST when an event happens — typical for payment gateways, courier tracking and GitHub.
Receiving webhooks safely:
- Verify the signature. Providers sign the raw body with a shared secret, usually HMAC-SHA256.
- Respond fast with 2xx, then process the event in a background job.
- Be idempotent. Providers retry on failure, so store event IDs and ignore duplicates.
- Don't trust event order; fetch the latest state from the API if order matters.
import hashlib
import hmac
def is_valid_signature(raw_body: bytes, received_sig: str, secret: str) -> bool:
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, received_sig)
hmac.compare_digest runs in constant time, preventing timing attacks that a normal == would allow.
Common mistakes
- Returning unbounded lists — always cap
limiton the server (for example, at most 100). - Retrying immediately in a tight loop after a 429.
- Verifying a webhook signature against re-serialised JSON instead of the exact raw bytes received.
- Doing slow work inside the webhook request, causing timeouts and duplicate retries.
Practice
Build a tiny FastAPI or Django endpoint that receives a webhook, verifies an HMAC signature from an
X-Signature header, stores the event ID to skip duplicates, and returns 200. Send test events to it with curl.