Per-plan AI quotas without shipping your API key

Give free and paid users different AI limits by routing model calls through your own gateway, metering usage in Postgres, and never embedding a key in the app.

Published
Reading time
8 min
Author
Edict

To give free and paid users different AI limits, put a small gateway between your app and the model provider. The app calls your gateway with the user’s session token. The gateway checks the user’s plan, increments a usage counter atomically in Postgres, and only then calls the model with a key that never leaves your server. Shipping the provider key inside the app cannot work, because anyone can extract it and spend your money.

This article walks through that pattern: why the key must stay on the server, what to meter, how to window quotas, how to count safely under concurrency, and what to tell the user when they run out.

Why can’t the app call the model API directly?

A key compiled into an iOS or Android binary, or placed in a React Native or Flutter bundle, is readable. Obfuscation slows a determined person down, but it does not stop them. Anyone can inspect traffic from their own device, and a call to a model provider carries the key in a header.

Once someone has the key, they can use it outside your app, at your cost, with no per-user limit. You also cannot tell the free user from the paid one, because the provider only sees one key.

So the call has to go through something you control. That something does three jobs:

  1. It authenticates the user, so you know who is asking.
  2. It decides whether this user may make this call now.
  3. It makes the call to the provider and returns the result.

What does the gateway pattern look like?

Keep the gateway thin. It can be an edge function, a route in your existing API, or a standalone service. The request path looks like this.

  1. The app sends a request to your endpoint with the user’s access token.
  2. The gateway validates the token and loads the user’s plan.
  3. The gateway reserves usage against the quota. If the quota is exhausted, it returns a clear error and stops.
  4. The gateway calls the provider with the server-held key.
  5. The gateway records the actual usage and returns the response.

A sketch in TypeScript shows the shape.

export async function handleChat(req: Request): Promise<Response> {
  const user = await authenticate(req); // 401 if missing or invalid
  const plan = await planFor(user.id); // 'free' | 'pro'
  const body = await parseBody(req); // validate size and fields

  const reservation = await reserve(user.id, plan, estimateTokens(body));
  if (!reservation.ok) {
    return json(
      { error: 'quota_exceeded', resetsAt: reservation.resetsAt },
      429,
    );
  }

  try {
    const result = await callModel(body); // key lives in server env only
    await settle(reservation.id, result.usage); // replace estimate with actual
    return json(result.output);
  } catch (err) {
    await release(reservation.id); // give the estimate back
    throw err;
  }
}

Three details matter. The gateway validates the request body, including its size, so a client cannot send a huge prompt that costs a lot. It reserves before it calls, so concurrent requests cannot all slip through. And it settles afterwards, so the counter reflects real usage and not only guesses.

What should you meter?

The right unit depends on what costs you money and what users understand.

Requests. Simple to explain: “20 messages per day.” It is a poor match for cost, because one request can be a short question or a long document.

Tokens. Model providers bill by tokens, so token counts track your cost closely. They are harder to explain to users, and you only know the output size after the call.

Credits. A common middle path is to define your own unit, a credit, and charge a different number of credits for different actions. A short chat turn costs one, an image or a long summary costs more. You can change the exchange rate without changing the app.

Pick one primary unit for enforcement and keep the raw numbers anyway. Store requests, input tokens, and output tokens in your usage rows. Later you may change the plan design, and old raw data lets you reason about it.

If you charge on tokens, you will need an estimate before the call, since the output length is unknown. A common approach is to reserve the input tokens plus a ceiling on output tokens, which you set with the provider’s maximum-output parameter. After the call, settle to the actual figure and return the difference.

How do quota windows work?

A quota needs a period. The usual options are:

  • Fixed calendar window. The quota resets at midnight UTC, or on the first of the month. Easy to build and easy to explain. The downside is a burst at the boundary: a user can spend a full quota at 23:59 and again at 00:01.
  • Billing-period window. The quota resets on the user’s subscription renewal date. This matches what the user paid for, but it means each user has a different reset time.
  • Rolling window. The quota counts usage over the last N hours. It avoids boundary bursts, but it needs a sum over recent rows, and users find it harder to predict.

For many apps, a daily fixed window for free users and a billing-period window for paid users is a sensible mix. Whatever you choose, store the window start in the row, so you can compute “resets at” for the message you show the user.

Handle time zones in one place. Keep windows in UTC in the database and convert only for display.

How do you count usage atomically in Postgres?

The most common bug in quota code is a read followed by a write. Two requests read “9 of 10 used” at the same time, both decide they are allowed, and both write. The user gets 11.

Postgres can do the check and the increment in one statement, using INSERT ... ON CONFLICT. Start with a counter row per user and window.

create table usage_counters (
  user_id      uuid not null references users (id),
  window_start timestamptz not null,
  used         bigint not null default 0,
  primary key (user_id, window_start)
);

Then reserve with a single upsert that only succeeds when there is room.

insert into usage_counters (user_id, window_start, used)
values ($1, $2, $3)
on conflict (user_id, window_start) do update
  set used = usage_counters.used + excluded.used
  where usage_counters.used + excluded.used <= $4   -- the plan limit
returning used;

If the statement returns a row, the reservation succeeded. If it returns nothing, the conflict branch’s where clause failed, and the user is over the limit. Because it is a single statement, Postgres takes a row lock for the update (see explicit locking), so concurrent requests are serialized on that row.

One edge case: on the very first request of a window the insert path runs with no conflict, so the where check does not apply. If a single request could exceed the limit on its own, add a check in your code, or insert a zero row first and then run the guarded update.

To give back an estimate after a failure, or to settle to the real figure, run an update that subtracts the difference, never below zero.

update usage_counters
set used = greatest(used - $3, 0)
where user_id = $1 and window_start = $2;

Keep an append-only usage_events table beside the counters with one row per call, holding the request id, the units, and the plan at the time. The counter is for fast enforcement. The events are for audit, support, and recomputing the counter if it ever drifts.

How do you handle retries and abuse?

Clients retry. Networks drop, users tap twice, and SDKs resend on timeouts. If every retry charges the quota, honest users lose allowance for nothing.

Ask the client to send an idempotency key, a unique id per logical request. Store it with the usage event, with a unique constraint. If the same key arrives again, return the earlier result or a pointer to it, and do not charge again. Also give the idempotency record a lifetime, so the table does not grow forever.

For abuse, quotas are a start but not the whole answer. A few measures help.

  • Rate limit per user and per IP, separate from the quota. Answer with HTTP 429 and a Retry-After header when a limit applies. A quota says how much per day. A rate limit says how fast. A script can burn a daily quota in seconds, which may hurt your provider limits.
  • Cap request size. Limit prompt length and the output ceiling you pass to the provider.
  • Watch account creation. If the free tier is generous, people will create many accounts. Tie free usage to something harder to multiply, such as a verified identity, or accept the cost and set the free quota with that in mind.
  • Have a kill switch. A single setting that disables an endpoint, or one user, is cheap to build and valuable on the day something goes wrong.

Also decide what happens when the provider call fails. Release the reservation, as in the code above, so users are not charged for errors.

How does the plan feed into the limit?

The gateway needs a fast answer to “what plan is this user on?” That answer should come from your entitlements data, which is itself driven by store notifications and not by the client. If the app sent its own plan name, a user could claim to be on the best plan.

Keep limits in a table or configuration, keyed by plan, not in code scattered across handlers.

{
  "free": { "window": "day", "units": 20, "maxOutputTokens": 500 },
  "pro": { "window": "period", "units": 1000, "maxOutputTokens": 2000 }
}

The numbers above are placeholders for illustration. Choose your own from your costs and your product.

When a user upgrades, the entitlement changes and the next request reads the new plan. Decide whether the upgrade resets the counter, grants the difference, or starts a fresh window. Any of these is fine if it is predictable and written down.

What should users see?

Quota errors are a product moment, so design them.

Return a structured error that the app can act on: a stable code such as quota_exceeded, the time the quota resets, and the user’s plan. Do not return a raw provider error.

In the app, show remaining allowance before the user hits zero. A small “12 of 20 left today” label turns a surprise into a choice. When the limit is reached, say when it resets, and, for free users, offer the upgrade in the same place. Keep the text honest: tell the user what they have used and what the paid plan changes.

Also return usage headers or a small GET /usage endpoint so the app can show the balance without making a model call.

How does this relate to Edict?

Edict is a mobile backend in development. Its planned AI gateway module is designed to cover secure provider requests, metering, per-user limits, and streaming, so that a gateway like this can be declared rather than hand-written. Plan-based access would come from the planned subscriptions module. These capabilities are planned, and the details may change.

The AI assistants and companion apps and AI photo and content tools examples both need per-plan AI limits. So do the AI-generated quizzes in education and flashcards, the AI plans in meal planning and recipes, and the AI suggestions in travel and itinerary planning. For the server-side plan check that feeds the limit, read why subscription entitlements belong on the server.

Key takeaways

  • Never embed a model provider key in the app. Route calls through a server you control.
  • The gateway authenticates, checks the plan, reserves quota, calls the provider, and settles.
  • Meter in the unit that matches your cost, and store raw tokens and requests as well.
  • Reserve and increment in one SQL statement so concurrent requests cannot exceed the limit.
  • Use idempotency keys so retries do not double-charge, and add rate limits and size caps against abuse.
  • Source the plan from server-side entitlements, and show users their remaining allowance and reset time.