Charge customers based on how much they use your API. This recipe shows how to track usage with Unkey's credits system and integrate with billing providers like Stripe.
The pattern#
// Create a key with monthly creditsconst key = await unkey.keys.createKey({ apiId: "api_xxx", credits: { remaining: 10000, // 10,000 API calls included refill: { interval: "monthly", amount: 10000, }, },});// Each verification decrements remainingconst { meta, data } = await unkey.keys.verifyKey({ key: userKey });if (!data.valid && data.code === "USAGE_EXCEEDED") { // Prompt user to upgrade or pay for overage}// Check remaining creditsconsole.log(data.credits); // 9,999 after first callFull implementation#
Setting up usage tracking#
// lib/unkey.tsimport { Unkey } from "@unkey/api";const unkey = new Unkey({ rootKey: process.env.UNKEY_ROOT_KEY! });interface CreateBillingKeyOptions { customerId: string; plan: "starter" | "growth" | "scale"; stripeCustomerId?: string;}const PLAN_CREDITS = { starter: 1_000, growth: 10_000, scale: 100_000,};export async function createBillingKey(options: CreateBillingKeyOptions) { const credits = PLAN_CREDITS[options.plan]; // The SDK throws on failure, so any error propagates to the caller. const { data } = await unkey.keys.createKey({ apiId: process.env.UNKEY_API_ID!, externalId: options.customerId, // Link to your user/org name: `${options.plan} plan`, credits: { remaining: credits, refill: { interval: "monthly", amount: credits, }, }, meta: { plan: options.plan, stripeCustomerId: options.stripeCustomerId, createdAt: new Date().toISOString(), }, }); return data;}API route with usage tracking#
// app/api/route.tsimport { Unkey } from "@unkey/api";import { NextResponse } from "next/server";const unkey = new Unkey({ rootKey: process.env.UNKEY_ROOT_KEY! });export async function POST(request: Request) { const apiKey = request.headers.get("authorization")?.replace("Bearer ", ""); if (!apiKey) { return NextResponse.json({ error: "Missing API key" }, { status: 401 }); } let data; try { ({ data } = await unkey.keys.verifyKey({ key: apiKey })); } catch { return NextResponse.json({ error: "Verification failed" }, { status: 500 }); } if (!data.valid) { if (data.code === "USAGE_EXCEEDED") { return NextResponse.json( { error: "Usage limit exceeded", remaining: 0, message: "Please upgrade your plan or wait for monthly reset", }, { status: 402 }, // Payment Required ); } return NextResponse.json( { error: "Invalid API key", code: data.code }, { status: 401 }, ); } // Include usage info in response headers const response = NextResponse.json({ success: true, // Your API response... }); response.headers.set("X-Usage-Remaining", (data.credits ?? 0).toString()); return response;}Stripe integration for overages#
// lib/billing.tsimport Stripe from "stripe";import { Unkey } from "@unkey/api";const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);const unkey = new Unkey({ rootKey: process.env.UNKEY_ROOT_KEY! });// Record overage usage in Stripeexport async function recordOverage(keyId: string, amount: number) { // Get the key to find the Stripe customer const { data: key } = await unkey.keys.getKey({ keyId }); if (!key?.meta?.stripeCustomerId) { throw new Error("No Stripe customer linked to this key"); } // Find or create a usage-based subscription item const subscriptions = await stripe.subscriptions.list({ customer: key.meta.stripeCustomerId as string, status: "active", }); const subscription = subscriptions.data[0]; if (!subscription) { throw new Error("No active subscription"); } // Find the metered price item const meteredItem = subscription.items.data.find( (item) => item.price.recurring?.usage_type === "metered", ); if (meteredItem) { // Report usage to Stripe await stripe.subscriptionItems.createUsageRecord(meteredItem.id, { quantity: amount, timestamp: Math.floor(Date.now() / 1000), action: "increment", }); }}// Webhook handler for when credits run outexport async function handleUsageExceeded(keyId: string) { const { data: key } = await unkey.keys.getKey({ keyId }); if (key?.meta?.stripeCustomerId) { // Option 1: Add overage credits and bill later await unkey.keys.updateKey({ keyId, credits: { remaining: 1000, // Grant overage allowance }, }); await recordOverage(keyId, 1000); // Option 2: Or just notify and let them upgrade // await sendUpgradeEmail(key.meta.email); }}Usage dashboard endpoint#
// app/api/usage/route.tsimport { Unkey } from "@unkey/api";import { NextResponse } from "next/server";const unkey = new Unkey({ rootKey: process.env.UNKEY_ROOT_KEY! });export async function GET(request: Request) { const customerId = request.headers.get("x-customer-id"); if (!customerId) { return NextResponse.json({ error: "Missing customer ID" }, { status: 401 }); } // Get all keys for this customer const { data } = await unkey.apis.listKeys({ apiId: process.env.UNKEY_API_ID!, externalId: customerId, }); if (!data.length) { return NextResponse.json({ error: "No keys found" }, { status: 404 }); } const key = data[0]; const remaining = key.credits?.remaining ?? 0; const limit = key.credits?.refill?.amount ?? 0; return NextResponse.json({ plan: key.meta?.plan ?? "starter", usage: { remaining, limit, used: limit - remaining, refillInterval: key.credits?.refill?.interval ?? null, refillDay: key.credits?.refill?.refillDay ?? null, }, });}Plan upgrades#
When a customer upgrades, update their key:
export async function upgradePlan( keyId: string, newPlan: "starter" | "growth" | "scale",) { const newCredits = PLAN_CREDITS[newPlan]; // Get current usage const { data: currentKey } = await unkey.keys.getKey({ keyId }); const currentRemaining = currentKey.credits?.remaining ?? 0; const currentRefillAmount = currentKey.credits?.refill?.amount ?? 0; // Option 1: Add the difference (pro-rated) const creditsToAdd = newCredits - currentRefillAmount; await unkey.keys.updateKey({ keyId, credits: { remaining: currentRemaining + creditsToAdd, refill: { interval: "monthly", amount: newCredits, }, }, meta: { ...currentKey.meta, plan: newPlan, upgradedAt: new Date().toISOString(), }, }); // Option 2: Reset to new plan's full credits // await unkey.keys.updateKey({ // keyId, // credits: { // remaining: newCredits, // refill: { interval: "monthly", amount: newCredits }, // }, // });}Multi-resource tracking#
Track different types of usage separately:
// Create a key with multiple rate limits as credit poolsconst { data } = await unkey.keys.createKey({ apiId: process.env.UNKEY_API_ID!, externalId: customerId, credits: { remaining: 10000, // General API calls }, ratelimits: [ { name: "ai-tokens", limit: 100000, duration: 2592000000 }, // 30 days { name: "storage-mb", limit: 5000, duration: 2592000000 }, { name: "exports", limit: 100, duration: 2592000000 }, ], meta: { plan: "growth" },});// Check specific resource usage on verifyconst { data: verification } = await unkey.keys.verifyKey({ key: userKey, ratelimits: [{ name: "ai-tokens", cost: tokenCount }],});if ( verification.ratelimits?.find((r) => r.name === "ai-tokens")?.remaining === 0) { return { error: "AI token quota exceeded" };}Best practices#
Use refill wisely
Monthly refills keep usage tracking simple. For different billing cycles, adjust the interval.
Handle overages gracefully
Don't just cut users off. Offer overage billing or soft limits with warnings.
Show usage prominently
Let users see their usage in your dashboard. Nobody likes surprise bills.
Sync with your billing provider
Use webhooks to keep Stripe/Paddle usage records in sync with Unkey.