Customer API and MCP usage limits
Each user may make 500 customer API requests and 500 MCP tool calls per UTC day in prod. The limits stop abuse and runaway integrations or agents; they are not a billing meter. Tracked in issue 689.
The limits
- Customer API (
/v3/customeron the Flaskapiservice):CUSTOMER_API_DAILY_LIMIT, 500 in prod, 5000 on staging and test, 100000 in local docker-compose. Usage history (each user's daily request count inapi_usage) is kept for 90 days, then removed automatically. - MCP server (
mcp-server):MCP_DAILY_LIMIT, with the same values per environment. Usage history (each user's daily tool call count inmcp_usage) is kept for 90 days, then removed automatically.
The limit is per user (the owner of the API key or MCP key), not per client account. Every user is a paid seat, so a customer cannot multiply the limit for free. Days are fixed UTC days: every count resets at 00:00 UTC, whatever the customer's time zone.
Staging and test carry 5000 so repeated E2E runs by the shared test users never reach the limit.
What counts
- Customer API: every request made with a valid key, including a
400or404. A401(bad key) or403(no active subscription) is not counted, because the counter runs after those checks. Refused429requests keep counting, so a count above the limit shows how hard a client kept trying after it was refused. - MCP server: only
tools/call.initialize,tools/list,ping, the session stream and the session delete never count, so a client over the limit can still connect and receive the explanation. A call to an unknown tool is not counted.
What the user sees
- Customer API: HTTP
429with a message that states the limit and the reset time, pluslimit,reset_atandretry_after_secondsin the body. Every keyed response carriesX-RateLimit-Limit,X-RateLimit-RemainingandX-RateLimit-Reset; a429addsRetry-After. The public page is Errors and limits. - MCP server: an
isErrortool result whose text states the limit and the reset time and tells the assistant not to retry. It is not an HTTP 429, because MCP clients surface a 429 as a connection error and some retry it.whoamireportsdaily_limit,used_todayandresets_at. - Settings page: both key cards show "Used today: N of 500 ... Resets at midnight UTC." once a key exists.
Where the counters live
Three Mongo collections in nexgen_mdb, each holding one document per user per UTC day with a count:
api_usage: customer API requests, written by the Flask api. Fields_id("nexgen:<user_id>:<day>"),app,user_id,day,count,created_at.mcp_usage: MCP tool calls, written by the mcp-server. Same fields.ai_usage: AI calls only (the label ingredient translation cap). Its documents also carry afeature. Usage history is kept for 90 days, then removed automatically, like the other two. Documents written before October 2026 have nocreated_at, so they are never removed automatically; that includes the customer API's old counts here underfeature: "customer_api", which were left in place.
app is always nexgen: both services only accept keys from the paid database. Each write is one atomic findOneAndUpdate on the _id with $inc and upsert, no aggregation. Keying on _id is what stops two concurrent first requests of the day from creating two documents: the _id index always exists and is always unique, so it holds from the first request, whether or not the indexes below have been created.
Indexes, all created by nexgen/backend/create_indexes.php:
api_usageandmcp_usage:{day}for the admin page, and a TTL index oncreated_atthat removes documents after 90 days (expireAfterSeconds: 7776000), so a day more than 90 days back shows no usage. They need no unique index of their own (see the_idabove).ai_usage: non-unique{app, user_id, feature, day}, and the same 90-day TTL index oncreated_at. The first is not unique because older documents may already hold duplicates, and merging them would mean deleting some.
Read a user's usage
The quickest view is the admin API Usage page at /admin/usage (Axxya staff only): every user with customer API requests or MCP tool calls on a chosen UTC day, highest first, 100 rows to a page, with their account, the count, the current limit, and how many requests were refused. Rows over the limit are highlighted. The User box finds one user by their exact email address or user id. Earlier days are compared with today's limits. Counts are kept for 90 days.
From the bastion, read-only (see Nexgen V3 databases for the connection):
db.api_usage.find({ _id: 'nexgen:1234:2026-10-25' })
db.mcp_usage.find({ app: 'nexgen', user_id: 1234 }).sort({ day: -1 }).limit(7)
db.api_usage.find({ day: '2026-10-25' }).sort({ count: -1 }).limit(20)
Replace 1234 with the user's id from the MySQL users table, and the day with the UTC day you need.
Change a limit
The limits are environment variables, so a change is a manifest edit and a deploy, with no code change. Each variable is set in two manifests that must agree:
CUSTOMER_API_DAILY_LIMITincopilot/api/manifest.yml(enforces it) andcopilot/nexgen-backend/manifest.yml(shows it on the Settings card and the admin page).MCP_DAILY_LIMITincopilot/mcp-server/manifest.yml(enforces it) andcopilot/nexgen-backend/manifest.yml.
The prod value is the top-level variables entry; staging and test override it in their environments blocks. Merge to main for staging, and deploy prod by hand (./deploy.sh prod api nexgen-backend or ./deploy.sh prod mcp-server nexgen-backend). There is no per-user override today; a customer who needs more is a code change.
The MCP server's Mongo grant
The mcp-server connects to DocumentDB as mcp_readonly, which has the built-in read role. To write its counter it also needs the mcpUsageCounter role, which may find, insert and update the mcp_usage collection and nothing else. Create and grant it once per environment from the bastion:
copilot svc exec --app nexgen-v3 --env staging --name bastion
mongosh "mongodb://$MDB_USERNAME:$MDB_PASSWORD@$MDB_HOST:27017/?authSource=$MDB_AUTH_DB&tls=false" --eval "
db.getSiblingDB('admin').createRole({ role: 'mcpUsageCounter', privileges: [ { resource: { db: 'nexgen_mdb', collection: 'mcp_usage' }, actions: ['find', 'insert', 'update'] } ], roles: [] });
db.getSiblingDB('admin').grantRolesToUser('mcp_readonly', [ { role: 'mcpUsageCounter', db: 'admin' } ])"
Without the role the MCP server keeps working but does not enforce its limit: each tool call logs [mcp-server] usage counter write failed; the call is allowed but not counted, and whoami reports used_today: 0. Check with:
copilot svc logs --name mcp-server --env staging --since 30m | grep "usage counter write failed"
The Flask api writes api_usage as flask_writer, which already has readWrite on nexgen_mdb, so it needs no new grant. Local Mongo runs without access control and needs nothing.
Create the indexes
On staging and test, create_indexes.php runs in the nexgen-backend entrypoint every time the container starts, so merging to main creates the indexes there, the TTL index included. On prod it is a hand step. Run it after nexgen-backend is deployed and before the api and mcp-server are, so the TTL and {day} indexes exist before the first counter document:
copilot svc exec --app nexgen-v3 --env prod --name nexgen-backend --command "sh -c 'su -s /bin/sh www-data -c \"php /var/www/html/create_indexes.php\"'"
The script prints a line per index and still exits successfully when one fails, so read its output for api_usage and mcp_usage. To confirm, read-only from the bastion:
db.api_usage.getIndexes()
db.mcp_usage.getIndexes()
db.ai_usage.getIndexes()
api_usage and mcp_usage should each list _id_, day_1 and created_at_1 (with expireAfterSeconds: 7776000); ai_usage should list _id_, app_1_user_id_1_feature_1_day_1 and the same created_at_1. The counters are correct without these indexes, because each write is keyed on _id; without {day} the admin page scans the collection, and without the TTL index old documents stay.
When a customer reports a 429
- Open
/admin/usagefor the day in question (the 429 message names the UTC reset time, which tells you the day) and enter the user's email in the User box. A count far above the limit usually means an integration that retries in a loop. - Tell the customer the limit (500 requests per user per UTC day), when it resets, and that refused requests also count, so retrying straight away makes no progress.
- Point them at the ways to stay within the limit on the public Errors and limits page: pages of 200,
view=full,updated_since, and caching ingredients. - If their use is genuine and still needs more, raise it with the team; the limit can only be changed for everyone today.