Skip to Content
GuideSDK 3.0 — Backend AI Proxy

Your backend as an AI proxy

You want to keep monetize.software’s sign-in (Google, Apple, email/password) and not build your own auth — but your backend sits between the user and an AI provider (OpenAI/Anthropic), so it has to know who the authenticated user is before spending their tokens. This guide wires that up end-to-end: the browser signs in with our AuthClient, your backend verifies the session and debits credits.

The one idea to internalise: two credentials, two jobs.

  • The user’s Authorization: Bearer token proves who the user is. Your backend uses it only to verify identity — never to change a balance.
  • Your server X-Api-Key (sk_…) proves you’re the paywall owner. Your backend uses it to debit tokens.

A user’s Bearer token is deliberately powerless against the balance endpoint — otherwise a user could grant themselves free credits. So the flow is always: verify with the Bearer, then act with the apiKey.

Don’t pass the user’s email in the request body and trust it — it’s trivially spoofable. The Bearer token is the only thing the client can’t forge, because it’s signed by our auth.

The flow

Browser (AuthClient session) │ 1. token = await auth.getAccessToken() │ 2. POST /chat ── Authorization: Bearer <token> ──▶ YOUR BACKEND 3. GET /api/v1/paywall/{id}/user ── Authorization: Bearer <token> ──▶ monetize.software ◀── { user: { id, email }, paid, balances } ──────────────────────────┘ │ (401 ⇒ reject) 4. call OpenAI/Anthropic with YOUR provider key ◀──────────┤ 5. POST /api/v1/paywall/{id}/balances ── X-Api-Key ──▶ monetize.software body: { user_id, type, amount, op: 'debit' } │ 6. return AI response ──────────────────────────────────▶ Browser

Prerequisites

Tokenization is enabled on the paywall

This flow debits a token balance, so the paywall must have tokenization turned on with at least one token_type. The type you debit must match one of those. For a plain subscription paywall (no metering) you don’t need any of this — just read paid from step 3.

You have a server apiKey and a custom domain

  • apiKey — Dashboard → Settings → API keys → Create. Copy the sk_… once. Keep it server-only.
  • apiOrigin — your custom domain. It’s what the browser SDK is initialised with, and the host your backend calls.
PAYWALL_ID=3 PAYWALL_ORIGIN=https://pay.yourapp.com # your custom domain MONETIZE_API_KEY=sk_live_... # server-only, never in the bundle

Step 1 — get the token on the frontend

Your browser code already has an AuthClient (standalone, or via PaywallUI). Read a fresh token right before each call to your backend — getAccessToken() lazy-refreshes when the token is close to expiry, so don’t cache its result.

import { AuthClient } from '@monetize.software/sdk/core'; const auth = new AuthClient({ paywallId: 'pw_123', apiOrigin: 'https://pay.yourapp.com' }); async function askAI(prompt: string) { const token = await auth.getAccessToken(); if (!token) { openLogin(); return; } const res = await fetch('https://api.yourapp.com/chat', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: JSON.stringify({ prompt }) }); return res.json(); }

See Session management for the full getAccessToken / onAuthChange contract.

Step 2 — verify the token & resolve the user on your backend

Your backend takes the incoming Bearer and calls GET /api/v1/paywall/{id}/user with that same Bearer. The response’s user.id is the monetize.software auth id — that’s the id you’ll debit against. A 401 means the token is invalid or expired: reject the request.

In the Bearer path the identity comes entirely from the token — there are no query params and nothing from the request body is trusted. The user object (id, email, name) is only populated on this path; with an apiKey call it’s undefined.

Step 3 — call the AI provider, then debit

Your backend owns the provider key, so call OpenAI/Anthropic directly (no reason to route through our API Gateway — that exists for browser-only apps). After a successful response, debit the user’s balance with your apiKey and the user_id from step 2.

Debit after success, not before. If the AI call throws, you haven’t charged the user. The balance endpoint isn’t idempotent — every call debits — so if you add retries, dedup on your side (store an idempotency key next to each AI-call record and skip the debit if it’s already there).

Full example

The SDK wraps the debit; verification is a plain fetch (the SDK’s typed methods are for your apiKey identity, not for introspecting a third party’s Bearer).

// POST /chat — Express / any Node handler import { BillingClient, PaywallError } from '@monetize.software/sdk/core'; import OpenAI from 'openai'; const ORIGIN = process.env.PAYWALL_ORIGIN!; const PAYWALL_ID = process.env.PAYWALL_ID!; const openai = new OpenAI(); const billing = new BillingClient({ paywallId: PAYWALL_ID, apiOrigin: ORIGIN, apiKey: process.env.MONETIZE_API_KEY! }); app.post('/chat', async (req, res) => { // 1. pull the Bearer the browser attached const token = (req.headers.authorization || '').replace(/^Bearer /, ''); if (!token) return res.status(401).json({ error: 'no_token' }); // 2. verify it & resolve the monetize user.id const who = await fetch(`${ORIGIN}/api/v1/paywall/${PAYWALL_ID}/user`, { headers: { Authorization: `Bearer ${token}` } }); if (!who.ok) return res.status(401).json({ error: 'invalid_token' }); const { user, paid } = await who.json(); // optional: gate on subscription state too // if (!paid) return res.status(402).json({ error: 'no_subscription' }); // 3. call the AI provider with YOUR key const completion = await openai.chat.completions.create({ model: 'gpt-4', messages: [{ role: 'user', content: req.body.prompt }] }); // 4. debit AFTER success, against the verified user try { billing.setIdentity({ userId: user.id }); await billing.debitTokens({ type: 'gpt-4', amount: 1 }); } catch (e) { if (e instanceof PaywallError && e.code === 'insufficient') { return res.status(402).json({ error: 'out_of_credits' }); } throw e; } res.json({ answer: completion.choices[0].message.content }); });

Performance: the verify hop

Step 2 is one round-trip to us per request. For a high-QPS AI proxy you can cache the token → user.id resolution in memory, keyed by the token, with a short TTL (e.g. 30–60 s):

const cache = new Map(); // token → { userId, exp } async function resolveUser(token) { const hit = cache.get(token); if (hit && hit.exp > Date.now()) return hit.userId; const r = await fetch(`${ORIGIN}/api/v1/paywall/${PAYWALL_ID}/user`, { headers: { Authorization: `Bearer ${token}` } }); if (!r.ok) return null; const { user } = await r.json(); cache.set(token, { userId: user.id, exp: Date.now() + 60_000 }); return user.id; }

Caching trades freshness for latency: a signed-out user stays “valid” until the cache entry expires. Keep the TTL short and never longer than you’re comfortable serving a revoked session. We don’t expose a JWKS endpoint for offline JWT verification — calling /user is the supported way to validate a session, and it reflects revocation immediately when you don’t cache.

Why not…

Tempting shortcutWhy it’s wrong
Send email in the body, trust itSpoofable — any client can claim any email. Verify the Bearer instead.
Debit with the user’s BearerThe balance endpoint rejects Bearer by design (apikey_required) — users must not move their own balance. Use the owner apiKey.
Use the Bearer to authorise your own APIIt’s a session for our API. Use it to resolve identity, then run your own request authorisation however you like.
Route the AI call through our API GatewayThe gateway exists so browser-only apps avoid shipping provider keys. Your backend already has the keys — calling it adds a hop for nothing.

See also