Rate limits keep the map fair and protect it from spam and scraping. They also keep us within the usage policies of the free OpenStreetMap services behind search and routing.
Limits
Each limit applies to one visitor (a salted hash of IP address + user agent) unless marked network (IP address only). Many phones in Uganda share a carrier IP, so network limits are set higher.
| Endpoint | Limit | Window |
|---|---|---|
POST /api/spots (report) |
5 per visitor, and 30 per network | 1 hour |
POST /api/spots/{id}/vote |
60 | 1 hour |
POST /api/spots/{id}/flag |
20 | 1 hour |
GET /api/route |
20 | 1 minute |
GET /api/search |
120 | 1 minute |
Place lookups that miss the cache (inside /api/search) |
30 | 1 minute |
GET /api/spots, /api/stats, /api/me (shared) |
120 | 1 minute |
| Moderator sign-in | 10 attempts per network | 15 minutes |
GET /api/health |
Not limited |
Response headers
Every rate-limited response carries the IETF RateLimit header fields:
HTTP/1.1 200 OK
RateLimit-Limit: 120
RateLimit-Remaining: 117
RateLimit-Reset: 38
RateLimit-Policy: 120;w=60| Header | Meaning |
|---|---|
RateLimit-Limit |
Requests allowed in the window |
RateLimit-Remaining |
Requests left |
RateLimit-Reset |
Seconds until the window resets |
RateLimit-Policy |
The policy as limit;w=window-seconds |
Retry-After |
Seconds to wait. Sent only with 429. |
When an endpoint has more than one policy (reports have a visitor and a network limit), the headers describe whichever policy is closest to its limit.
Handling 429
Over the limit, you get 429 Too Many Requests with a message you can show users:
{ "error": "You have reported a lot of spots recently. Please try again in an hour." }Wait for Retry-After seconds before retrying. Don’t retry in a tight loop, because rejected
requests still count.
async function withBackoff(input: RequestInfo, init?: RequestInit, attempts = 3): Promise<Response> {
const res = await fetch(input, init)
if (res.status !== 429 || attempts <= 1) return res
const wait = Number(res.headers.get("Retry-After") ?? "1")
await new Promise((r) => setTimeout(r, wait * 1000))
return withBackoff(input, init, attempts - 1)
}How it works
- Shared and durable. Counters live in Postgres (
public.rate_limits, updated by thehit_rate_limitfunction), so limits hold across app restarts and multiple app replicas. - Sliding window. The previous window’s count is weighted by how much of it still overlaps the current window. This avoids the double burst a fixed window allows at its boundary.
- Private. Keys are
policy:hash. Raw IP addresses are never stored, and the table isn’t readable through the public API. - Fail-open. If the database limiter is unreachable, each app instance falls back to an in-memory limiter with the same limits, so the API stays available.
Limits are defined in one place, RATE_LIMITS in
lib/rate-limit.ts. If
you change them, update this page and the OpenAPI spec in the same pull request.