Loading...
Loading...
Implement usage-based billing with Flowglad including recording usage events, checking balances, and displaying usage information. Use this skill when adding metered billing, tracking API calls, or implementing consumption-based pricing.
npx skill4agent add flowglad/skills flowglad-usage-trackinguseBilling().createUsageEventamount1transactionIdsubscriptionId'use client'
import { useBilling } from '@flowglad/nextjs'
function RecordUsageButton({ usageMeterSlug }: { usageMeterSlug: string }) {
const billing = useBilling()
const handleClick = async () => {
if (!billing.createUsageEvent) return
const result = await billing.createUsageEvent({
usageMeterSlug,
// amount defaults to 1
// transactionId auto-generated
// subscriptionId auto-inferred
})
if ('error' in result) {
console.error('Failed to record usage:', result.error)
return
}
console.log('Usage recorded:', result.usageEvent.id)
}
return <button onClick={handleClick}>Use Feature</button>
}const result = await billing.createUsageEvent({
usageMeterSlug: 'api-calls',
amount: 5, // Override default of 1
// transactionId and subscriptionId still auto-handled
})billing.reload()await billing.createUsageEvent({ usageMeterSlug: 'generations' })
await billing.reload() // Refresh to show updated balanceflowglad(userId).createUsageEventsubscriptionIdtransactionIdamount// API route - app/api/generate/route.ts
import { flowglad } from '@/lib/flowglad'
import { auth } from '@/lib/auth'
export async function POST(req: Request) {
const session = await auth()
if (!session?.user?.id) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
// Get billing to find subscriptionId
const billing = await flowglad(session.user.id).getBilling()
const subscriptionId = billing.currentSubscription?.id
if (!subscriptionId) {
return Response.json({ error: 'No active subscription' }, { status: 402 })
}
// Perform the operation
const result = await generateContent()
// Record usage with explicit parameters
await flowglad(session.user.id).createUsageEvent({
usageMeterSlug: 'generations',
amount: 1,
subscriptionId,
transactionId: `gen_${result.id}`,
})
return Response.json(result)
}| Use Case | Recommended Approach | Why |
|---|---|---|
| Simple button click tracking | Client-side | Smart defaults make it easy |
| Feature usage counters | Client-side | No server round-trip needed |
| AI generation / expensive operations | Server-side | Track atomically with operation |
| API endpoint metering | Server-side | Already on server |
| Operations that cost you money | Server-side | Ensure tracking happens |
| Quick prototyping | Client-side | Fewer files to create |
export async function POST(req: Request) {
const session = await auth()
const billing = await flowglad(session.user.id).getBilling()
// 1. Check balance first
const balance = billing.checkUsageBalance('generations')
if (!balance || balance.availableBalance <= 0) {
return Response.json({ error: 'No credits' }, { status: 402 })
}
// 2. Perform expensive operation
const result = await openai.images.generate({ prompt })
// 3. Record usage (atomic with operation)
await flowglad(session.user.id).createUsageEvent({
usageMeterSlug: 'generations',
amount: 1,
subscriptionId: billing.currentSubscription!.id,
transactionId: `gen_${result.data[0].url}`,
})
return Response.json(result)
}function FeatureButton() {
const billing = useBilling()
const handleUse = async () => {
// Track usage - smart defaults handle the rest
await billing.createUsageEvent({ usageMeterSlug: 'feature-uses' })
await billing.reload()
// Do the feature thing
}
return <button onClick={handleUse}>Use Feature</button>
}priceSlugpriceIdusageMeterSlugusageMeterIdawait createUsageEvent({
priceSlug: 'api-calls-standard', // Specific price
amount: 1,
// ...
})await createUsageEvent({
usageMeterSlug: 'api-calls', // Resolves to meter's default price
amount: 1,
// ...
})usageMeterSlug{usagemeterslug}_no_charge$0.00// Works even if no custom price exists for 'api-calls' meter
await createUsageEvent({
usageMeterSlug: 'api-calls',
amount: 1,
// Resolves to 'api-calls_no_charge' if no other default is set
})api-calls-standardpriceSlug: 'api-calls-standard'createUsageEventpriceIdconst result = await createUsageEvent({ usageMeterSlug: 'api-calls', amount: 1 })
if (!('error' in result)) {
console.log('Charged to price:', result.usageEvent.priceId)
}transactionId// API route
export async function POST(req: Request) {
const session = await auth()
const result = await generateImage(prompt)
// If this request times out and retries, user gets double-charged!
await flowglad(session.user.id).createUsageEvent({
usageMeterSlug: 'image-generations',
amount: 1,
})
return Response.json(result)
}// API route
export async function POST(req: Request) {
const session = await auth()
const result = await generateImage(prompt)
// Safe for retries - same transactionId = same event
await flowglad(session.user.id).createUsageEvent({
usageMeterSlug: 'image-generations',
amount: 1,
transactionId: `img_${result.id}`, // Unique per logical operation
})
return Response.json(result)
}// Random IDs don't prevent duplicates on retry
await flowglad(userId).createUsageEvent({
usageMeterSlug: 'api-calls',
amount: 1,
transactionId: crypto.randomUUID(), // New ID on every retry!
})// Option 1: Use the result's ID
await flowglad(userId).createUsageEvent({
usageMeterSlug: 'generations',
amount: 1,
transactionId: `gen_${result.id}`,
})
// Option 2: Use request ID from incoming request header
// IMPORTANT: Only use if your client sends a stable x-request-id on retries
const requestId = req.headers.get('x-request-id')
if (!requestId) {
return Response.json({ error: 'x-request-id header required' }, { status: 400 })
}
await flowglad(userId).createUsageEvent({
usageMeterSlug: 'api-calls',
amount: 1,
transactionId: `req_${requestId}`,
})
// Option 3: Hash of operation parameters for deterministic operations
import { createHash } from 'crypto'
function hashOperationParams(params: Record<string, unknown>): string {
return createHash('sha256')
.update(JSON.stringify(params))
.digest('hex')
.slice(0, 16)
}
const operationHash = hashOperationParams({ userId, prompt })
await flowglad(userId).createUsageEvent({
usageMeterSlug: 'queries',
amount: 1,
transactionId: `query_${operationHash}`,
})async function generateImage(userId: string, prompt: string) {
// Spends $0.10 on AI generation
const image = await openai.images.generate({
model: 'dall-e-3',
prompt,
})
// Then discovers user has no credits - too late, we already paid OpenAI!
const billing = await flowglad(userId).getBilling()
const balance = billing.checkUsageBalance('image-generations')
if (balance.availableBalance <= 0) {
throw new Error('No credits') // User got nothing, we lost money
}
await flowglad(userId).createUsageEvent({
usageMeterSlug: 'image-generations',
amount: 1,
})
return image
}async function generateImage(userId: string, prompt: string) {
// Check balance BEFORE the expensive operation
const billing = await flowglad(userId).getBilling()
const balance = billing.checkUsageBalance('image-generations')
if (balance.availableBalance <= 0) {
throw new InsufficientCreditsError('No credits remaining. Please upgrade.')
}
// Now safe to proceed
const image = await openai.images.generate({
model: 'dall-e-3',
prompt,
})
// Use a stable identifier from the operation result
// The image URL or a hash of the image data provides idempotency
const imageId = image.data[0].url?.split('/').pop()?.split('.')[0] ||
createHash('sha256').update(prompt + userId).digest('hex').slice(0, 16)
await flowglad(userId).createUsageEvent({
usageMeterSlug: 'image-generations',
amount: 1,
transactionId: `img_${imageId}`,
})
return image
}if (balance.availableBalance <= 0) {
throw new Error('Operation failed')
}class InsufficientCreditsError extends Error {
constructor(
public meterSlug: string,
public availableBalance: number,
public required: number
) {
super(
`Insufficient credits for ${meterSlug}. ` +
`Available: ${availableBalance}, Required: ${required}`
)
this.name = 'InsufficientCreditsError'
}
}
// In API route - throwing the error
if (balance.availableBalance < requiredAmount) {
throw new InsufficientCreditsError(
'image-generations',
balance.availableBalance,
requiredAmount
)
}
// Catching and handling the error in your API route
export async function POST(req: Request) {
try {
const result = await generateImage(userId, prompt)
return Response.json(result)
} catch (error) {
if (error instanceof InsufficientCreditsError) {
return Response.json(
{
error: 'insufficient_credits',
message: error.message,
availableBalance: error.availableBalance,
required: error.required,
upgradeUrl: '/pricing',
},
{ status: 402 } // Payment Required
)
}
throw error // Re-throw unexpected errors
}
}function UsageDisplay() {
const { checkUsageBalance } = useBilling()
const balance = checkUsageBalance('api-calls')
// Just showing a number is confusing
return <div>Usage: {balance.usedBalance}</div>
}import { useBilling } from '@flowglad/nextjs'
function UsageDisplay() {
const { loaded, checkUsageBalance } = useBilling()
if (!loaded) {
return <UsageSkeleton />
}
const balance = checkUsageBalance('api-calls')
// Handle unlimited plans (balanceLimit is null)
// For unlimited plans, show usage count without percentage
const hasLimit = balance.balanceLimit != null
const percentUsed = hasLimit
? (balance.usedBalance / balance.balanceLimit!) * 100
: 0
// For unlimited plans, skip the progress bar entirely
if (!hasLimit) {
return (
<div className="text-sm">
<span>API Calls: {balance.usedBalance.toLocaleString()}</span>
<span className="text-gray-500 ml-1">(Unlimited)</span>
</div>
)
}
return (
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span>API Calls</span>
<span>
{balance.usedBalance.toLocaleString()} / {balance.balanceLimit?.toLocaleString() ?? 'Unlimited'}
</span>
</div>
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
<div
className={`h-full transition-all ${
percentUsed > 90 ? 'bg-red-500' : percentUsed > 75 ? 'bg-yellow-500' : 'bg-blue-500'
}`}
style={{ width: `${Math.min(percentUsed, 100)}%` }}
/>
</div>
{percentUsed > 90 && (
<p className="text-sm text-red-600">
You're approaching your limit. Consider upgrading.
</p>
)}
</div>
)
}function Dashboard() {
const { checkUsageBalance } = useBilling()
async function handleGenerate() {
await fetch('/api/generate', { method: 'POST' })
// Balance display is now stale - shows old value
}
const balance = checkUsageBalance('generations')
return (
<div>
<p>Remaining: {balance.availableBalance}</p>
<button onClick={handleGenerate}>Generate</button>
</div>
)
}function Dashboard() {
const { checkUsageBalance, reload, loaded } = useBilling()
const [isGenerating, setIsGenerating] = useState(false)
async function handleGenerate() {
setIsGenerating(true)
try {
await fetch('/api/generate', { method: 'POST' })
// Refresh billing data to show updated balance
await reload()
} finally {
setIsGenerating(false)
}
}
if (!loaded) {
return <LoadingSkeleton />
}
const balance = checkUsageBalance('generations')
return (
<div>
<p>Remaining: {balance.availableBalance}</p>
<button onClick={handleGenerate} disabled={isGenerating}>
{isGenerating ? 'Generating...' : 'Generate'}
</button>
</div>
)
}function FeatureComponent() {
const { checkUsageBalance } = useBilling()
const balance = checkUsageBalance('generations')
if (balance.availableBalance <= 0) {
return null // Feature just disappears
}
return <GenerateForm />
}function FeatureComponent() {
const { loaded, checkUsageBalance, createCheckoutSession } = useBilling()
if (!loaded) {
return <LoadingSkeleton />
}
const balance = checkUsageBalance('generations')
if (balance.availableBalance <= 0) {
return (
<div className="p-6 border rounded-lg bg-gray-50">
<h3 className="font-semibold text-lg">Out of generations</h3>
<p className="text-gray-600 mt-2">
You've used all {balance.balanceLimit} generations this month.
Upgrade to continue creating.
</p>
<div className="mt-4 flex gap-3">
<button
onClick={() =>
createCheckoutSession({
priceSlug: 'pro-monthly',
successUrl: `${window.location.origin}/dashboard?upgraded=true`,
cancelUrl: window.location.href,
autoRedirect: true,
})
}
className="px-4 py-2 bg-blue-600 text-white rounded-lg"
>
Upgrade to Pro
</button>
<button
onClick={() => window.location.href = '/pricing'}
className="px-4 py-2 border rounded-lg"
>
View Plans
</button>
</div>
</div>
)
}
return <GenerateForm />
}// Showing upgrade randomly is annoying
function Dashboard() {
const showUpgrade = Math.random() > 0.7
return (
<div>
{showUpgrade && <UpgradePrompt />}
<MainContent />
</div>
)
}function Dashboard() {
const { loaded, checkUsageBalance } = useBilling()
if (!loaded) {
return <LoadingSkeleton />
}
const balance = checkUsageBalance('generations')
const percentUsed = balance.balanceLimit
? (balance.usedBalance / balance.balanceLimit) * 100
: 0
// Show upgrade prompts at meaningful thresholds
const showUpgrade = percentUsed >= 80
return (
<div>
{showUpgrade && (
<div className="mb-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<p className="text-blue-800">
{percentUsed >= 100
? "You've used all your generations this month."
: `You've used ${Math.round(percentUsed)}% of your generations.`}
{' '}
<a href="/pricing" className="underline font-medium">
Upgrade for unlimited access
</a>
</p>
</div>
)}
<MainContent />
</div>
)
}const billing = useBilling()
// With smart defaults (amount: 1, auto transactionId, auto subscriptionId)
await billing.createUsageEvent({ usageMeterSlug: 'your-meter-slug' })
// With explicit amount
await billing.createUsageEvent({ usageMeterSlug: 'your-meter-slug', amount: 5 })
// Don't forget to reload if showing balance
await billing.reload()import { flowglad } from '@/lib/flowglad'
const billing = await flowglad(userId).getBilling()
await flowglad(userId).createUsageEvent({
usageMeterSlug: 'your-meter-slug', // or priceSlug for specific price
amount: 1,
subscriptionId: billing.currentSubscription!.id,
transactionId: `unique_${operationId}`,
})// Client-side
const { checkUsageBalance } = useBilling()
const balance = checkUsageBalance('your-meter-slug')
// balance.availableBalance, balance.usedBalance, balance.balanceLimit
// Server-side
const billing = await flowglad(userId).getBilling()
const balance = billing.checkUsageBalance('your-meter-slug')// Using usageMeterSlug - resolves to meter's default price
await createUsageEvent({ usageMeterSlug: 'api-calls', ... })
// Using priceSlug - targets specific price directly
await createUsageEvent({ priceSlug: 'api-calls-standard', ... })
// Every meter has auto-generated no-charge price: {slug}_no_charge| Status | Use Case |
|---|---|
| User not authenticated |
| Insufficient credits/balance |
| User authenticated but lacks access to this feature |
| Rate limited (separate from usage billing) |