WWatchLLM

Rate limits

WatchLLM returns standard rate-limit headers on authenticated REST requests, MCP metadata responses, and successful MCP tool calls.

  • X-RateLimit-Limit
  • X-RateLimit-Remaining
  • X-RateLimit-Reset

Local development uses persisted Postgres quota storage, not an in-process counter. Organization quota rows live in api_usage, and the per-IP guard stores SHA-256 IP hashes in api_ip_usage.

Quota model

Authenticated REST and MCP calls share the same organization hourly quota. Project-scoped keys resolve the organization through their project. Organization-scoped keys use X-Project-Id for REST project routes or projectId in MCP tool arguments when a tool needs project context, but the quota still belongs to the organization.

  • Explore: 100 requests per hour
  • Solo: 500 requests per hour
  • Startup: 2,000 requests per hour
  • Business: 10,000 requests per hour
  • Enterprise: 50,000 requests per hour

The reset value is the next UTC hour as a Unix epoch in seconds.

IP guard

The local API layer also applies a durable per-IP guard of 1,000 requests per minute. IP addresses are never stored raw; WatchLLM stores only the SHA-256 hash plus the minute window.

Public endpoint guard

The organization quota and the api_ip_usage guard above only cover the authenticated REST and MCP surface. The public endpoints run before any key or session check, so they are guarded separately. An in-process counter cannot do this job: each request can land on a different serverless instance, which resets a module-level counter.

The guard is always on. It has two stores and picks one at run time.

  • **Upstash Redis**, when UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN are both set. Sliding window, and no database write per request. Preferred.
  • **Postgres** otherwise, in the same api_ip_usage table. The window is fixed rather than sliding, so a caller can spend one window's budget at the end of a window and the next one's at the start. The prune-api-usage job already deletes these rows after 7 days.

If the store is unreachable, the guard fails open and logs a warning — a rate-limit store outage must not take sign-in down.

Identifiers are hashed with SHA-256 before they are stored, so no IP address or email address is kept in plain text.

| Endpoint | Limit | Counted per | | --- | --- | --- | | POST /api/auth/sign-in/magic-link | 10 per 15 minutes | IP | | POST /api/auth/sign-in/magic-link | 3 per 15 minutes | target email address | | Free-tool compute routes (9) | 20 per minute | IP | | POST/PATCH /api/free-tools/[tool]/runs | 60 per minute | IP | | POST /api/public-conversion-intents | 5 per 10 minutes | IP | | POST /api/support/messages | 20 per hour | user | | POST /api/track | 120 per minute | IP |

A blocked request returns 429 with Retry-After and the three X-RateLimit-* headers.

{
  "code": "RATE_LIMITED",
  "message": "Too many sign-in link requests. Try again in a few minutes.",
  "statusCode": 429,
  "retryAfter": 612
}

Error shape

Project-context errors return JSON with error and message fields.

{
  "error": "MISSING_PROJECT_ID",
  "message": "X-Project-Id header required for this endpoint."
}

MCP initialize, tools/list, and successful tools/call responses consume the shared organization quota and include the same three X-RateLimit-* headers. Invalid bearer requests and organization-scoped tool calls missing project context return before consuming organization quota.

When the organization hourly quota is exceeded, REST returns 429 with rate-limit headers and Retry-After.

{
  "error": "Too Many Requests",
  "message": "You've hit your hourly API request limit of 100 requests. Please retry after the quota resets.",
  "retryAfter": 3600,
  "limit": 100,
  "remaining": 0
}

When the per-IP guard is exceeded, REST returns 429 with the standard guard body.

{
  "code": "RATE_LIMITED",
  "message": "Too many requests, please slow down",
  "statusCode": 429
}

Local verification

scripts/verify-rate-limits.ts exercises the public endpoint guard. Run npx tsx scripts/verify-rate-limits.ts postgres with a real DATABASE_URL to burst a throwaway policy and print the 429, ... failopen to see an unreachable store allow the request, and ... upstash with real credentials to burst the same policy through Redis.

The committed smoke:rest-core script verifies consecutive authenticated /api/v2/validate calls return X-RateLimit-Remaining values that decrement. smoke:mcp verifies authenticated MCP initialize, tools/list, and tools/call responses carry the same quota headers while unauthorized and missing-project calls do not consume organization quota. The rate-limit task queue also covers the persisted per-IP and per-organization 429 paths.