Records that an end-user consumed something. Increments every matching limit group and returns the new counter summaries. Throws if the user is already over a limit.
Signature
track(
userId: string,
event: string,
quantity?: number, // default: 1
metadata?: EventMetadata, // Record<string, string>
options?: {
prompt?: string; // input sent to your AI model
response?: string; // model's output
// Cost options - mutually exclusive sets:
costCents?: number; // single total cost in cents
inputCostCents?: number; // input portion; summed with outputCostCents
outputCostCents?: number; // output portion; summed with inputCostCents
},
): Promise<TrackResponseData>Parameters
| Name | Type | Description | |
|---|---|---|---|
userId | required | string | Your end-user's ID. We do not authenticate them - pass whatever string you use internally. |
event | required | string | Event type. Conventional dot-notation, e.g. image.render. |
quantity | optional | number | How much was consumed. Defaults to 1. Must be positive. |
metadata | optional | Record<string, string> | Flat key/value pairs. Used by limit-group match rules and shown in analytics. |
options.prompt | optional | string | The text prompt your user sent to the AI model. Persisted in event_logs for debugging and product analytics. Capped at 32 KB server-side; longer values are truncated. Silently ignored if prompt logging is disabled for the app (Settings → Prompt logging). |
options.response | optional | string | The model's response. Same 32 KB cap and silent-ignore behavior as prompt. |
Response
interface TrackResponseData {
eventId: string; // 'evt_…'
matchStatus: // shown as a status badge in the dashboard
| 'matched' // → counted against at least one limit group
| 'unmatched' // → recorded but no limit group covered it
| 'blocked' // → matched, but a quota was already at zero
| 'no_subscription'; // → user has no active subscription on this app
matchedGroupIds: string[]; // 'lg_…' for each group counted; empty otherwise
counters: {
groupId: string; // 'lg_…'
label: string; // group label from the dashboard
unit: 'count' | 'tokens' | 'seconds' | 'cents';
quota: number; // limit on the user's plan
count: number; // value AFTER incrementing
remaining: number; // max(0, quota - count) - clamped, never negative
costCents: number; // accumulated cost in cents
filters: Record<string, string[]>; // metadata gates (e.g. { source: ['text'] })
}[];
}canUse / reserve for fail-closed enforcement (they return matched: false for the same situations).Example
import { createClient, VeveeError } from '@vevee/sdk';
const vevee = createClient({ apiKey: process.env.VEVEE_KEY! });
try {
const result = await vevee.track('user_abc123', 'llm.completion', 1842, {
model: 'gpt-4o',
inputTokens: '920',
outputTokens: '922',
});
console.log(result.eventId); // 'evt_8sk2…'
console.log(result.counters);
// [{ groupId: 'lg_tokens_monthly', count: 184213, costCents: 553 }]
} catch (err) {
if (err instanceof VeveeError && err.code === 'limit_reached') {
return res.status(429).json({ error: 'Out of tokens this month' });
}
throw err;
}With prompt logging
Pass the prompt and response to the optional options object to capture them alongside the event. Enable prompt logging first in Settings → Prompt loggingon the app - when the toggle is off, these fields are silently ignored, so it's safe to leave them in your code.
const prompt = 'Write a haiku about Postgres';
const completion = await openai.chat.completions.create({
model: 'gpt-4o',
messages: [{ role: 'user', content: prompt }],
});
const response = completion.choices[0]?.message.content ?? '';
await vevee.track('user_abc123', 'llm.completion', completion.usage?.total_tokens ?? 0,
{ model: 'gpt-4o' },
{ prompt, response },
);event_logstable that's only read when you open an event in the dashboard, so charts and quotas stay fast. See Prompt logging for retention and privacy details.Recording actual cost
Pass explicit cost values in options to write the real cost of an event to the dashboard - useful for custom events (e.g. video.analyze) or for LLM calls where you compute tier-aware pricing yourself. The values appear in the overall app cost chart, the per-user coston the subscriber's detail page, and the per-event breakdown.
| Option | Type | Description | |
|---|---|---|---|
options.costCents | optional | number | A single total cost in cents. Use when input and output are not tracked separately. Must be non-negative. Mutually exclusive with inputCostCents / outputCostCents. |
options.inputCostCents | optional | number | Input portion of the cost in cents. The server sums input and output into the total. Must be non-negative. |
options.outputCostCents | optional | number | Output portion of the cost in cents. Summed with inputCostCents. Must be non-negative. |
costCents and inputCostCents / outputCostCents in the same call returns an invalid_request error - they are mutually exclusive. Catalog-priced events (e.g. image.<model>) still use catalog pricing when no explicit cost is supplied; explicit values override the catalog estimate when both are present.// Split input / output cost (e.g. a video analysis you priced yourself)
await vevee.track(userId, 'video.analyze', durationSeconds, { videoId }, {
inputCostCents: 30,
outputCostCents: 12,
});
// Single total cost
await vevee.track(userId, 'api.call', 1, { endpoint: '/summarize' }, {
costCents: 42,
});Errors
limit_reached(429) - at least one matching limit group is at quota.workspace_limit_reached(429) - your Vevee workspace hit its event quota.invalid_key(401) - bad or revoked API key.requires_secret_key(403) - you tried totrackwith apk_live_public key.invalid_request(400) - missing/invalid field.
When to use track vs reserve
track() for cheap, idempotent events you can absorb a tiny over-count on (analytics-style, page.viewed, chat.message).Use
reserve() for anything that costs real money or where parallel requests must not exceed quota (image gen, video gen, expensive LLM calls).