Stripe
Bill tenant clusters with Stripe Billing Meters. vBilling creates the meters and customers, sends every usage window exactly once, keeps subscriptions in step with the prices you tag, and turns Stripe's payment events into billing states for your tenant clusters.
How it works
tenant clusters ──► vBilling ──► ledger (fsynced, hash-chained) │ stripe cursor ▼ Stripe adapter ├─ Billing Meters one per metric, one per SKU ├─ Customers one per tenant, metadata vbilling_tenant_id ├─ Meter events identifier = event ID, timestamp = window start └─ Subscriptions optional: prices tagged vbilling_plan Stripe ──► /webhooks/stripe ──► billing state ──► tenant cluster invoices, subscriptions, active, warning, usage alerts delinquent, suspended
vBilling does
Meters, customers and meter events; retries and dead letters; optional subscriptions; reconciliation against Stripe; billing states from Stripe webhooks.
You do in Stripe
Products and prices, invoice and tax settings, payment methods, Smart Retries and dunning emails, and the customer portal.
Install in test mode
Start with a sandbox or test-mode key: a secret key (sk_test_...) or a restricted key (rk_test_...). Save it to a file and keep it in a Secret, never in --set flags or values files. From a clone of the repository:
kubectl create namespace vbilling-system
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-1vBilling creates one meter per billable metric at startup, and a customer for each tenant as its usage arrives. Check the log and the destination's health:
kubectl -n vbilling-system logs statefulset/vbilling | grep '\[stripe\]'
[stripe] created meter vcluster_gpu_hours (mtr_...)
[stripe] created customer cus_... for tenant vcluster-team-a-team-a
kubectl -n vbilling-system port-forward svc/vbilling 8080 &
curl -s localhost:8080/api/v1/status | jq '.destinations'For CI, run stripe-mock and set stripe.apiBase to it: every request is validated against Stripe's OpenAPI spec, as in scripts/e2e-kind.sh.
Settings
| Helm value | Environment variable | Default | Effect |
|---|---|---|---|
stripe.existingSecret | Secret with the keys api-key and, for webhooks, webhook-secret. Use it instead of the two values below. | ||
stripe.apiKey | STRIPE_API_KEY | Secret or restricted key. Required when stripe is in adapters. | |
stripe.webhookSecret | STRIPE_WEBHOOK_SECRET | Signing secret (whsec_...). Enables /webhooks/stripe. | |
stripe.splitMetersBy | STRIPE_SPLIT_METERS_BY | [sku] | Event fields that get their own meter. See Meters. |
stripe.autoSubscribe | STRIPE_AUTO_SUBSCRIBE | false | Subscribe each tenant to the prices tagged with its plan. |
stripe.cancelOnRemove | STRIPE_CANCEL_ON_REMOVE | false | Cancel an offboarded tenant's subscription at the end of the period. |
stripe.maxRPS | STRIPE_MAX_RPS | 0 | Request rate cap. 0 picks 20 requests/s for test keys and 200 for live keys. |
stripe.apiVersion | STRIPE_API_VERSION | 2026-09-30.endive | Stripe API version sent on every request. |
stripe.apiBase | STRIPE_API_BASE | https://api.stripe.com | API base URL, for stripe-mock or a proxy. |
billing.defaultPlanCode | DEFAULT_PLAN_CODE | vcluster-standard | Plan of tenants without a plan annotation. |
billing.currency | BILLING_CURRENCY | USD | Currency of tenants without a currency annotation. |
Restricted keys. A restricted key works if it can write customers and Billing Meters (meters and meter events) and read prices, plus write subscriptions when autoSubscribe or cancelOnRemove is on.
Meters
At startup vBilling creates, or reactivates, one meter per billable metric: sum aggregation, customers mapped by stripe_customer_id, the quantity in value. Informational metrics (vcluster_gpu_utilization, vcluster_gpu_downtime_hours) are never sent to Stripe.
Because a meter cannot price on dimensions, each event goes to a meter named after its metric plus the value of every field in splitMetersBy, created the first time that value appears. Values are lowercased and every other character becomes _; a name longer than Stripe's 100 characters ends in a short hash.
| Metric | Meters that receive events (split on sku) | SKU comes from |
|---|---|---|
| GPU hours | vcluster_gpu_hours__nvidia_h100_80gb_hbm3vcluster_gpu_hours__nvidia_a100_sxm4_40gb_mig_1g_5gbvcluster_gpu_hours__nvidia_l4_timeslice_4 | The GPU model or the node's SKU label, plus the MIG profile or time-slicing replicas for fractional GPUs |
| Dedicated node hours | vcluster_private_node_hours__bm_gpu_h100_8 | The SKU label, the instance type, or <n>x<GPU model> |
| Storage GiB-hours | vcluster_storage_gb_hours__standard_rwo | The storage class |
| CPU core-hours, memory GiB-hours, tenant cluster hours, LoadBalancer hours, network egress | vcluster_cpu_core_hours, vcluster_memory_gb_hours, and so on | No SKU: the base meter |
vcluster_gpu_hours__<sku>, so the base vcluster_gpu_hours meter stays empty and a price on it invoices nothing.STRIPE_KEY=$(cat ./stripe-test-key)
curl -s -G https://api.stripe.com/v1/billing/meters -u "$STRIPE_KEY:" -d limit=100 | jq -r '.data[] | "\(.id) \(.event_name)"'Pricing on more dimensions
Add fields to the split to give them their own meters and prices. Any event field works: sku, region, project, or a dimension such as capacity_type, billing_mode or tenant_class. Suffixes follow the order of the list:
--set stripe.splitMetersBy='{sku,capacity_type}'
# vcluster_gpu_hours__nvidia_h100_80gb_hbm3__on_demand
# vcluster_gpu_hours__nvidia_h100_80gb_hbm3__spotEvery field multiplies the meters and prices you manage. Settle the split before you go live: a new split sends new usage to new meters, and earlier usage stays on the old ones.
Customers
Each vBilling tenant gets one Stripe customer. By default a tenant is one tenant cluster, with the ID vcluster-<namespace>-<name>; the vbilling.vcluster.com/tenant key groups several tenant clusters under one customer, and TENANT_SOURCE=project gives each vCluster Platform project one. vBilling finds the customer in this order:
- A customer ID pinned with
vbilling.vcluster.com/stripe-customer-id. - The customer vBilling recorded earlier in
state-stripe.jsonon its volume. - A Stripe customer whose
metadata[vbilling_tenant_id]is exactly the tenant ID. vBilling uses customer search, and lists customers instead (by email first) for accounts without search. - A new customer, created with an idempotency key so that a retry never creates a second one.
Customer details come from labels or annotations on the tenant cluster's StatefulSet, its namespace, or its vCluster Platform VirtualClusterInstance, and are updated when they change:
kubectl annotate namespace team-a \
vbilling.vcluster.com/tenant=acme-ai \
vbilling.vcluster.com/display-name="Acme AI" \
vbilling.vcluster.com/email=billing@acme-ai.example \
vbilling.vcluster.com/plan=vcluster-standard
# reuse a customer that already exists in Stripe
kubectl annotate namespace team-a vbilling.vcluster.com/stripe-customer-id=cus_EXISTING| Customer field | Value |
|---|---|
name, email | The display-name (or the tenant ID) and email keys |
metadata[vbilling_tenant_id] | The tenant ID. vBilling only uses a customer whose value matches exactly. |
metadata[vbilling_plan] | The tenant's plan |
metadata[vbilling_region], [vbilling_project], [vbilling_tenant_class] | The region, vCluster Platform project and tenant class |
metadata[vbilling_clusters] | The tenant clusters billed to this customer |
vBilling never deletes customers. Do not delete them in Stripe either: to move a tenant to another customer, pin it.
Prices and subscriptions
vBilling never creates prices: what a GPU-hour costs is your decision. Create a recurring, metered price on each meter you charge for. Quantities are hours (GPU-hours, node-hours, GiB-hours), so unit prices are per hour. Fractional GPUs are already counted in GPU-equivalents (a 1g.5gb slice of an A100 is 1/7), so one per-GPU-hour price fits every variant of a SKU, or the slices can carry a premium.
METER=$(curl -s -G https://api.stripe.com/v1/billing/meters -u "$STRIPE_KEY:" -d limit=100 \
| jq -r '.data[] | select(.event_name=="vcluster_gpu_hours__nvidia_h100_80gb_hbm3") | .id')
curl -s https://api.stripe.com/v1/prices -u "$STRIPE_KEY:" \
-d currency=usd -d unit_amount_decimal=250 \
-d "recurring[interval]=month" -d "recurring[usage_type]=metered" \
-d "recurring[meter]=$METER" \
-d "product_data[name]=H100 GPU-hours" \
-d "metadata[vbilling_plan]=vcluster-standard"unit_amount_decimal is in cents: 250 is $2.50 per GPU-hour.
Automatic subscriptions
Without autoSubscribe, usage accrues on the meters and is invoiced once you subscribe the customer to prices yourself. With --set stripe.autoSubscribe=true, vBilling subscribes every tenant to the prices of its plan:
- The plan is the tenant's
vbilling.vcluster.com/plan, orbilling.defaultPlanCode(vcluster-standard). - A plan's prices are the active metered prices on vBilling's meters tagged
metadata[vbilling_plan]=<plan>, in the tenant's currency (vbilling.vcluster.com/currency, orbilling.currency). - A plan holds up to 20 prices, Stripe's limit per subscription. A tenant on a larger plan is logged as rejected and not subscribed, and its usage keeps reaching its meters.
- An existing vBilling subscription gets missing prices added. Prices are never removed from it.
- New prices reach new tenants within five minutes, and existing tenants when vBilling restarts or their labels and annotations change.
Plans let each tenant pay for what it buys. A tenant that rents whole GPU machines (dedicated or private nodes) can be on a plan with node-hour prices only, and a tenant that pays per GPU-hour on a plan with GPU-hour prices. Stripe only invoices a customer for the meters its subscription has prices on.
Invoices
Stripe sums each meter over the billing period and adds a line per price to the subscription's invoice; payment collection, tax, retries and dunning stay in Stripe. To see what a tenant owes so far, open the customer's upcoming invoice in the Dashboard, or preview it:
curl -s https://api.stripe.com/v1/invoices/create_preview -u "$STRIPE_KEY:" -d customer=cus_... \
| jq '.lines.data[] | {description, amount}'Stripe processes meter events asynchronously, so new usage can take a few minutes to show up.
Delivery and idempotency
Every closed window is committed to the ledger first. The Stripe destination sends it from its own cursor, so a Stripe outage never loses usage and never holds up other destinations.
| Concern | What vBilling does |
|---|---|
| Duplicates | The meter event identifier is the vBilling event ID: a hash of the event's identity (tenant, metric, window, dimensions), never of its quantity. A resend after a crash is rejected by Stripe as a duplicate and counted as delivered. |
| Event time | The meter event timestamp is the window start, so late deliveries and backfills land in the period they belong to. |
| Precision | Quantities are plain decimals with at most 15 significant digits and 9 decimals. |
| Rate | A token bucket at maxRPS, and four workers partitioned by customer and meter, so a customer's meter never gets concurrent calls. |
| Retries | Authentication errors (401, 403), 404, conflicts (409), rate limits (429), 5xx and network errors are retried with backoff and never dead-lettered. Stripe's Stripe-Should-Retry header wins when present. |
| Dead letters | Events Stripe rejects as invalid (400, 413, 422) are isolated one by one and parked with the reason; the rest of the batch goes through. |
After fixing what caused a rejection (often by upgrading vBilling), replay the parked events. Events Stripe still rejects stay parked (still_rejected). With API_TOKEN set, add -H "Authorization: Bearer $API_TOKEN".
curl -s localhost:8080/api/v1/destinations/stripe/dead-letters | jq
curl -s -X POST localhost:8080/api/v1/destinations/stripe/dead-letters/replay
{"replayed":12}Reconciliation
GET /api/v1/reconcile recomputes each tenant's usage per metric from the ledger, subtracts what was dead-lettered, and compares it with Stripe's meter event summaries for the tenant's customer, summed over every split meter of the metric. A row matches within 0.1%. The default range is the previous full UTC day; from and to take RFC 3339 times or dates. tenant and metric narrow it down; sku, region, project and cluster pick the tenants and metrics with such usage, and each row still compares all of that tenant's usage of the metric, because Stripe reports totals per customer.
curl -s 'localhost:8080/api/v1/reconcile?destination=stripe&from=2026-10-05&to=2026-10-06' | jq
{
"from": "2026-10-05T00:00:00Z",
"to": "2026-10-06T00:00:00Z",
"results": [{
"destination": "stripe",
"ok": true,
"rows": [
{"tenant": "acme-ai", "metric": "vcluster_gpu_hours", "ledger": 2.75, "dead_lettered": 0,
"expected": 2.75, "recorded": 2.75, "difference": 0, "match": true}
]
}]
}Stripe's summaries are aligned to minutes and trail ingestion by a few minutes, so reconcile closed periods rather than the current hour. Run it daily and alert when ok is false.
Webhooks and billing states
Stripe's payment events tell vBilling which tenants are paid up. Add a webhook endpoint in Stripe for https://<your-host>/webhooks/stripe with the events below, then add its signing secret to the Secret and restart vBilling:
| Stripe event | Billing state |
|---|---|
invoice.payment_failed | delinquent |
invoice.paid, invoice.payment_succeeded | active |
customer.subscription.created, .updated, .resumed | By subscription status: past_due is delinquent; unpaid, canceled and incomplete_expired are suspended; active and trialing are active |
customer.subscription.deleted | suspended |
billing.alert.triggered (a usage alert on a meter) | warning |
kubectl -n vbilling-system create secret generic vbilling-stripe \
--from-file=api-key=./stripe-test-key --from-file=webhook-secret=./stripe-webhook-secret \
--dry-run=client -o yaml | kubectl apply -f -
kubectl -n vbilling-system rollout restart statefulset/vbillingvBilling verifies the Stripe-Signature header (HMAC-SHA256 over the timestamp and the body, with the five-minute tolerance of Stripe's libraries) and applies each event once. It answers 400 to a bad signature, 200 with "applied": false for events it ignores, repeats, or customers it does not manage, and 500 when it could not record the state, so that Stripe retries.
Only the webhook path needs to be reachable from Stripe. Keep /api on the internal Service, for example with an Ingress for that one path:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: vbilling-webhooks
namespace: vbilling-system
spec:
ingressClassName: nginx
tls: [{hosts: [billing-hooks.example.com], secretName: billing-hooks-tls}]
rules:
- host: billing-hooks.example.com
http:
paths:
- path: /webhooks/stripe
pathType: Exact
backend:
service: {name: vbilling, port: {number: 8080}}To try it locally, let the Stripe CLI forward events. It prints a signing secret for the session to use as webhook-secret:
stripe listen --forward-to localhost:8080/webhooks/stripeEvents fired with stripe trigger belong to new test customers that vBilling does not manage, so they come back with "applied": false. To see a state change, act on one of vBilling's customers, for example by paying its invoice with one of Stripe's declining test cards.
What a billing state does
Stripe's webhooks set billing states once webhook-secret is set. States are recorded in every mode and shown by GET /api/v1/billing-states, the metrics and the dashboard. enforcement.mode decides what else happens:
| Mode | Effect |
|---|---|
observe (default) | Record the state only. |
annotate | Also annotate the tenant cluster, label its namespace vbilling.vcluster.com/billing-state, and emit a Kubernetes event. |
enforce | Also label suspended namespaces vbilling.vcluster.com/suspended=true. With enforcement.admissionPolicy.enabled=true (Kubernetes 1.30+), new pods are denied there while running workloads keep running. A paid invoice lifts it. |
| The admission policy acts in the control plane cluster, so it holds tenant clusters on shared or dedicated nodes. Tenant clusters with their own nodes (Private Nodes, Auto Nodes, Standalone) run their pods only inside the tenant cluster: act on their billing state with your own automation. | |
Override a mapping with enforcement.rules (ENFORCEMENT_RULES), for example invoice.payment_failed=suspended to suspend on the first failed payment, or billing.alert.triggered=suspended to turn a Stripe usage alert into a hard cap. Operators can also set a state by hand:
curl -s -X PUT localhost:8080/api/v1/billing-states/acme-ai -d '{"state": "active", "reason": "paid by bank transfer"}'Offboarding
When a tenant has been gone for billing.offboardGrace (default 1h), vBilling offboards it. The Stripe customer and its subscription stay, so final usage is still invoiced. With stripe.cancelOnRemove=true, the subscription is set to cancel at the end of the period.
Stripe limits
| Stripe limit | What vBilling does |
|---|---|
| Meter events at most 35 days old | Older events are dead-lettered as older than Stripe's 35-day meter event window. Normal backfills (billing.maxBackfill, default 6h) stay far inside it. |
| Identifiers unique for at least 24 hours | Acknowledged events are never resent. Keep cursor rewinds within 24 hours. |
| Event names up to 100 characters | Longer split meter names end in a hash. |
| 20 prices per subscription | Automatic subscriptions refuse a plan with more than 20 prices and log the tenant as rejected, without holding up anyone's usage. Split the plan. |
| Request rate (sandboxes share 25 requests/s) | 20 requests/s for test keys and 200 for live keys, adjustable with maxRPS. 429s are retried. |
| No pricing on dimensions within a meter | Meters are split by splitMetersBy. For pricing on many dimensions, use Metronome. |
| Customer search is not offered to every account | vBilling lists customers instead, still requiring an exact metadata[vbilling_tenant_id] match. |
Troubleshooting
The invoice shows no usage
- The price is on a meter that receives nothing, usually the base meter instead of the SKU meter. List the meters and compare with the event names in the log.
- The tenant is not subscribed. With
autoSubscribe, the log says[stripe] no active metered prices tagged vbilling_plan=<plan> in <currency>; tenant <id> not subscribed: check that the price is active and metered, carries the plan in its metadata, and is in the tenant's currency. - The usage is still being processed: Stripe aggregates meter events asynchronously.
Deliveries stop with 401 or 403
The key is wrong or revoked, or a restricted key lacks a permission. Nothing is lost: events wait in the ledger and are retried. Fix the Secret and restart vBilling; /api/v1/status shows each destination's failures and its oldest pending event.
Events dead-lettered as older than 35 days
Stripe no longer accepts them. They stay in the ledger, and reconciliation reports them under dead_lettered. Bill them with a one-off invoice item if needed.
Webhooks fail with 400 or 404
400: the signing secret does not match the endpoint (every endpoint, and every stripe listen session, has its own), the clock is more than five minutes off, or a proxy rewrote the body. 404: webhook-secret is not set.
"customer search unavailable"
[stripe] customer search unavailable (...); finding customers by listing them is expected for accounts without customer search. vBilling switches to listing customers on its own.
See also
- Example: GKE with GPUs: a complete setup from an empty project to Stripe invoices.
- Metronome: rating in Metronome with payment, invoices and tax in Stripe.
- Configuration reference: every setting, including the API and enforcement.