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.