Once your AI API is working, the next challenge is billing customers based on actual usage. The naive approach — billing per month for a flat tier — leaves money on the table and frustrates customers who use less. Usage-based billing with Stripe and Zuplo lets you charge exactly for what customers consume: tokens, API calls, or AI operations.
This guide walks through metering API usage in Zuplo, syncing usage data to Stripe, and building a lightweight billing dashboard.
Architecture Overview
| Component | Role |
|---|---|
| Zuplo gateway | Rate limiting, API key auth, request counting |
| Zuplo plugins | Webhooks on every request for metering |
| Stripe Meters | Aggregate usage events for billing |
| Stripe Billing | Invoice customers based on aggregated usage |
| Your backend | Business logic, no billing concerns |
Step 1: Set Up Stripe Usage Metering
// Create a Stripe Meter for API calls const meter = await stripe.billing.meters.create({ display_name: "AI API Calls", event_name: "ai_api_call", default_aggregation: { formula: "sum" }, customer_mapping: { type: "by_id", event_payload_key: "stripe_customer_id", }, }); // Create a pricing plan linked to the meter const price = await stripe.prices.create({ currency: "usd", product: "prod_xxxxxxxxxxxx", billing_scheme: "per_unit", unit_amount: 1, // $0.01 per API call recurring: { interval: "month", usage_type: "metered", meter: meter.id, }, });
Step 2: Report Usage from Zuplo
Create a Zuplo outbound policy that fires a webhook after every successful request. This webhook reports usage to Stripe:
// modules/usage-reporter.ts import { ZuploContext, ZuploRequest } from "@zuplo/runtime"; export async function reportUsage( response: Response, request: ZuploRequest, context: ZuploContext, policyName: string ): Promise<Response> { // Only meter successful requests if (response.ok) { const consumerId = context.consumer?.name; const stripeCustomerId = context.consumer?.metadata?.stripeCustomerId; if (stripeCustomerId) { // Fire and forget — don't block the response context.waitUntil( fetch("https://api.stripe.com/v1/billing/meter_events", { method: "POST", headers: { Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}`, "Content-Type": "application/x-www-form-urlencoded", }, body: new URLSearchParams({ event_name: "ai_api_call", "payload[stripe_customer_id]": stripeCustomerId, "payload[value]": "1", }), }) ); } } return response; }
{ "name": "usage-reporter-outbound", "policyType": "custom-code-outbound", "handler": { "export": "reportUsage", "module": "$import(./modules/usage-reporter)" } }
Step 3: Token-Based Metering
For LLM APIs, charging per token is more accurate than per request. Your backend can return token counts in response headers:
// Your Next.js backend — return token usage in headers export async function POST(req: Request) { const { messages } = await req.json(); const response = await openai.chat.completions.create({ model: "gpt-4o-mini", messages, }); const tokensUsed = response.usage?.total_tokens ?? 0; return new Response( JSON.stringify({ content: response.choices[0].message.content }), { headers: { "Content-Type": "application/json", "X-Tokens-Used": tokensUsed.toString(), // Zuplo reads this }, } ); }
// modules/token-reporter.ts — Zuplo outbound policy export async function reportTokenUsage( response: Response, request: ZuploRequest, context: ZuploContext ): Promise<Response> { const tokensUsed = parseInt(response.headers.get("X-Tokens-Used") ?? "0"); const stripeCustomerId = context.consumer?.metadata?.stripeCustomerId; if (stripeCustomerId && tokensUsed > 0) { context.waitUntil( fetch("https://api.stripe.com/v1/billing/meter_events", { method: "POST", headers: { Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}` }, body: new URLSearchParams({ event_name: "ai_tokens_used", "payload[stripe_customer_id]": stripeCustomerId, "payload[value]": tokensUsed.toString(), }), }) ); } // Remove internal header before sending to client const cleanHeaders = new Headers(response.headers); cleanHeaders.delete("X-Tokens-Used"); return new Response(response.body, { ...response, headers: cleanHeaders }); }
Step 4: Provision API Keys on Stripe Checkout
// app/api/billing/checkout/route.ts export async function POST(req: Request) { const { userId } = await getAuthFromRequest(req); const user = await getUser(userId); // Create Stripe customer if not exists let stripeCustomerId = user.stripeCustomerId; if (!stripeCustomerId) { const customer = await stripe.customers.create({ email: user.email }); stripeCustomerId = customer.id; await updateUser(userId, { stripeCustomerId }); } // Create Stripe Checkout session const session = await stripe.checkout.sessions.create({ customer: stripeCustomerId, line_items: [{ price: "price_xxxxxxxxxxxx", quantity: 1 }], mode: "subscription", success_url: `${process.env.NEXT_PUBLIC_URL}/dashboard?subscribed=true`, cancel_url: `${process.env.NEXT_PUBLIC_URL}/pricing`, }); return NextResponse.redirect(session.url!); } // After Stripe confirms payment — webhook handler // app/api/billing/webhook/route.ts export async function POST(req: Request) { const event = stripe.webhooks.constructEvent( await req.text(), req.headers.get("stripe-signature")!, process.env.STRIPE_WEBHOOK_SECRET! ); if (event.type === "customer.subscription.created") { const subscription = event.data.object; const customerId = subscription.customer as string; // Issue a Zuplo API key linked to this Stripe customer await fetch(`https://api.zuplo.com/v1/accounts/${ZUPLO_ACCOUNT}/key-buckets/${BUCKET}/consumers`, { method: "POST", headers: { Authorization: `Bearer ${process.env.ZUPLO_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ name: `customer-${customerId}`, metadata: { stripeCustomerId: customerId, plan: "pro" }, }), }); } }
Set up a Stripe dashboard meter chart to monitor usage in real time. Share a read-only link with customers in your developer portal so they can track their own consumption.