API Rate Limiting
Protect your storefront API and MCP endpoints from abuse using Upstash Redis.
Cartwright rate-limits its API to prevent abuse and manage costs, in three layers: an optional global limiter in proxy.ts, which sees the /api paths that have no dot in them — /api/mcp and the storefront AI assistant chat among them, but no tool call on /api/v1/tools/<name> (How it works); the public agent API's own limits, for MCP and REST tool calls alike; and in-memory limits on the public forms.
The global limiter is implemented via Upstash Redis and runs in proxy.ts, which Next.js 16 runs on the Node.js runtime. The public agent API and the public forms carry limits of their own, which apply with or without Upstash — see below.
Setup
-
Create an Upstash Database Go to Upstash and create a free Redis database.
-
Configure Environment Variables Add the following variables to your
.env.local(and your production environment like Vercel):UPSTASH_REDIS_REST_URL="https://eu1-....upstash.io" UPSTASH_REDIS_REST_TOKEN="AYaL..."
How it works
The proxy.ts file (Next.js 16's name for middleware) does not run on every request. Its matcher (the config export at the end of the file) leaves out every path that contains a dot, which is how static files such as /robots.txt skip it. Among /api paths it sees /api/mcp, the tool list at GET /api/v1/tools, the contact form's endpoints and the assistant chat — and no tool call: every tool name has a dot (products.search), so POST /api/v1/tools/products.search never reaches proxy.ts, and neither does /api/openapi.json. If the Upstash environment variables are present, it rate-limits the /api requests it sees.
- Limit: 20 requests per 10 seconds per client IP, as the engine's trusted resolver sees it (Who counts as one client). Self-hosted without
CARTWRIGHT_TRUST_PROXY_IP_HEADERS, every visitor counts as the same unknown client, with or without a proxy in front, so every/apirequest the proxy sees shares one 20-per-10-seconds bucket — set the variable behind a proxy that overwrites the forwarding header, or leave Upstash unset. The OAuth routes (/oauth/*) use the same limiter in a bucket of their own. - Fail-open: If Upstash is unreachable or the environment variables are missing (e.g. in local development), the global limiter is bypassed so the app doesn't crash (the agent API, authentication-attempt and form limits below still apply).
- Headers: Every limited response carries
RateLimit-Policy,RateLimit,RateLimit-Limit,RateLimit-RemainingandRateLimit-Reset. Over the limit, the API answers429 Too Many Requestswith aRetry-Afterheader and anapplication/problem+jsonbody.
The public agent API
Anonymous calls to /api/mcp and to the public tools on /api/v1/tools/<name> — those sent without an Authorization header — have a limit of their own, in lib/public-agent-rate-limit.ts: 60 requests per minute per client IP, as a bucket of 60 that refills at one request per second.
- With Upstash: the bucket is kept in Redis, so it holds across every server instance, and MCP and REST calls from one client draw on the same bucket. If an Upstash check fails or times out, the shop uses the in-memory bucket for the next 30 seconds instead of letting requests through unthrottled.
/api/mcpalso passes the global limiter inproxy.ts, and passes it first, so an MCP client meets that 20-per-10-seconds window before this bucket: a burst of more than 20 requests in 10 seconds is refused there although the bucket holds 60, and allowing one needs a change inproxy.ts, even at the default bucket size. REST tool calls have a dot in their path, soproxy.tsnever sees them: for an anonymous call to a public tool, this bucket is the only limit (authenticated calls, and anonymous calls to other tools, meet the auth-attempt limit below instead). - Without Upstash: the same bucket — 60, then one per second — is held in each server instance's memory.
- Over the limit:
429 Too Many Requestswith aRetry-Afterheader, anapplication/problem+jsonbody with the coderate_limit_exceeded, andRateLimit-*headers under the policy namepublic-agent.
Calls that carry an Authorization header do not use this bucket: they count against a per-IP authentication-attempt limit instead (lib/auth-attempt-rate-limit.ts: 120 in a burst, then 2 per second, in memory), checked before the key is looked up. On REST, an anonymous call to a tool outside the public list counts against that limit too, before it is refused for lacking a key. The authentication-attempt limit keys on the same client address as the others, so self-hosted without CARTWRIGHT_TRUST_PROXY_IP_HEADERS every authenticated caller — every API key, your own scripts and agents included — shares one bucket of 120, then 2 per second, in each server instance's memory.
Customizing the Limits
Each limit lives in its own file:
- The global limiter:
GLOBAL_API_RATE_LIMITandGLOBAL_API_RATE_WINDOW_SECONDSinproxy.tsat the root of your Cartwright shop. If you need different limits for different/apiendpoints, apply specific rules there based onreq.nextUrl.pathname— for the paths the proxy sees. A rule for a tool call never runs: tool-call paths have a dot, and thematcherkeeps them out ofproxy.ts. - The public agent API (anonymous calls to
/api/mcpand/api/v1/tools/<name>):lib/public-agent-rate-limit.ts, notproxy.ts.PUBLIC_AGENT_RATE_LIMITis the size of the bucket, in Upstash and in memory alike, and the number theRateLimit-*headers report; the refill of one request per second is set beside it (Ratelimit.tokenBucket(1, "1 s", …)andrefillRate: 1). With Upstash set,/api/mcpmeets the global limiter's 20 per 10 seconds first, so letting an MCP client send more than 20 requests in 10 seconds needs a change inproxy.tstoo — even with the bucket at its default of 60. REST tool calls never reachproxy.ts, so for them this file alone sets the anonymous limit. - The authentication-attempt limit:
AUTH_ATTEMPT_RATE_LIMITand therefillRatebeside it inlib/auth-attempt-rate-limit.ts. - The form limits below:
lib/rate-limit.ts.
Limits that work without Upstash
The public forms carry their own limits, held in memory on each server instance, so they apply even when the Upstash variables are missing. Each is a bucket that starts full and refills slowly:
| Form | Limit |
|---|---|
Contact form (/api/inquiries) | 5 in a burst per client IP, then 1 per minute |
The contact form's AI answer (/api/support/triage), asked for before the message is sent | 3 in a burst per client IP, then 1 per minute. Over it, the form sends the message to a person without an AI answer |
Contact form attachments (/api/contact/upload, when contactAttachments is on) | 6 in a burst per client IP, then 1 every 2 minutes |
Customer sign-up (/account/signup), from v0.63.0 | 5 in a burst per client IP, then 1 every 5 minutes |
| Magic-link sign-in | 3 in a burst per email address, then 1 every 20 minutes; from v0.63.0 also 10 in a burst per client IP, then 1 every 6 minutes. A client over that limit is sent nothing but sees the same "check your inbox", and its requests do not count against the address they name |
| Password reset | 3 in a burst per email address, then 1 every 20 minutes; and 10 in a burst per client IP, then 1 every 6 minutes. A throttled request still answers "sent", so the form never reveals which addresses exist |
The sign-up limit, and from v0.63.0 the magic link's per-client limit, count per client IP as the engine's trusted resolver sees it: on Vercel, or self-hosted with CARTWRIGHT_TRUST_PROXY_IP_HEADERS set behind a proxy that overwrites the forwarding header. Without either, the shop cannot tell its visitors apart, so both limits stay off rather than throttle every customer through one shared bucket. A typo or a refused submit does not use up an attempt.
From the same release, sign-up also has a hidden field that only a bot fills (the bot gets the normal success answer and no account is created), and refuses a form sent less than three seconds after it appeared, with a message — the person sends it again. A sign-up for an address that already has an account gets the same answer as a new one, so the form's answer no longer says who your customers are.
From v0.63.0, the contact form — the message itself (with or without a database), the AI answer it asks for first and its attachments — and the password reset's per-IP limit key on the same trusted client address. Up to v0.62.0 they read the first address in the X-Forwarded-For header as the request sent it. Next.js fills that header with the connection's address only when a request has none, so on a self-hosted server without an overwriting proxy a client could send a new value with every request and never meet the limit. Unlike sign-up, they keep counting a visitor they cannot identify: such visitors share one bucket. On Vercel nothing changes.
Self-hosted without CARTWRIGHT_TRUST_PROXY_IP_HEADERS, every visitor is in that shared bucket, with or without a proxy in front: the header is on every request either way, so it cannot tell a proxied server from one that clients reach directly. The whole shop then gets five contact messages and then one a minute, three AI answers and then one a minute (past that, each message goes to a person without an AI answer), six attachments and then one every two minutes, and ten password resets and then one every six minutes. A single client can use up any of them for everyone, while each throttled reset still answers "sent". That shared limit is the price of closing the hole, and it beats none: the contact form has no other spam brake. Password reset checks the visitor's limit before the address's, so a client already over its limit cannot use up the limits of other people's addresses.
Self-hosted? Engine v0.63.0 changes these limits for you, with or without a proxy. Behind a reverse proxy that overwrites X-Forwarded-For, set CARTWRIGHT_TRUST_PROXY_IP_HEADERS to true (or 1) when you take it, and every visitor gets its own limit again.
If clients reach the server directly, do not set the variable: the header is then the client's own, and anyone could forge it. Put a reverse proxy that overwrites the header in front (a TLS setup normally has one), then set it. Until then, these limits count all your visitors as one client: the contact form and password reset, the public agent API, the auth-attempt limit that API keys meet, and (with Upstash) the global limiter in proxy.ts. Sign-up's per-client limit switches off instead — see Who counts as one client.
Because these limits live in memory, they reset on a redeploy and are counted per instance. Setting the Upstash variables does not change that: it turns on the global limiter in proxy.ts above, while the form limits stay in each instance's memory.
Who counts as one client
The global limiter in proxy.ts, the public agent API, the authentication-attempt limit and — from v0.63.0 — sign-up, the magic link's per-client limit, the contact form (its message, its AI answer and its attachments) and the password reset's per-IP limit all key on the same client address, resolved by lib/trusted-client-ip.ts:
- On Vercel (
VERCEL=1), it readsX-Vercel-Forwarded-For, orX-Forwarded-Forwhen that is missing. Vercel overwrites these headers as requests come in, so there is nothing to configure. - Self-hosted, it reads
X-Forwarded-For(orX-Real-IPwhen that is missing) only whenCARTWRIGHT_TRUST_PROXY_IP_HEADERSistrueor1. Surrounding whitespace is trimmed and case is ignored, soTRUEcounts too; any other value, such asyesoron, does nothing — the variable then counts as unset. - Set it only behind a reverse proxy that overwrites
X-Forwarded-For. The first address in the header is the one used, and only if it is a valid IPv4 or IPv6 address; anything else counts as the unknown client. A proxy that appends to the header leaves whatever the client sent at the front, and a client that reaches the server directly writes the header itself — either way, a client could pick a fresh bucket with every request. - Without the variable (self-hosted), every visitor is the same unknown client, with or without a proxy in front: the global limiter (when Upstash is set), the public agent API, the authentication-attempt limit (so every API key at once), the contact form's three limits and the password reset each count the whole shop in one bucket, and the sign-up limit and the magic link's per-client limit stay off. A server that clients reach directly has no per-visitor setting that cannot be forged — setting the variable there would trust the client's own header — so put an overwriting proxy in front first, then set it.
From v0.63.0, a self-hosted server running in production (NODE_ENV=production, so not pnpm dev) without the variable logs a warning the first time a request needs a client's address, whether or not that request carries an X-Forwarded-For header. It is logged once per server process, not once per limit: proxy.ts and every engine route share one flag. A route a shop adds with runtime = "edge" runs in a sandbox of its own and may log it once more. It is not logged on Vercel (VERCEL=1), nor when CARTWRIGHT_TRUST_PROXY_IP_HEADERS is true or 1; any other value counts as unset, and the warning is still logged. The line starts [cartwright] Self-hosted without CARTWRIGHT_TRUST_PROXY_IP_HEADERS set to "true" or "1", names the limits that share a bucket — the contact form (its AI answer, its attachments and the message itself), password reset, the public agent API and the optional limit in proxy.ts — and ends Logged once per server process; see docs/self-hosting.md.