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.
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/callbackin dev,https://yourapp.com/auth/quota/callbackin prod. The value must match exactly what your server sends to/oauth/authorize(including the port). - Allowed scopes:
credits.read credits.spend.credits.spendlets the user's wallet pay for chat completions;credits.readlets you show their balance. If you skipcredits.readand try to display a balance pill, you'll hit403 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.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-string03 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);
});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=Laxin production.
Troubleshooting
| Error | Where | Fix |
|---|---|---|
invalid_scope | /oauth/authorize | The 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/authorize | The redirect_uri on the request must exactly match one registered on the app — same scheme, host, port, and path. |
state_mismatch | /auth/quota/callback | Session 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/completions | The 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/completions | User'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/token | Code 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. |