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 groupMatch ruleQuota
Images (overall)image.gemini-3.1-image3 / month
Text sourceimage.gemini-3.1-image + { source: 'text' }2 / month
Viral sourceimage.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

FieldTypeWhat it is
groupIdstringStable identifier of the limit group. Use it as a React key.
labelstringHuman-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”).
quotanumberMaximum allowed in the current period. Comes from the plan.
countnumberAlready used. Can briefly equal or exceed quota - see callout below.
remainingnumberMath.max(0, quota - count), pre-computed on the server so your UI never has to clamp. Always >= 0.
costCentsnumberAccumulated cost (in cents) for events that hit this group. Driven by per-app pricing_rules overrides or the AI model catalog.
filtersRecord<string, string[]>Distinct metadata gate values across the group's match rules. {} for “overall” buckets; { source: ['text'] } for splits.
!
Why 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 left

Browser-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>;
}
i
With a 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_found error (HTTP 404). Catch it and show an upsell, or call upsertSubscription() first when the user signs up. track() and reserve() behave differently - see cancelSubscription() for the per-method matrix.
  • Lifetime plans. period.end is null. 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 onPlanChange semantics for carry / reset / block.
  • Brief negative quota - count. remainingis already clamped, so you don’t need to repeat the math. If you display count directly, cap it at quota for a less confusing UI.

Where to go next