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;
}

vevee.reserve()POST /api/v1/reservesk_live_

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
}
i
Fail-closed by default.If the event_type doesn't match any limit group on the user's plan, 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 only inputTokensis 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.


vevee.commit()POST /api/v1/commitsk_live_

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');
}
i
extra vs actual. 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).
i
Overshoot is recorded truthfully. A 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.

vevee.release()POST /api/v1/releasesk_live_

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;
}
i
Use 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;
}
i
The prompt persists across the reservation lifecycle even when the call fails - useful for debugging which inputs trigger content filters or provider errors. Each field is capped at 32 KB server-side and truncated with …[truncated] if larger.

Reservation lifecycle

  1. reserve() - atomic increment. reservationId issued, expires in 60s.
  2. Within 60s, exactly one of:
    • commit() - final. Counter stays (plus any extra or actual topup). Event recorded.
    • release() - counter decremented. No event recorded.
    • (timeout) - server auto-releases on TTL.

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 on reserve() 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 on reserve() / canUse() when usage is past the configured reservation safety buffer but not yet at the true quota. Body contains { allowed: false, reasons }. Distinct from limit_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) - on commit() when both extra.<unit> and actual.<unit> are set for the same unit. Also: missing required field, wrong type, or value out of range.
  • reservation_not_pending (400) - commit or release called on a reservation that was already committed or released.
  • reservation_expired (400) - commit or release called after the 60-second TTL.
  • not_found (404) - unknown reservationId.
i
Calling 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.