Quota
/ docs
Dashboard
Docs/Recipes/Connect Quota Wallet

Connect Quota Wallet

Add Quota AI credits to an app that already has users. Your existing login stays put; users click Connect Quota Wallet and from then on AI usage bills their own balance, not yours.

Is this the right recipe?

You want this if your app already has auth (NextAuth, Clerk, Passport, a custom user table) and just needs spendable credits attached to each user. If you don't have auth yet and want Quota to provide it, use Sign in with Quota instead.

01 Register an OAuth app

Open /dashboard/sign-in-with-quota and register a new app, or use the API. You'll get a client_id and a one-time-visible client_secret.

Set two things at registration time:

  • Redirect URI: http://localhost:5180/auth/quota/callback in dev, https://yourapp.com/auth/quota/callback in prod. The value must match exactly what your server sends to /oauth/authorize (including the port).
  • Allowed scopes: credits.read credits.spend. credits.spend lets the user's wallet pay for chat completions; credits.read lets you show their balance. If you skip credits.read and try to display a balance pill, you'll hit 403 insufficient_scope.
# Use a developer session token from POST /auth/login
curl -X POST https://api.usequota.ai/developers/apps \
  -H "Authorization: Bearer sess_…" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "My App",
    "redirect_uris": ["http://localhost:5180/auth/quota/callback"],
    "allowed_scopes": ["credits.read", "credits.spend"]
  }'

# → { "client_id": "quota_client_…", "client_secret": "quota_secret_…", … }
# The client_secret is only shown once. Capture it.
Don't request openid/profile/email here

Wallet-linking is authorization, not identity — your native login already knows who the user is. Asking for OIDC identity scopes forces a longer consent screen and gives you data you don't need. Reserve openid profile email for Sign in with Quota.

redirect_uri must match exactly — and HTTPS in production

Quota does exact-string matching on the redirect URI. The dev examples use http://localhost / http://127.0.0.1 (both are accepted for local dev); production URIs must use HTTPS. No wildcards. Register every URI your app might callback to — dev and prod — on the same OAuth client.

02 Set environment variables

Three required vars. The rest have sensible defaults — the callback URL derives from the incoming request, the API base URL defaults to production, and scopes default to the wallet-only set.

# Required
QUOTA_CLIENT_ID=quota_client_…
QUOTA_CLIENT_SECRET=quota_secret_…
SESSION_SECRET=any-random-32-char-string
Why the callback URL isn't required

With app.set("trust proxy", true), Express builds the callback URL from req.protocol and req.get("host") — same value in dev (http://localhost:5180) and prod (https://yourapp.com). You only need to register each environment's URL on your Quota OAuth app.

03 Add the OAuth routes

Two routes: /auth/quota/connect kicks off the OAuth round-trip; /auth/quota/callback exchanges the code for tokens and stores them on the user's native session.

3.1 Start the round-trip

Build the authorize URL with response_type=code, yourclient_id, your exact redirect_uri, scope="credits.read credits.spend", a random state, and a PKCE code_challenge bound to the session.

import crypto from "node:crypto";

// Trust the reverse proxy so req.protocol returns "https" in production.
app.set("trust proxy", true);

function callbackUrl(req) {
  return `${req.protocol}://${req.get("host")}/auth/quota/callback`;
}

app.get("/auth/quota/connect", (req, res) => {
  if (!req.session?.user) {
    return res.status(401).json({ error: "Sign in first." });
  }
  const state = crypto.randomBytes(32).toString("hex");
  const redirectUri = callbackUrl(req);

  // PKCE: generate a random verifier, hash it to a challenge, stash the
  // verifier on the session for the callback to replay on /oauth/token.
  const verifier = crypto.randomBytes(32).toString("base64url");
  const challenge = crypto.createHash("sha256").update(verifier).digest("base64url");

  req.session.oauthState = state;
  req.session.redirectUri = redirectUri;
  req.session.oauthVerifier = verifier;

  const u = new URL("/oauth/authorize", process.env.QUOTA_BASE_URL || "https://api.usequota.ai");
  u.searchParams.set("response_type", "code");
  u.searchParams.set("client_id", process.env.QUOTA_CLIENT_ID);
  u.searchParams.set("redirect_uri", redirectUri);
  u.searchParams.set("scope", "credits.read credits.spend");
  u.searchParams.set("state", state);
  u.searchParams.set("code_challenge", challenge);
  u.searchParams.set("code_challenge_method", "S256");
  res.redirect(u.toString());
});

3.2 Handle the callback

Quota redirects to your redirect_uri with ?code=…&state=…. Verify the state, then POST the code to /oauth/token to exchange it for an access + refresh token pair.

app.get("/auth/quota/callback", async (req, res) => {
  const { code, state, error } = req.query;
  if (error)                             return res.redirect(`/?quota_error=${error}`);
  if (!code || !state)                   return res.redirect("/?quota_error=invalid_callback");
  if (state !== req.session?.oauthState) return res.redirect("/?quota_error=state_mismatch");

  // The redirect_uri sent here MUST match what we sent to /authorize.
  // We stashed it (and the PKCE verifier) on the session at connect time.
  const redirectUri = req.session.redirectUri;
  const codeVerifier = req.session.oauthVerifier;
  req.session.oauthState = undefined;
  req.session.redirectUri = undefined;
  req.session.oauthVerifier = undefined;

  const r = await fetch(`${process.env.QUOTA_BASE_URL || "https://api.usequota.ai"}/oauth/token`, {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "authorization_code",
      code: String(code),
      redirect_uri: redirectUri,
      client_id: process.env.QUOTA_CLIENT_ID,
      client_secret: process.env.QUOTA_CLIENT_SECRET,
      code_verifier: codeVerifier,
    }),
  });
  if (!r.ok) return res.redirect("/?quota_error=token_exchange_failed");

  const t = await r.json();
  // Demo: store on the cookie session. Production: store in your DB
  // keyed by your user id (see section 05).
  req.session.quota = {
    access_token: t.access_token,
    refresh_token: t.refresh_token,
    expires_at: Date.now() + t.expires_in * 1000,
  };
  res.redirect("/");
});

04 Bill the user's wallet

Chat completions and balance reads both use the user's access_token as the bearer. Same OpenAI SDK, only the key changes.

import OpenAI from "openai";

app.post("/api/chat", async (req, res) => {
  const token = await getQuotaToken(req);    // refresh if needed
  if (!token) return res.status(401).json({ error: "Wallet not connected." });

  const client = new OpenAI({
    apiKey: token,                            // user's quota_token_… token
    baseURL: `${process.env.QUOTA_BASE_URL}/v1`,
  });

  const completion = await client.chat.completions.create({
    model: req.body.model ?? "gpt-4o-mini",
    messages: req.body.messages ?? [],
  });
  res.json(completion);
});
Two URLs, one host

OAuth (/oauth/authorize, /oauth/token) uses the root URL. Model calls use {QUOTA_BASE_URL}/v1. Don't set the OpenAI SDK baseURL to the root — it'll 404 on POST /chat/completions. Don't set it to /v1/v1 either; that's the other half of the same footgun.

That's the happy path

Steps 01–04 are a complete working integration. Paste the snippets above into a fresh project, set QUOTA_CLIENT_ID and QUOTA_CLIENT_SECRET in .env, run it, and a user can connect their Quota wallet end-to-end. The rest of this page is production hardening — skip on the first read.


Production hardening

Everything below is needed before you ship to real users, but you can skip it on the first read-through. The Next.js SDK handles most of it (refresh, single-flight, encrypted token storage) for you; the Express snippets show what to do without the SDK.

Token refresh

Access tokens are short-lived (typically one hour). Before each billed request, check expiry and exchange the refresh token if the access token is near its end of life. The Next.js SDK does this automatically; the Express recipe needs an explicit helper.

async function getQuotaToken(req) {
  const t = req.session?.quota;
  if (!t) return null;
  if (Date.now() < t.expires_at - 30_000) return t.access_token;

  const r = await fetch(`${process.env.QUOTA_BASE_URL}/oauth/token`, {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "refresh_token",
      refresh_token: t.refresh_token,
      client_id: process.env.QUOTA_CLIENT_ID,
      client_secret: process.env.QUOTA_CLIENT_SECRET,
    }),
  });
  if (!r.ok) { req.session.quota = undefined; return null; }
  const fresh = await r.json();
  req.session.quota = {
    access_token: fresh.access_token,
    refresh_token: fresh.refresh_token,
    expires_at: Date.now() + fresh.expires_in * 1000,
  };
  return fresh.access_token;
}

Production token storage

The Express snippet above stashes the token pair in a signed cookie session. That's fine for local dev and the runnable example, but for production:

  • Move tokens to your database, keyed by your user id. Refresh tokens are long-lived and shouldn't live in a browser cookie.
  • Encrypt at rest. Tokens are bearer credentials — treat them like API keys, not session IDs.
  • Single-flight refresh. Two concurrent requests from the same user can both try to refresh and one will lose. Wrap the refresh call in a per-user lock or a single in-flight promise.
  • HTTPS-only cookies. Any session cookie you do keep should be Secure + HttpOnly + SameSite=Lax in production.

Troubleshooting

ErrorWhereFix
invalid_scope/oauth/authorizeThe scope isn't in this app's allowed_scopes. Add it at create time (this recipe's step 01) or PATCH /developers/apps/:id later.
invalid_redirect_uri/oauth/authorizeThe redirect_uri on the request must exactly match one registered on the app — same scheme, host, port, and path.
state_mismatch/auth/quota/callbackSession lost between the redirect to Quota and the callback. On http://localhost, make sure cookies don't have Secure set (browsers drop them on plaintext origins).
403 insufficient_scope/v1/balance, /v1/chat/completionsThe user's token is missing credits.read (for balance) or credits.spend (for chat). Re-run connect with both in scope.
402 insufficient_credits/v1/chat/completionsUser's wallet is empty. Trigger a top-up — either render <QuotaBuyCredits /> from the Next.js SDK or redirect to your own Stripe Checkout integration.
401 invalid_token/oauth/tokenCode expired or already used. Codes are single-use. Make sure the same redirect_uri is sent at authorize and token, and that you exchange the code before the user clicks away.