Skip to content

Show users where they stand

Your users want to know how much they have left before they run out: “4,975 credits left for your team”. Limitry knows the exact numbers for every limit that applies to a user, including the balances they share with their team. This guide uses the team credits from Sell prepaid credits. This guide adds a route on your server that asks Limitry, and a React component that shows the answer.

Limitry doesn’t host the screen. You copy the component, style it, and it stays yours.

limitry.standing() returns every enabled limit on one subject, with what’s used, what’s left and when it resets. Name shared subjects too, such as the user’s team, and their limits come back in the same list:

import { createClient } from "@limitry/sdk";
const limitry = createClient(); // reads LIMITRY_API_KEY
// Express-style handler for the signed-in user.
app.get("/api/usage", async (req, res) => {
const user = req.user;
const standing = await limitry.standing({
subject: { kind: "user", id: user.id },
shared: [{ kind: "team", id: user.teamId }],
});
res.json(standing);
});

Call it from your server with your API key, and only for the signed-in user. The key can read every subject in the workspace, so it must never reach a browser. The key needs Limits: View (limits:read), which Check limits includes.

Asking isn’t a check. Limitry counts nothing, and it doesn’t use your plan’s checks.

The same question over HTTP:

import { createClient } from "@limitry/sdk";
const limitry = createClient();
const standing = await limitry.usage.standing({
kind: "user",
id: "usr_123",
shared: "team:team_42",
});

shared takes up to 4 subjects as kind:id pairs separated by commas.

{
"subject": { "kind": "user", "id": "usr_123" },
"environment": "live",
"limits": [
{
"limit": "lim_…",
"name": "Credits",
"subject": { "kind": "team", "id": "team_42" },
"shared": true,
"action": "*",
"unit": "credits",
"window": "total",
"rolling": false,
"amount": 100,
"used": 125,
"remaining": 4975,
"resetsAt": null
}
]
}

Each item is one limit. Here it’s the team’s balance: 100 free credits to start, a 5,000-credit pack bought since, and 125 spent. shared is true because the limit is on a shared subject (the team) rather than on the user. A few cases to handle:

  • No limits: a user with no limit in this environment gets an empty limits list. Show nothing.
  • Balances (window total): resetsAt is null, because a balance never resets. After you grant credits, remaining can be more than amount, as above, so show “credits left” rather than a share of amount.
  • Limits that reset (day, month and the rest): show “18 of 50 left” and when it resets.
  • Rolling limits: resetsAt is when the oldest use in the window leaves it, which is when the next unit frees up.
  • Concurrent limits: used is how many are running now.

The numbers are exact at the moment you ask, the same counts a check sees. A test key reads test usage and a live key reads live usage.

Copy this component into your app. It needs React and nothing else, and it reads the route from step 1:

import { useEffect, useState } from "react";
type Limit = {
limit: string;
name: string;
unit: string;
window: string;
amount: number;
used: number;
remaining: number;
resetsAt: string | null;
};
export function UsageMeter() {
const [limits, setLimits] = useState<Limit[] | null>(null);
useEffect(() => {
fetch("/api/usage")
.then((res) => (res.ok ? res.json() : { limits: [] }))
.then((body) => setLimits(body.limits))
.catch(() => setLimits([]));
}, []);
if (!limits || limits.length === 0) return null;
return (
<ul style={{ listStyle: "none", padding: 0 }}>
{limits.map((l) => (
<li key={l.limit} style={{ marginBottom: 16 }}>
<UsageRow limit={l} />
</li>
))}
</ul>
);
}
function UsageRow({ limit }: { limit: Limit }) {
const balance = limit.window === "total";
const max = Math.max(limit.amount, limit.used + limit.remaining);
const left = Math.max(limit.remaining, 0);
const label = balance
? `${left} ${limit.unit} left`
: `${left} of ${limit.amount} ${limit.unit} left`;
const resets = limit.resetsAt
? `Resets ${new Date(limit.resetsAt).toLocaleString()}`
: null;
return (
<div>
<div style={{ display: "flex", justifyContent: "space-between", gap: 8 }}>
<span>{limit.name}</span>
<span>{label}</span>
</div>
<div
role="meter"
aria-label={limit.name}
aria-valuemin={0}
aria-valuemax={max}
aria-valuenow={left}
aria-valuetext={label}
style={{
height: 8,
borderRadius: 4,
background: "#e5e7eb",
overflow: "hidden",
}}
>
<div
style={{
width: `${max > 0 ? (left / max) * 100 : 0}%`,
height: "100%",
background: left === 0 ? "#b91c1c" : "#1d4ed8",
}}
/>
</div>
{resets && <small>{resets}</small>}
</div>
);
}

The bar shows what’s left, so it empties as the team spends. Screen readers announce the same words the user sees, such as “4975 credits left”. Swap the inline styles for your own classes.

Fetch it when the page opens, not on a timer. If your product shows the meter on every page, cache the route’s answer for a few seconds on your server.

A check that’s turned away throws LimitExceeded from limitry.guard() and limitry.hold(). Answer it where your product talks to the user, and point them at the meter or a way to get more:

import { LimitExceeded } from "@limitry/sdk";
try {
return await limitry.guard(
{
subject: { kind: "user", id: user.id },
shared: [{ kind: "team", id: user.teamId }],
action: "report",
cost: 25,
},
() => buildReport(params),
);
} catch (err) {
if (err instanceof LimitExceeded && err.reason === "limit_exceeded") {
return res.status(402).json({
message: "Your team is out of credits. Buy more to keep going.",
buyUrl: "/billing/credits",
});
}
throw err;
}

A balance’s retryAfterSeconds is null, because waiting won’t help. Show a “Buy more credits” button when remaining reaches 0, and grant the credits from your payment webhook (see Sell prepaid credits). For a limit that resets, tell the user when, from err.retryAfterSeconds.

To tell users before they run out, see Warn your users before they hit a limit.