reserve / commit / release
The only safe way to enforce limits under concurrency. reserve() atomically holds quota with a 60-second TTL. You then commit() on success or release() on failure. Forgotten reservations auto-release on TTL.
The pattern
const r = await vevee.reserve(userId, 'image.render', 1, { model: 'flux-pro' });
if (!r.allowed) {
// hard stop - user is at quota (or inside the reservation safety buffer)
throw new LimitError(r.reasons);
}
try {
const image = await callFluxPro(prompt);
await vevee.commit(r.reservationId!); // confirm the charge
return image;
} catch (err) {
await vevee.release(r.reservationId!); // refund the quota
throw err;
}Signature
reserve(
userId: string,
event: string,
quantity?: number,
metadata?: EventMetadata,
options?: {
prompt?: string; // input prompt - stored to event_logs on commit/release
inputTokens?: number; // token count for the pending LLM input
inputCostCents?: number; // cost in cents for the pending LLM input
},
): Promise<ReserveResponseData>Response
interface ReserveResponseData {
allowed: boolean;
matched: boolean; // false → no limit group covers this event,
// or the user has no subscription on file
reservationId?: string; // 'rsv_…', present when allowed=true
expiresAt?: string; // ISO 8601, ~60s from now
reasons?: string[]; // 'limit_reached' | 'reservation_headroom_exceeded'
// | 'unmatched_event' | 'no_subscription'
groups?: ReserveResponseGroup[];
}
interface ReserveResponseGroup {
groupId: string;
unit: 'count' | 'tokens' | 'seconds' | 'cents';
quota: number;
current: number;
headroomGate?: number; // effective gate value when headroom is configured
// multiplier mode: quota; percent mode: quota × pct/100
}reserve returns { allowed: false, matched: false, reasons: ['unmatched_event'] } and does not create a reservation. Same for users with no subscription on file (no_subscription). The SDK console.warns in development so typos and missing limit groups surface immediately.Per-unit input quantities
For LLM events, you often know the input token count (or input cost in cents) before the AI call but not the output. Pass inputTokens or inputCostCents in options so each matched limit group reserves the right amount:
- Tokens groups read
inputTokens. - Cents groups read
inputCostCents- or, when onlyinputTokensis provided, derive the cost from the model's token price in the catalog. - Count groups always count 1 regardless.
- All other units fall back to
quantity.
// Reserve 5,000 input tokens against llm.gpt-4o.
// A tokens group will hold 5_000; a cents group will derive cost from the catalog price.
const r = await vevee.reserve(userId, 'llm.gpt-4o', 0, undefined, {
inputTokens: 5_000,
});
if (!r.allowed) {
return Response.json({ error: 'limit_reached', reasons: r.reasons }, { status: 429 });
}Reservation headroom
LLM plans commonly configure a reservation safety buffer(headroom) on one or more limit groups. The buffer leaves room for the unknown output cost that arrives with the AI response - so a token or dollar cap isn't accidentally breached by a large reply.
Headroom is configured at the planlevel (in the dashboard's “Reservation safety” collapsible on LLM rows) - you do not pass anything extra in the SDK call. When a group's buffer fires, reserve() returns:
{
allowed: false,
reasons: ['reservation_headroom_exceeded'],
groups: [
{
groupId: 'lg_tokens_monthly',
unit: 'tokens',
quota: 100_000,
current: 82_000,
headroomGate: 85_000, // the effective threshold (quota × 85%)
},
],
}The user is not yet at the true cap - they are inside the safety buffer. Use groups[0].headroomGate in your paywall UI to show how close they are. Once the period resets (or they upgrade), the next reserve() will succeed again.
Confirms a reservation. Counter stays incremented; the event becomes a permanent record.
Signature
commit(
reservationId: string,
options?: {
response?: string; // model's output - stored on the event_logs row
extra?: {
count?: number;
tokens?: number;
cents?: number;
outputTokens?: number; // sugar → tokens
outputCostCents?: number; // sugar → cents
};
actual?: {
count?: number;
tokens?: number;
cents?: number;
};
},
): Promise<CommitResponseData>
interface CommitResponseData {
groups: CommitResponseGroup[];
}
interface CommitResponseGroup {
groupId: string;
unit: 'count' | 'tokens' | 'seconds' | 'cents';
quota: number;
current: number;
overflow: boolean; // true when this commit pushed the counter past quota
}Per-unit topup at commit time
The canonical LLM flow: you know the input cost at reserve time, but output cost only arrives with the AI response. Pass extra on commit() to add the output units on top of what was reserved:
// 1. Reserve with input tokens
const r = await vevee.reserve(userId, 'llm.gpt-4o', 0, undefined, {
inputTokens: 5_000,
});
if (!r.allowed) throw new LimitError(r.reasons);
// 2. Call the LLM
const out = await callLLM(prompt);
// → out.usage.completion_tokens = 12_000, out.cost_cents = 8
// 3. Commit, adding the output units on top of the reserved input
const c = await vevee.commit(r.reservationId!, {
extra: {
outputTokens: 12_000, // added on top of reserved 5_000 → total 17_000
outputCostCents: 8, // added on top of reserved input cost → total cost
},
});
// 4. Check for overshoot
if (c.groups.some(g => g.overflow)) {
console.warn('Counter pushed past quota; next reserve will block');
}extra is a delta added on top of the reserved amount. actual is the absolute final value - the server computes delta = actual − reserved and applies it. Setting both extra.<unit> and actual.<unit> for the same unit returns invalid_request (HTTP 400).commit() that pushes the counter past quota succeeds - the response's groups[i].overflow flag tells you it happened. The next reserve() against that group will return limit_reached. Negative deltas are allowed; counters floor at 0.Cancels a reservation. Counter is decremented back; quota is returned to the user.
Signature
release(
reservationId: string,
options?: {
reason?: string; // free-form note, max 500 chars
errorCode?: string; // short machine-readable code, max 100 chars
response?: string; // partial output, if any - stored on the event_logs row
},
): Promise<void>Capturing why a reservation was released
Both reason and errorCodeare optional. When provided, they are persisted alongside the released reservation so you can later answer "why did this user's quota get refunded?" - useful for debugging provider failures, surfacing error rates per model, or auditing disputed charges.
try {
const image = await callFluxPro(prompt);
await vevee.commit(r.reservationId!);
} catch (e) {
await vevee.release(r.reservationId!, {
errorCode: 'provider_error',
reason: e instanceof Error ? e.message : String(e),
});
throw e;
}errorCodefor short stable identifiers you'll group on (provider_error, user_canceled, safety_filter, timeout). Use reason for the human-readable detail (the upstream error message). The server truncates to 100 / 500 characters respectively.Capturing the prompt and response
Pass the user's prompt to reserve()and the model's response to commit() (or release(), if the call failed mid-stream). Both end up in the event_logstable tied to the resulting event, viewable in the dashboard's user detail page. Enable the feature first in Settings → Prompt logging; while it's off these fields are silently ignored.
const r = await vevee.reserve(userId, 'image.render', 1, { model: 'flux-pro' },
{ prompt }, // capture the input upfront
);
if (!r.allowed) throw new LimitError(r.reasons);
try {
const image = await callFluxPro(prompt);
await vevee.commit(r.reservationId!, { response: image.url }); // capture the output
} catch (e) {
await vevee.release(r.reservationId!, {
errorCode: 'provider_error',
reason: e instanceof Error ? e.message : String(e),
response: '', // optional partial output
});
throw e;
}…[truncated] if larger.Reservation lifecycle
- reserve() - atomic increment.
reservationIdissued, expires in 60s. - Within 60s, exactly one of:
- commit() - final. Counter stays (plus any
extraoractualtopup). Event recorded. - release() - counter decremented. No event recorded.
- (timeout) - server auto-releases on TTL.
- commit() - final. Counter stays (plus any
Real-world example
// app/api/generate/route.ts (Next.js)
import { vevee } from '@/lib/vevee';
import { VeveeError } from '@vevee/sdk';
export async function POST(req: Request) {
const { userId, prompt } = await req.json();
let reservationId: string | undefined;
try {
const r = await vevee.reserve(userId, 'image.render', 1, { model: 'flux-pro' });
if (!r.allowed) {
return Response.json(
{ error: 'limit_reached', reasons: r.reasons },
{ status: 429 },
);
}
reservationId = r.reservationId;
const image = await callFluxPro(prompt);
await vevee.commit(reservationId!);
return Response.json({ image });
} catch (err) {
if (reservationId) {
await vevee.release(reservationId).catch(() => {});
}
if (err instanceof VeveeError) {
return Response.json({ error: err.code, message: err.message }, { status: err.status });
}
throw err;
}
}Errors
limit_reached(200, in body) - only onreserve()when at quota. The body contains{ allowed: false, reasons }; this is a data answer, not a thrown error, since it's an expected outcome.reservation_headroom_exceeded(200, in body) - only onreserve()/canUse()when usage is past the configured reservation safety buffer but not yet at the true quota. Body contains{ allowed: false, reasons }. Distinct fromlimit_reached, which fires at the actual cap. The user isn't fully blocked - the buffer is protecting headroom for unknown output cost.invalid_request(400) - oncommit()when bothextra.<unit>andactual.<unit>are set for the same unit. Also: missing required field, wrong type, or value out of range.reservation_not_pending(400) -commitorreleasecalled on a reservation that was already committed or released.reservation_expired(400) -commitorreleasecalled after the 60-second TTL.not_found(404) - unknownreservationId.
release()after the auto-release timeout is harmless - you'll get reservation_expired. Wrap your release call in a .catch(() => {})if you don't care.