Configuration
-Every setting for vBilling v0.2: environment variables, Helm chart values, and pricing setup in your billing backend.
+Every setting for vBilling v0.2: environment variables, Helm chart values, and pricing setup in your billing platform.
Environment Variables
@@ -436,7 +445,7 @@Metering
PROMETHEUS_METRICS_FILEAPI and enforcement
-Stripe
+Stripe
+Setup, meters, prices and webhooks are in the Stripe guide.
Metronome
+Metronome
+Setup, rate cards, the Stripe link and webhooks are in the Metronome guide.
Lago
+Lago
+Running Lago and pricing with charge filters are in the Lago guide.
Webhook
+Webhook
+Event types, signature verification and retries are in the Custom Billing Adapter guide.
SPOT_DISCOUNT_PERCENT |
- Ignored since v0.2. Price capacity_type in your billing backend instead, so quantities stay physical and reconcilable. |
+ Ignored since v0.2. Price capacity_type in your billing platform instead, so quantities stay physical and reconcilable. |
(none) |
| Approach | Code to write | Use it for |
|---|---|---|
| Webhook adapter | None in vBilling, an HTTPS receiver on your side | A data platform, a Kafka bridge or a homegrown rating engine |
| Ingest API | An HTTP client in your service | Usage vBilling cannot see: inference tokens, Slurm accounting, storage |
| Go adapter | A Go package in your fork of vBilling | A billing platform with its own API for customers, meters and usage |
Webhook adapter
+The webhook adapter POSTs CloudEvents 1.0 batches in structured mode to one endpoint, up to 500 events per request. Keep the signing secret in a Secret under the key secret, and list webhook next to any other adapter (for example '{metronome,webhook}'):
kubectl -n vbilling-system create secret generic vbilling-webhook \
+ --from-file=secret=./webhook-secret
+helm upgrade --install vbilling deploy/helm/vbilling -n vbilling-system \
+ --set adapters='{webhook}' \
+ --set webhook.url=https://billing.example.com/vbilling \
+ --set webhook.existingSecret=vbilling-webhook \
+ --set region=us-east-1 --set clusterName=us-east-1a| Helm value | Environment variable | Effect |
|---|---|---|
webhook.url | WEBHOOK_URL | Endpoint that receives the batches. Required when webhook is in adapters. |
webhook.existingSecret (key secret) | WEBHOOK_SECRET | Signs every request with HMAC-SHA256. Without it, requests are not signed. |
webhook.headers | WEBHOOK_HEADERS (k=v,k=v) | Extra request headers. The chart sets them as a plain environment value, not from a Secret. |
region, clusterName | REGION, CLUSTER_NAME | Form the CloudEvents source: vbilling/<region>/<clusterName>. |
Event types
+| type | Sent | data |
|---|---|---|
com.vcluster.vbilling. | For every event committed to the ledger, collector and ingested alike: every metric, including informational (vcluster_gpu_utilization, vcluster_gpu_downtime_hours) and custom ones | One usage event |
com.vcluster.vbilling. | Before the tenant's first usage event, again when its details change, and after a restart | The tenant: id, display_name, email, currency, region, project, class, plan, clusters, metadata, provider_ids, created_at |
com.vcluster.vbilling. | Once the tenant has had no tenant clusters for OFFBOARD_GRACE (default 1h) | The tenant |
com.vcluster.vbilling. | Each time vBilling starts, before any usage | Every metric definition: code, name, unit, description, billable, group_keys, custom |
A usage event's CloudEvent id is its usage event ID (vb1_...); the others are tenant-<tenant>-<unix time>, offboard-<tenant>-<unix time> and catalog-<unix time>. Each envelope also carries specversion 1.0, source, subject (the tenant ID; the catalog has none), time (a usage event's window_end), datacontenttype application/json and, on usage events, dataschema. A usage batch, formatted and trimmed to one private node's GPU-hours for one minute:
[{
+ "specversion": "1.0",
+ "id": "vb1_5d0f8e2c9a7b4e1f3c6a8d2b0e9f7a41",
+ "source": "vbilling/us-east-1/us-east-1a",
+ "type": "com.vcluster.vbilling.usage.v1",
+ "subject": "acme-ai",
+ "time": "2026-10-05T10:01:00Z",
+ "datacontenttype": "application/json",
+ "dataschema": "https://github.com/vClusterLabs-Experiments/vbilling/blob/main/docs/schema/usage-event.v1.json",
+ "data": {
+ "schema_version": "vbilling.usage/v1",
+ "id": "vb1_5d0f8e2c9a7b4e1f3c6a8d2b0e9f7a41",
+ "tenant": "acme-ai", "metric": "vcluster_gpu_hours",
+ "quantity": 0.133333333, "unit": "gpu-hours",
+ "window_start": "2026-10-05T10:00:00Z", "window_end": "2026-10-05T10:01:00Z",
+ "region": "us-east-1", "sku": "NVIDIA-H100-80GB-HBM3", "resource_id": "gpu-node-1",
+ "dimensions": {"billing_mode": "private_node", "capacity_type": "reserved",
+ "gpu_profile": "full", "gpu_type": "NVIDIA-H100-80GB-HBM3",
+ "node": "gpu-node-1", "tenant_cluster": "vcluster-acme-ai-train"},
+ "properties": {"gpu_devices": 8, "physical_gpus": 8},
+ "source": "collector", "recorded_at": "2026-10-05T10:01:04Z"
+ }
+}]Headers and signature
+POST /vbilling HTTP/1.1
+Content-Type: application/cloudevents-batch+json
+User-Agent: vbilling/0.2
+X-VBilling-Signature: t=1791194464,v1=<hex HMAC-SHA256>The signature uses the t=...,v1=... scheme of Stripe's Stripe-Signature: an HMAC-SHA256, keyed with your secret, over the timestamp, a period and the raw request body. Verify it on the raw bytes before parsing JSON and reject stale timestamps. A retry is signed again with a new timestamp.
import hashlib, hmac, time
+
+def verify(body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
+ fields = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
+ ts, sig = fields.get("t", ""), fields.get("v1", "")
+ if not ts.isdigit() or abs(time.time() - int(ts)) > tolerance:
+ return False
+ mac = hmac.new(secret.encode(), ts.encode() + b"." + body, hashlib.sha256).hexdigest()
+ return hmac.compare_digest(mac, sig)Responses, retries and dead letters
+| Your endpoint returns | vBilling |
|---|---|
| 2xx | Counts the batch as delivered and moves the cursor on. Return it only once the batch is stored. |
| 400, 413 or 422 | Treats the payload as rejected for good: it splits the batch until each rejected event is alone, parks those as dead letters and delivers the rest. |
| Anything else, or no answer within 30s | Retries the whole batch with exponential backoff (1s, doubling up to 5 min) while later events wait. Auth errors, 404, 409, 429 and 5xx never dead-letter an event. |
id. A batch can arrive twice: after a timeout, a restart before the cursor was saved, a dead-letter replay or a cursor rewind. A usage event keeps its ID for the same window, so upsert on it. Tenant and catalog IDs carry a timestamp: keep the latest. Return 2xx for types you ignore, because vBilling holds all delivery to the endpoint until it accepts the catalog event.Dead letters stay on the ledger volume until a replay clears them. Add -H "Authorization: Bearer $API_TOKEN" when the API is protected:
curl -s localhost:8080/api/v1/destinations/webhook/dead-letters \
+ | jq '.dead_letters[] | {reason, id: .event.id}'
+curl -s -X POST localhost:8080/api/v1/destinations/webhook/dead-letters/replay
+# send the retained ledger (RETENTION, default 7 days) to a new endpoint
+curl -s -X POST localhost:8080/api/v1/destinations/webhook/cursor -d '{"seq": 0}'Ingest API
+Usage vBilling cannot measure itself, such as inference tokens from a model gateway or Slurm accounting, goes in through POST /api/v1/events. Accepted events are committed to the same ledger as collector events and fan out to every adapter, the webhook and your own adapter included. Declare the metric, enable ingest with a token, and send closed windows:
helm upgrade vbilling deploy/helm/vbilling -n vbilling-system --reuse-values \
+ --set metering.customMetrics='inference_output_tokens:tokens:model' \
+ --set api.existingSecret=vbilling-api # keys: api-token, ingest-token
+
+curl -X POST localhost:8080/api/v1/events -H "Authorization: Bearer $INGEST_TOKEN" \
+ -H "X-VBilling-Client: model-gateway" -d '{"events": [{
+ "tenant": "acme-ai", "metric": "inference_output_tokens", "quantity": 1250000,
+ "window_start": "2026-10-05T10:00:00Z", "window_end": "2026-10-05T10:01:00Z",
+ "dimensions": {"model": "llama-3.3-70b"}}]}'-
+
- Ingest is off until
INGEST_TOKEN(orAPI_TOKEN) is set. Up to 5,000 events or 10 MiB per request, as{"events": [...]}or a bare array.
+ - The metric must be built in or declared in
CUSTOM_METRICS(code:unit[:group_key|group_key]). Custom metrics are billable, so billing adapters create them at bootstrap.
+ window_endmay be at most 5 minutes in the future andwindow_startat most 34 days old.regiondefaults toREGION, andsourcebecomesingest:<X-VBilling-Client>.
+ - Send a stable
id, or let vBilling derive one from the event's identity, which excludes the quantity. An ID seen in the last 48 hours is reported as a duplicate and dropped, so send each window once, after it closes.
+ - Events are validated one by one: the response lists accepted IDs, duplicates and rejections with their index and reason, and is a 400 only when no event passed. +
See Configuration and Custom metrics for the full reference.
+ +Go adapter
+For a billing platform with its own API, implement the Destination interface in a Go package. vBilling's packages are under internal/, so the adapter lives in your fork of the repository, at internal/destinations/<name>, and ships in your own image.
// internal/destinations/destination.go (package destinations)
+type Destination interface {
+ Name() string
+ Bootstrap(ctx context.Context, metrics []usage.MetricDef) error
+ EnsureTenant(ctx context.Context, t usage.Tenant) error
+ RemoveTenant(ctx context.Context, t usage.Tenant) error
+ SendEvents(ctx context.Context, events []usage.Event) error
+}| Method | Called | Contract |
|---|---|---|
Name | Wherever the adapter is named: status, logs, /api/v1/destinations/<name> | Return the name you register, the one operators list in adapters (ADAPTERS). A name may be listed once. |
Bootstrap | When delivery starts, with the whole catalog (built-in, informational and custom metrics) | Create meters or billable metrics. No event is delivered until it succeeds; failures are retried with backoff. |
EnsureTenant | Before a tenant's first event reaches the adapter, when its details change, and after a restart | Create or update the customer. A Permanent error is logged and delivery continues; any other error retries the batch. |
RemoveTenant | Once a tenant has had no tenant clusters for OFFBOARD_GRACE | Decide what offboarding means. Never delete a customer with open invoices. An error is retried on the next pass. |
SendEvents | With batches of up to 500 events from the ledger, and with every parked dead letter on a replay | Deliver them. The error you return decides what happens next (below). |
vBilling retries every method freely, so each must be idempotent. Three optional interfaces add capabilities:
+| Interface | Method | Purpose |
|---|---|---|
EventFilter | Accepts(metric string) bool | Skip metrics the platform should not see. destinations.BillableOnly drops the informational ones. Skipped events are counted, never retried. |
Reconciler | RecordedTotals(ctx context.Context, q TotalsQuery) ([]Total, error) | Report what the platform recorded per tenant and metric, so GET /api/v1/reconcile can compare it with the ledger. |
CustomerResolver | TenantForCustomer(ctx context.Context, customerID string) (string, bool) | Map a platform customer ID back to a tenant for inbound billing webhooks. Only the Stripe and Metronome adapters' webhooks (/webhooks/stripe, /webhooks/metronome) consult it today. |
Errors decide what happens to events
+| SendEvents returns | vBilling |
|---|---|
nil | Counts every event as delivered and moves the cursor on. |
&destinations.PartialError{Rejected: rejected}, mapping event IDs to reasons | Dead-letters the listed events and counts the rest as delivered. Return it only when nothing in the batch needs a retry. |
destinations.Permanent(err) | Splits the batch until each rejected event is alone, then dead-letters it. |
| Any other error | Retries the whole batch with backoff while later events wait. Use it for auth errors, rate limits and outages: dead-lettering usage because a key expired would lose revenue. |
destinations.StatusError applies the same rule to HTTP responses: 400, 413 and 422 are permanent, everything else is retried.
Idempotency. Event IDs are deterministic: vb1_ plus a SHA-256 of the event's identity (tenant, metric, window, region, project, SKU, resource ID and dimensions), never of its quantity. A window keeps its ID through restarts, backfills, replays and a second replica. Send ev.ID as the platform's idempotency key and treat "already exists" as success. A retried batch repeats events you already delivered; destinations.SentCache skips them without a request. Events can also arrive late (backfill up to MAX_BACKFILL, default 6h, tenant API gap fills, replays), so accept backdated timestamps, or reject too-old events one by one with a PartialError, as the Stripe adapter does for Stripe's 35-day limit.
A minimal adapter
+internal/destinations/acme/adapter.go, for a platform that takes JSON over HTTPS. Its settings come from the environment in the factory:
// Package acme ships usage to the Acme billing API.
+package acme
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "os"
+
+ "github.com/vclusterlabs-experiments/vbilling/internal/config"
+ "github.com/vclusterlabs-experiments/vbilling/internal/destinations"
+ "github.com/vclusterlabs-experiments/vbilling/internal/usage"
+)
+
+func init() { destinations.Register("acme", factory) }
+
+func factory(cfg *config.Config) (destinations.Destination, error) {
+ url, key := os.Getenv("ACME_URL"), os.Getenv("ACME_API_KEY")
+ if url == "" || key == "" {
+ return nil, errors.New("ACME_URL and ACME_API_KEY must be set")
+ }
+ return New(url, key), nil
+}
+
+type Adapter struct {
+ url, key string
+ http *http.Client
+ sent *destinations.SentCache
+}
+
+func New(url, key string) *Adapter {
+ return &Adapter{url: url, key: key, http: destinations.NewHTTPClient(),
+ sent: destinations.NewSentCache(100_000)}
+}
+
+func (a *Adapter) Name() string { return "acme" }
+
+// Accepts skips informational metrics (GPU utilization, GPU downtime).
+func (a *Adapter) Accepts(metric string) bool { return destinations.BillableOnly(metric) }
+
+func (a *Adapter) Bootstrap(ctx context.Context, metrics []usage.MetricDef) error {
+ for _, m := range metrics {
+ if !m.Billable {
+ continue
+ }
+ meter := map[string]any{"name": m.Name, "unit": m.Unit, "group_by": m.GroupKeys}
+ if err := a.post(ctx, "/meters/"+m.Code, meter); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (a *Adapter) EnsureTenant(ctx context.Context, t usage.Tenant) error {
+ return a.post(ctx, "/customers/"+t.ID, map[string]any{
+ "name": t.DisplayName, "email": t.Email, "currency": t.Currency,
+ })
+}
+
+// RemoveTenant keeps the customer: its final usage still has to be invoiced.
+func (a *Adapter) RemoveTenant(ctx context.Context, t usage.Tenant) error { return nil }
+
+func (a *Adapter) SendEvents(ctx context.Context, events []usage.Event) error {
+ rejected := map[string]string{}
+ for _, ev := range events {
+ if a.sent.Has(ev.ID) {
+ continue // delivered before this batch was retried
+ }
+ err := a.post(ctx, "/usage", map[string]any{
+ "idempotency_key": ev.ID, "customer": ev.Tenant, "meter": ev.Metric,
+ "quantity": ev.Quantity, "timestamp": ev.WindowStart, "sku": ev.SKU,
+ "region": ev.Region, "dimensions": ev.Dimensions,
+ })
+ switch {
+ case err == nil:
+ a.sent.Add(ev.ID)
+ case destinations.IsPermanent(err):
+ rejected[ev.ID] = err.Error() // dead-lettered; the rest is delivered
+ default:
+ return err // the batch is retried with backoff
+ }
+ }
+ if len(rejected) > 0 {
+ return &destinations.PartialError{Rejected: rejected}
+ }
+ return nil
+}
+
+func (a *Adapter) post(ctx context.Context, path string, body any) error {
+ b, err := json.Marshal(body)
+ if err != nil {
+ return err
+ }
+ url := a.url + path
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(b))
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Authorization", "Bearer "+a.key)
+ req.Header.Set("Content-Type", "application/json")
+ resp, err := a.http.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ msg, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
+ if resp.StatusCode >= 200 && resp.StatusCode < 300 {
+ return nil
+ }
+ return destinations.StatusError(http.MethodPost, url, resp.StatusCode, msg)
+}
+
+var (
+ _ destinations.Destination = (*Adapter)(nil)
+ _ destinations.EventFilter = (*Adapter)(nil)
+)Register it with a blank import next to the built-in adapters in cmd/vbilling/main.go:
// Register the remaining built-in adapters.
+ _ "github.com/vclusterlabs-experiments/vbilling/internal/destinations/lago"
+ _ "github.com/vclusterlabs-experiments/vbilling/internal/destinations/noop"
+ _ "github.com/vclusterlabs-experiments/vbilling/internal/destinations/webhook"
+ _ "github.com/vclusterlabs-experiments/vbilling/internal/destinations/acme"Test it the way internal/destinations/webhook/adapter_test.go does: point the adapter at an httptest server and check that a 422 returns a PartialError and a 503 a retryable error. Then build and deploy your image:
go test -race ./internal/destinations/acme/
+make test lint
+make docker-push IMAGE=registry.example.com/vbilling TAG=acme-1
+helm upgrade --install vbilling deploy/helm/vbilling -n vbilling-system \
+ --set adapters='{acme}' \
+ --set image.repository=registry.example.com/vbilling --set image.tag=acme-1The chart has no generic extra environment value: add ACME_URL, and ACME_API_KEY from a Secret, to the container's env in deploy/helm/vbilling/templates/statefulset.yaml in your fork.
Dry runs with noop
+--set adapters='{noop}' delivers nowhere and logs every event, for example [noop] event tenant=acme-ai metric=vcluster_gpu_hours qty=0.133333333 gpu-hours sku=NVIDIA-H100-80GB-HBM3 region=us-east-1 window=10:00:00. Use it to check metering before a billing platform is connected, or list it next to an adapter you are building.
Getting Started
-Go from zero to metered vCluster billing in four steps. This guide walks you through deploying Lago, installing vBilling, configuring pricing, and verifying the full pipeline.
+Install vBilling in your control plane cluster, connect the billing platform you run, and see the first usage arrive. vBilling meters; your billing platform prices, invoices and collects.
Prerequisites
-Before you begin, make sure you have the following tools installed:
-
-
- Docker and Docker Compose (for running Lago locally) -
- kubectl configured to talk to a Kubernetes cluster -
- vCluster CLI (
vcluster) with at least one vCluster running
- - Helm 3 (for the Helm installation path) -
- Go 1.22+ (only if building from source) -
- metrics-server installed in the cluster (for CPU/memory metrics) +
- A control plane cluster with at least one tenant cluster. vBilling meters every tenancy model: tenant clusters with their own nodes (vCluster Private Nodes, Auto Nodes, Standalone), dedicated nodes and shared nodes. +
- kubectl and Helm 3 +
- metrics-server in the control plane cluster, for CPU and memory usage +
- An account on your billing platform (a Stripe sandbox, a Metronome account), or Docker to run Lago locally
app=vcluster label, or via the vCluster Platform API if available. Make sure you have at least one vCluster running before starting.app=vcluster), and through the vCluster Platform API when it is available. No cluster yet? Run everything on a laptop with vind and Lago, or follow the GKE example with real GPUs.1Deploy Lago
-Lago is the open-source billing engine that vBilling sends usage events to. You can run it locally with Docker Compose or deploy it to your cluster.
- -Clone the Lago repository
-git clone https://github.com/getlago/lago.git
-cd lagoGenerate RSA keys for Lago API authentication
-# Generate RSA keypair for JWT signing
-openssl genrsa -out lago.key 2048
-openssl rsa -in lago.key -pubout -out lago.pub
-
-# Base64-encode for environment variables
-export LAGO_RSA_PRIVATE_KEY=$(base64 -w0 lago.key 2>/dev/null || base64 lago.key)
-echo "RSA_PRIVATE_KEY=$LAGO_RSA_PRIVATE_KEY" >> .envStart Lago with Docker Compose
-docker compose up -d
-
-# Wait for services to be healthy (takes ~60 seconds)
-docker compose ps
-
-# Expected output:
-# NAME STATUS
-# lago-api-1 running (healthy)
-# lago-front-1 running
-# lago-db-1 running (healthy)
-# lago-redis-1 running (healthy)
-# lago-worker-1 runningCreate an organization via GraphQL
-Lago needs an organization before you can use the API. Run this GraphQL mutation against the Lago API:
-curl -X POST http://localhost:3000/graphql \
- -H "Content-Type: application/json" \
- -d '{
- "query": "mutation { registerUser(input: { email: \"admin@example.com\", password: \"ILoveLago99!\", organizationName: \"vBilling\" }) { token user { id email } } }"
- }'
-
-# Expected output:
-# {"data":{"registerUser":{"token":"eyJhb...","user":{"id":"...","email":"admin@example.com"}}}}Get the API key from the database
-The API key is stored in the Lago database. Extract it with:
-# Connect to the Lago database and get the API key
-docker compose exec db psql -U lago -d lago \
- -c "SELECT value FROM api_keys LIMIT 1;"
-
-# Expected output:
-# value
-# ---------------------------------
-# a1b2c3d4-e5f6-7890-abcd-ef1234567890
-# (1 row)
-
-# Save it for later
-export LAGO_API_KEY="<your-lago-api-key>"1Choose a billing platform
+vBilling is the pipe, not the billing engine: prices, invoices and payments stay in the billing platform you choose. Each platform has a billing adapter in vBilling, and several adapters can run at once (for example adapters='{stripe,webhook}'), each with its own delivery cursor.
| Billing adapter | Choose it for |
|---|---|
| Stripe | Pay-as-you-go prices per metric and SKU, with Stripe invoices, payments and tax |
| Metronome | Pricing on many dimensions, commits, credits and rate cards, optionally with Stripe collecting payment |
| Lago | Open source, self-hosted billing that also runs on a laptop with Docker Compose |
| Custom Billing Adapter | Your own rating engine or data platform, through signed CloudEvents webhooks or an adapter written in Go |
2Install vBilling
-You can install vBilling via Helm (recommended for production) or run it locally during development.
- -Option A: Helm chart (recommended)
- -The chart defaults to the published image ghcr.io/vclusterlabs-experiments/vbilling:v0.2.0 (linux/amd64 and linux/arm64). Build your own only to run local changes:
# Build multi-arch image
-docker buildx build \
- --platform linux/amd64,linux/arm64 \
- -t <your-registry>/vbilling:dev \
- --push .
-
-# Or single-arch for local testing:
-# docker build -t vbilling:latest .Install with Helm:
-helm upgrade --install vbilling deploy/helm/vbilling \
- --namespace vbilling-system --create-namespace \
- --set lago.apiURL="http://lago-api.lago-system.svc.cluster.local:3000" \
- --set lago.apiKey="$LAGO_API_KEY"
-
-# Expected output:
-# Release "vbilling" does not exist. Installing it now.
-# NAME: vbilling
-# NAMESPACE: vbilling-system
-# STATUS: deployedOr use an existing Kubernetes secret for the API key:
-# Create the secret first
-kubectl create secret generic vbilling-lago-secret \
- -n vbilling-system \
- --from-literal=api-key="$LAGO_API_KEY"
-
-# Install with secret reference
-helm upgrade --install vbilling deploy/helm/vbilling \
- --namespace vbilling-system --create-namespace \
- --set lago.apiURL="http://lago-api.lago-system.svc.cluster.local:3000" \
- --set lago.existingSecret="vbilling-lago-secret"Option B: Run locally (development)
-# Clone the repository
-git clone https://github.com/vClusterLabs-Experiments/vbilling.git
-cd vbilling
-
-# Build the binary
-make build
-
-# Expected output:
-# CGO_ENABLED=0 go build -o bin/vbilling ./cmd/vbilling
-
-# Run with environment variables
-export LAGO_API_URL="http://localhost:3000"
-export LAGO_API_KEY="<your-lago-api-key>"
-./bin/vbilling
-
-# Expected output:
-# vBilling - vCluster Billing Controller
-# =======================================
-# Lago API: http://localhost:3000
-# Plan: vcluster-standard | Currency: USD
-# Collection: 1m0s | Reconcile: 30s
-# Bootstrapping Lago billing configuration...
-# [bootstrap] setting up Lago billing configuration...
-# [bootstrap] created metric "vcluster_cpu_core_hours" (id=...)
-# [bootstrap] created metric "vcluster_memory_gb_hours" (id=...)
-# ...
-# [bootstrap] created plan "vcluster-standard" (id=...) with 9 charges
-# [bootstrap] Lago billing configuration complete
-# Starting billing controller...~/.kube/config to connect to the cluster. Make sure your kubeconfig context points to the cluster with your tenant clusters.3Configure Pricing in Lago
-vBilling bootstraps all 9 billable metrics and a default plan (vcluster-standard) on startup. However, all charges default to $0.00. You must set pricing in the Lago UI.
Open the Lago dashboard
-# Lago UI is available at:
-open http://localhost:80
-
-# Log in with the credentials you used in the GraphQL mutation:
-# Email: admin@example.com
-# Password: ILoveLago99!Edit the plan charges
--
-
- Navigate to Plans in the left sidebar -
- Click on vCluster Standard (code:
vcluster-standard)
- - Scroll to the Charges section -
- For each charge, click the edit icon and set the price per unit: -
- CPU Core-Hours: $0.05 | Memory GB-Hours: $0.01 | Storage GB-Hours: $0.001
- Instance Hours: $0.10 | GPU Hours: $2.50 (default) | Network Egress/GB: $0.05
- LoadBalancer Hours: $0.025 | Private Node Hours: $1.00 -
4Verify It Works
-Once vBilling is running, it will auto-discover your tenant clusters and start sending usage events within one reconcile cycle (default: 30 seconds).
- -Check vBilling logs
-# If running via Helm:
-kubectl logs -n vbilling-system -l app=vbilling -f
-
-# Expected output:
-# [controller] starting billing controller
-# [controller] reconcile interval: 30s, collection interval: 1m0s
-# [controller] new vCluster discovered: vcluster-team-alpha/my-vcluster
-# [controller] ensured customer vcluster-vcluster-team-alpha-my-vcluster
-# [controller] created subscription sub-vcluster-vcluster-team-alpha-my-vcluster -> plan vcluster-standard
-# [metrics] vcluster-team-alpha: CPU=0.250 cores, Memory=0.51 GB
-# [metrics] vcluster-team-alpha: Storage=10.00 GB (2 PVCs)
-# [controller] sent 4 billing events to LagoCheck Lago for customers
-Open the Lago UI and navigate to Customers. You should see one customer per discovered vCluster:
-# Or verify via the API:
-curl -s http://localhost:3000/api/v1/customers \
- -H "Authorization: Bearer $LAGO_API_KEY" | jq '.customers[].external_id'
-
-# Expected output:
-# "vcluster-vcluster-team-alpha-my-vcluster"
-# "vcluster-default-dev-cluster"Check current usage
-curl -s http://localhost:3000/api/v1/customers/vcluster-vcluster-team-alpha-my-vcluster/current_usage\?external_subscription_id=sub-vcluster-vcluster-team-alpha-my-vcluster \
- -H "Authorization: Bearer $LAGO_API_KEY" | jq
-
-# Expected output:
-# {
-# "customer_usage": {
-# "from_datetime": "2025-01-01T00:00:00Z",
-# "to_datetime": "2025-01-31T23:59:59Z",
-# "amount_cents": 1250,
-# "amount_currency": "USD",
-# ...
-# }
-# }Try It Locally with vind
-You can test the full pipeline on your laptop using vind (vCluster in Docker) -- no cloud cluster needed.
- -Create a vind control plane cluster
-# Set Docker driver and create a control plane cluster
-vcluster use driver docker
-vcluster create vbilling-host --connect=true
-
-# Install metrics-server
-kubectl apply -f https://github.com/kubernetes-sigs/metrics-server/releases/latest/download/components.yaml
-kubectl patch deployment metrics-server -n kube-system --type=json \
- -p='[{"op":"add","path":"/spec/template/spec/containers/0/args/-","value":"--kubelet-insecure-tls"}]'Create nested tenant clusters
-vcluster use driver helm
-vcluster create team-alpha --namespace vcluster-team-alpha --connect=false
-vcluster create team-beta --namespace vcluster-team-beta --connect=false
-vcluster create team-gpu --namespace vcluster-team-gpu --connect=falseDeploy vBilling as a pod in the vind cluster
-# Build the image
-docker build --build-arg TARGETARCH=arm64 -t vbilling:test .
-
-# Load into vind's containerd
-docker save vbilling:test | docker exec -i vcluster.cp.vbilling-host \
- ctr -n k8s.io images import --all-platforms -
-
-# Create secret and install via Helm
+ The chart defaults to the published image ghcr.io/vclusterlabs-experiments/vbilling:v0.2.0 (linux/amd64 and linux/arm64). Keep billing keys in Secrets, never in --set flags or values files. From a clone of the repository, with Stripe as the example:
+ git clone https://github.com/vClusterLabs-Experiments/vbilling.git && cd vbilling
kubectl create namespace vbilling-system
-kubectl create secret generic lago-credentials \
- --namespace vbilling-system \
- --from-literal=api-key="$LAGO_API_KEY"
-
-helm upgrade --install vbilling deploy/helm/vbilling \
- --namespace vbilling-system \
- --set image.repository=vbilling \
- --set image.tag=test \
- --set image.pullPolicy=Never \
- --set lago.apiURL=http://host.docker.internal:3000 \
- --set lago.existingSecret=lago-credentials
+kubectl -n vbilling-system create secret generic vbilling-stripe --from-file=api-key=./stripe-test-key
+helm upgrade --install vbilling deploy/helm/vbilling -n vbilling-system \
+ --set adapters='{stripe}' \
+ --set stripe.existingSecret=vbilling-stripe \
+ --set region=us-east-1The other adapters take the same shape:
+| Adapter | Secret keys | Helm values |
|---|---|---|
stripe | api-key, webhook-secret | stripe.existingSecret |
metronome | api-token, webhook-secret | metronome.existingSecret, metronome.rateCard |
lago | api-key | lago.existingSecret, lago.apiURL |
webhook | secret | webhook.existingSecret, webhook.url |
noop | None: logs every event instead of sending it, for a dry run |
Run one vBilling per control plane cluster and set region: it is stamped on every event, and every ledger record stays in that cluster.
Verify
-kubectl logs -n vbilling-system statefulset/vbilling
+ Run from source (development)
+ make build
+ADAPTERS=noop DATA_DIR=./data ./bin/vbilling
+ Outside a cluster, vBilling uses your current kubeconfig context. With noop, each event is logged ([noop] event tenant=... metric=... qty=...) instead of sent.
-# Expected output (v0.2):
-# Using in-cluster Kubernetes config
-# [controller] starting (adapters=[lago], window=1m0s, reconcile=30s, region=default)
-# API listening on :8080
-# [bootstrap] setting up Lago billing configuration...
-# [controller] window 10:18:20: 17 events for 3 tenant cluster(s) (11 pods, 5 GPU pods, 0 nodes down)
-# [lago] subscribed tenant acme to plan vcluster-standard3Configure pricing and packaging
+vBilling creates the metrics in your billing platform; the prices are yours. Every tenant is on the default plan vcluster-standard unless its tenant cluster carries vbilling.vcluster.com/plan.
-
+
- Stripe: a metered price on each meter you charge for, tagged with the plan for automatic subscriptions. See Prices and subscriptions. +
- Metronome: rate card products on the billable metrics, priced on dimensions such as SKU, region and capacity type. See Pricing on dimensions. +
- Lago: the plan's charges, with charge filters on
skuorcapacity_type. See Configure pricing.
+
Every event carries the dimensions to price on, among them sku, region, capacity_type (on-demand, spot, preemptible or reserved), billing_mode and tenant_class. See the Metrics Reference.
4Verify it works
+vBilling meters closed windows (60 seconds by default), commits them to its ledger, and delivers them to each adapter.
+kubectl -n vbilling-system logs statefulset/vbilling
+Using in-cluster Kubernetes config
+[controller] starting (adapters=[stripe], window=1m0s, reconcile=30s, region=us-east-1)
+API listening on :8080
+[controller] window 10:18:00: ... events for 3 tenant cluster(s) (...)kubectl -n vbilling-system port-forward svc/vbilling 8080
+# dashboard: http://localhost:8080/
+curl -s localhost:8080/api/v1/status | jq '.destinations' # delivery health per adapter
+curl -s 'localhost:8080/api/v1/usage?group_by=tenant,sku' | jq # line items from the ledger
+curl -s localhost:8080/api/v1/reconcile | jq '.results[] | {destination, ok}' # Stripe and Metronomekubectl -n vbilling-system port-forward svc/vbilling 8080 and curl localhost:8080/api/v1/status.Clean up
-vcluster use driver docker
-vcluster delete vbilling-host
-cd deploy/lago && docker compose down -vNext Steps
+Next steps
-
-
- Configuration -- tune intervals, namespaces, and pricing -
- Metrics Reference -- understand all 9 billable metrics -
- Dedicated and Private Nodes -- set up dedicated node billing -
- Troubleshooting -- common issues and fixes +
- Configuration: intervals, tenants and projects, regions, the API and enforcement +
- Metrics Reference: the eight billable metrics and the two informational ones +
- Dedicated and Private Nodes: whole GPU nodes, including vCluster Private Nodes, Auto Nodes and Standalone +
- Example: GKE with GPUs: an end-to-end setup with real GPUs and Stripe +
- Troubleshooting: common issues and fixes
Usage-based billing for AI Clouds running vCluster or vMetalRead the docs View on GitHub
New in v0.2: Stripe and Metronome, a verifiable ledger, vCluster Private Nodes, and DCGM through your own Prometheus. See it on GKE with shared and private GPUs →
+New in v0.2: Stripe and Metronome, a verifiable ledger, vCluster Private Nodes, and DCGM through your own Prometheus. See it end to end on GKE with real GPUs →
Usage-based billing for AI Clouds running vCluster or vMetal
Adapter-agnostic
- Ships to your billing backend
+ Ships to your billing platform
vBilling is the pipe, not the billing engine. Meter tenant clusters once and route events to whichever billing adapter you run.
Adapter-agnostic
-Ships to your billing backend
+Ships to your billing platform
vBilling is the pipe, not the billing engine. Meter tenant clusters once and route events to whichever billing adapter you run.
Three steps to tenant billing
Choose Billing Platform
-Pick the billing backend you already run: Stripe, Metronome (with Stripe for payments), Lago, or your own pipeline through a signed webhook. Send to several at once.
+Pick the billing platform you already run: Stripe, Metronome (with Stripe for payments), Lago, or your own pipeline through a signed webhook. Send to several at once.
02 · Configure
@@ -1148,7 +1148,7 @@Configure Pricing & Packaging
Connect vBilling Adapter
-Deploy vBilling and point it at your backend. Tenant clusters are auto-discovered, and usage starts flowing from the next closed window.
+Deploy vBilling and point it at your billing platform. Tenant clusters are auto-discovered, and usage starts flowing from the next closed window.
Connect vBilling Adapter
Features
Everything you need to bill tenants
-vBilling meters shared and dedicated GPU capacity, commits it to a durable ledger, and delivers it to every billing backend you run, exactly once.
+vBilling meters GPU capacity in every tenancy model, commits it to a durable ledger, and delivers it to every billing platform you run, exactly once.
GPU SKU-aware
Fractional GPUs
-MIG slices and time-sliced GPUs are metered at their fraction, so shared GPUs can be sold by the slice.
+MIG slices and time-sliced GPUs are metered at their fraction of a GPU, so every slice can carry its own price.
Fair metering
Dedicated nodes
-Bill whole bare-metal nodes by SKU or instance type, without double-billing the pods that run on them.
+Node capacity tracking
+Meter the full capacity of dedicated nodes (CPU, memory and GPU SKUs), billed by SKU or instance type, without double-billing the pods that run on them.
Regions and sovereignty
Auto-discovery
-Finds tenant clusters and vCluster Platform projects. Customers, meters and metrics are created in your backend automatically.
+Finds tenant clusters and vCluster Platform projects. Customers, meters and metrics are created in your billing platform automatically.
Auto-discovery
Architecture
How the pieces fit together
-vBilling runs as a controller in your Control Plane Cluster. It watches tenant clusters, collects capacity and usage metrics, and streams events to the billing adapter you choose.
+vBilling runs as a controller in your control plane cluster. It watches tenant clusters, collects capacity and usage metrics, and streams events to the billing adapter you choose.
How the pieces fit together
Quick start
Up and running in 3 steps
-Pick your billing adapter below. Each tab walks through deploy, install, and pricing for that platform.
+Pick your billing adapter below. Each tab walks through deploy, install, and pricing for that platform; the full guides cover Stripe, Metronome, Lago and a custom billing adapter.
Install vBilling with Metronome
Price on dimensions
-vBilling creates SUM billable metrics grouped on region, sku, capacity_type, billing_mode and tenant_cluster. Add rate card products with pricing group keys.
+vBilling creates SUM billable metrics, each with one compound group key (for GPU hours: region, sku, capacity_type, billing_mode, tenant_cluster). Add rate card products whose pricing and presentation group keys together form it.
# Metronome product on vcluster_gpu_hours
-pricing_group_key: [region, sku, capacity_type]
+pricing_group_key: [region, sku, capacity_type, billing_mode]
presentation_group_key: [tenant_cluster]
Reconcile
Build your own adapter
-vBilling is structured so any HTTP billing API can plug in. Implement the Destination interface in a small Go package, rebuild the image, and point vBilling at your endpoint.
Or skip Go entirely: the webhook destination streams signed CloudEvents batches to any HTTP endpoint, and the ingest API accepts usage from your own services.
vBilling is structured so any HTTP billing API can plug in. Implement the Destination interface in a Go package in your fork of vBilling, register it, rebuild the image, and select it in adapters.
Or skip Go entirely: the webhook destination streams signed CloudEvents batches to any HTTP endpoint, and the ingest API accepts usage from your own services. See the Custom Billing Adapter guide.
Default events, zero manual tagging
For AI Clouds
Built for AI Clouds
-If you run an AI cloud on Kubernetes, vBilling gives you the billing pipe, without forcing a billing backend on you.
+If you run an AI Cloud on Kubernetes, vBilling gives you the billing pipe, without forcing a billing platform on you.
-
AI Clouds give each customer a tenant cluster with dedicated GPU nodes. vBilling detects the hardware, meters node capacity by GPU SKU, and streams events into whichever billing adapter you run.
+AI Clouds give each customer an isolated tenant cluster with its own GPU nodes. vBilling detects the hardware, meters node capacity by GPU SKU, and streams events into whichever billing adapter you run.
-
-
- Shared, fractional (MIG, time-sliced) and dedicated GPUs, metered to the second +
- Whole GPU nodes (Private Nodes, Standalone, dedicated nodes) and fractional GPUs (MIG, time-slicing), metered to the second
- SKU, region and capacity-type dimensions priced in your billing platform
- No charges for provisioning, image pulls, or unhealthy hardware
- Durable ledger, backfill and reconciliation: metering held to financial-data standards -
- Stripe, Metronome, Lago or a webhook: keep your existing billing backend +
- Stripe, Metronome, Lago or a custom billing adapter: keep your existing billing platform