Reading usage and remaining quota
Every call to vevee.usage(userId)returns the user's full picture for the active period: how much each limit group allows, how much they've used, and how much they have left. This guide walks through a plan with an overall bucket plus per-source splits and shows how to render it.
The plan we’re modelling
A monthly plan with three limit groups stacked over the same image model. The “overall” bucket caps total images; the source-specific buckets cap each traffic source independently. An image with metadata.source = "text" increments boththe overall and the text bucket on the same call - that's the default behaviour when an event matches multiple groups.
| Limit group | Match rule | Quota |
|---|---|---|
| Images (overall) | image.gemini-3.1-image | 3 / month |
| Text source | image.gemini-3.1-image + { source: 'text' } | 2 / month |
| Viral source | image.gemini-3.1-image + { source: 'viral' } | 1 / month |
The plan as JSON
{
"periodType": "monthly",
"periodAnchor": "subscription_start",
"groups": [
{
"id": "lg_image_total",
"label": "Images",
"unit": "count",
"quota": 3,
"matches": [{ "event": "image.gemini-3.1-image" }]
},
{
"id": "lg_image_text",
"label": "Text source",
"unit": "count",
"quota": 2,
"matches": [
{ "event": "image.gemini-3.1-image", "metadata": { "source": "text" } }
]
},
{
"id": "lg_image_viral",
"label": "Viral source",
"unit": "count",
"quota": 1,
"matches": [
{ "event": "image.gemini-3.1-image", "metadata": { "source": "viral" } }
]
}
]
}Tracking some usage
Track one image from the “text” source and one from “viral.” Each call increments the overall bucket plus the matching source bucket - so after these two calls the overall counter sits at 2.
import { vevee } from '@/lib/vevee';
await vevee.track('user_abc', 'image.gemini-3.1-image', 1, { source: 'text' });
await vevee.track('user_abc', 'image.gemini-3.1-image', 1, { source: 'viral' });Reading it back
const usage = await vevee.usage('user_abc');The response:
{
"userId": "user_abc",
"period": {
"start": "2026-05-09T00:00:00.000Z",
"end": "2026-06-09T00:00:00.000Z"
},
"counters": [
{
"groupId": "lg_image_total",
"label": "Images",
"unit": "count",
"quota": 3,
"count": 2,
"remaining": 1,
"costCents": 8,
"filters": {}
},
{
"groupId": "lg_image_text",
"label": "Text source",
"unit": "count",
"quota": 2,
"count": 1,
"remaining": 1,
"costCents": 4,
"filters": { "source": ["text"] }
},
{
"groupId": "lg_image_viral",
"label": "Viral source",
"unit": "count",
"quota": 1,
"count": 1,
"remaining": 0,
"costCents": 4,
"filters": { "source": ["viral"] }
}
]
}Field reference
| Field | Type | What it is |
|---|---|---|
groupId | string | Stable identifier of the limit group. Use it as a React key. |
label | string | Human-readable name typed into the dashboard. Display this to end-users. |
unit | 'count' | 'tokens' | 'seconds' | 'cents' | What the group counts. Drives how you format the number (“3 images” vs “1,200 tokens” vs “$0.04”). |
quota | number | Maximum allowed in the current period. Comes from the plan. |
count | number | Already used. Can briefly equal or exceed quota - see callout below. |
remaining | number | Math.max(0, quota - count), pre-computed on the server so your UI never has to clamp. Always >= 0. |
costCents | number | Accumulated cost (in cents) for events that hit this group. Driven by per-app pricing_rules overrides or the AI model catalog. |
filters | Record<string, string[]> | Distinct metadata gate values across the group's match rules. {} for “overall” buckets; { source: ['text'] } for splits. |
remaining is clamped. track() is non-enforcing: it increments the counter first, then marks the event blocked if it pushes a group over quota. So count can momentarily exceed quota when concurrent calls race past zero remaining. The server pre-clamps remaining to Math.max(0, quota - count) so naive UI code never displays a negative number. If you need strict enforcement, use reserve() / commit() / release() instead.Why every group always shows up (even at zero)
The response includes every limit group on the user's plan, not just the ones with counter rows. A brand-new user who has used nothing still gets all three rows back, all with count: 0 and remaining: quota. This means you can render the same UI on signup, on the first call, and steady-state - no special case for “empty.”
Telling overall apart from splits
The simplest signal is filters:
filters: {}→ an “overall” bucket with no metadata gate.filters: { source: ['text'] }→ gated to a single value.filters: { source: ['text', 'viral'] }→ matches multiple values (rare, but possible if a group has several rules).
Rendering it
Server: forward the JSON to the client
// app/api/quota/route.ts
import { NextRequest } from 'next/server';
import { vevee } from '@/lib/vevee';
export async function GET(req: NextRequest) {
const userId = req.nextUrl.searchParams.get('userId');
if (!userId) return Response.json({ error: 'userId required' }, { status: 400 });
const usage = await vevee.usage(userId);
return Response.json(usage);
}Client: a minimal quota panel
'use client';
import useSWR from 'swr';
import type { UsageResponseData } from '@vevee/sdk';
export function QuotaPanel({ userId }: { userId: string }) {
const { data } = useSWR<UsageResponseData>(`/api/quota?userId=${userId}`, (u) =>
fetch(u).then((r) => r.json()),
);
if (!data) return null;
if (!data.period) return <p>No active subscription.</p>;
// Show the "overall" group on top, then the splits underneath.
const sorted = [...data.counters].sort((a, b) => {
const aSplit = Object.keys(a.filters).length > 0 ? 1 : 0;
const bSplit = Object.keys(b.filters).length > 0 ? 1 : 0;
return aSplit - bSplit || a.label.localeCompare(b.label);
});
const resets = data.period.end ? new Date(data.period.end) : null;
return (
<section className="quota">
<header>
<strong>This period</strong>
{resets && <span> · resets {resets.toLocaleDateString()}</span>}
</header>
<ul>
{sorted.map((c) => {
const isOverall = Object.keys(c.filters).length === 0;
const tag = Object.entries(c.filters)
.map(([k, vs]) => `${k}: ${vs.join(' | ')}`)
.join(', ');
return (
<li key={c.groupId} data-overall={isOverall}>
<span>{c.label}{tag && ` (${tag})`}</span>
<span>{c.count} / {c.quota} used · {c.remaining} left</span>
</li>
);
})}
</ul>
</section>
);
}What the user sees
This period · resets 6/9/2026
Images 2 / 3 used · 1 left
Text source (source: text) 1 / 2 used · 1 left
Viral source (source: viral) 1 / 1 used · 0 leftBrowser-only: use a public key
vevee.usage() is the only SDK method that accepts a pk_live_ key, which makes it safe to call straight from the browser. No backend route needed for read-only quota display.
'use client';
import { useEffect, useState } from 'react';
import { createClient } from '@vevee/sdk';
import type { UsageResponseData } from '@vevee/sdk';
const vevee = createClient({ apiKey: process.env.NEXT_PUBLIC_VEVEE_KEY! });
export function ImagesLeftBadge({ userId }: { userId: string }) {
const [usage, setUsage] = useState<UsageResponseData | null>(null);
useEffect(() => {
vevee.usage(userId).then(setUsage);
}, [userId]);
if (!usage) return null;
const overall = usage.counters.find(
(c) => c.label === 'Images' && Object.keys(c.filters).length === 0,
);
return <span>{overall?.remaining ?? 0} images left</span>;
}pk_live_ key the API enforces that userIdmatches the requesting end-user (passed via your auth integration). Other users' usage is never returned.Common derived UI
Total cost across all groups
Be careful: an event that matches several groups contributes its cost to each of those groups, so summing every costCentsdouble-counts. To get total spend, sum only the “overall” buckets (filters empty), or design plans so cost only ever lives on one group.
const totalCents = usage.counters
.filter((c) => Object.keys(c.filters).length === 0)
.reduce((sum, c) => sum + c.costCents, 0);Percent used (for a progress bar)
const pct = c.quota === 0 ? 100 : Math.min(100, (c.count / c.quota) * 100);“Disable button” check without a round-trip
For a coarse client-side gate you can read remaining off the cached usage response. This is not race-safe - for the actual call use canUse() or reserve().
<button disabled={overall && overall.remaining === 0}>Generate image</button>Edge cases
- No subscription on file. The call rejects with a
subscription_not_founderror (HTTP 404). Catch it and show an upsell, or callupsertSubscription()first when the user signs up.track()andreserve()behave differently - see cancelSubscription() for the per-method matrix. - Lifetime plans.
period.endisnull. Skip the “resets on” line in your UI. - Plan changed mid-period.Counters that exist for the new plan's groups carry over by default - see the core concepts page on
onPlanChangesemantics forcarry/reset/block. - Brief negative
quota - count.remainingis already clamped, so you don’t need to repeat the math. If you displaycountdirectly, cap it atquotafor a less confusing UI.
Where to go next
- usage() reference - signature, parameters, and errors.
- canUse() / can() - read-only check before you spend money on an AI provider.
- reserve() / commit() / release() - atomic, race-safe enforcement under concurrency.
- Freemium image generator - end-to-end recipe combining limits, plans, and reserve / commit.