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.

i
A Stripe meter prices one quantity. To price several dimensions at once (region, SKU and capacity type), or to sell commits and credits, rate usage in Metronome and let Stripe collect payment.

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-1

vBilling 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 valueEnvironment variableDefaultEffect
stripe.existingSecretSecret with the keys api-key and, for webhooks, webhook-secret. Use it instead of the two values below.
stripe.apiKeySTRIPE_API_KEYSecret or restricted key. Required when stripe is in adapters.
stripe.webhookSecretSTRIPE_WEBHOOK_SECRETSigning secret (whsec_...). Enables /webhooks/stripe.
stripe.splitMetersBySTRIPE_SPLIT_METERS_BY[sku]Event fields that get their own meter. See Meters.
stripe.autoSubscribeSTRIPE_AUTO_SUBSCRIBEfalseSubscribe each tenant to the prices tagged with its plan.
stripe.cancelOnRemoveSTRIPE_CANCEL_ON_REMOVEfalseCancel an offboarded tenant's subscription at the end of the period.
stripe.maxRPSSTRIPE_MAX_RPS0Request rate cap. 0 picks 20 requests/s for test keys and 200 for live keys.
stripe.apiVersionSTRIPE_API_VERSION2026-09-30.endiveStripe API version sent on every request.
stripe.apiBaseSTRIPE_API_BASEhttps://api.stripe.comAPI base URL, for stripe-mock or a proxy.
billing.defaultPlanCodeDEFAULT_PLAN_CODEvcluster-standardPlan of tenants without a plan annotation.
billing.currencyBILLING_CURRENCYUSDCurrency 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.

MetricMeters that receive events (split on sku)SKU comes from
GPU hoursvcluster_gpu_hours__nvidia_h100_80gb_hbm3
vcluster_gpu_hours__nvidia_a100_sxm4_40gb_mig_1g_5gb
vcluster_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 hoursvcluster_private_node_hours__bm_gpu_h100_8The SKU label, the instance type, or <n>x<GPU model>
Storage GiB-hoursvcluster_storage_gb_hours__standard_rwoThe storage class
CPU core-hours, memory GiB-hours, tenant cluster hours, LoadBalancer hours, network egressvcluster_cpu_core_hours, vcluster_memory_gb_hours, and so onNo SKU: the base meter
!
Put prices on the meters that receive events. GPU usage lands on 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__spot

Every 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:

  1. A customer ID pinned with vbilling.vcluster.com/stripe-customer-id.
  2. The customer vBilling recorded earlier in state-stripe.json on its volume.
  3. 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.
  4. 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 fieldValue
name, emailThe 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:

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.

ConcernWhat vBilling does
DuplicatesThe 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 timeThe meter event timestamp is the window start, so late deliveries and backfills land in the period they belong to.
PrecisionQuantities are plain decimals with at most 15 significant digits and 9 decimals.
RateA token bucket at maxRPS, and four workers partitioned by customer and meter, so a customer's meter never gets concurrent calls.
RetriesAuthentication 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 lettersEvents Stripe rejects as invalid (400, 413, 422) are isolated one by one and parked with the reason; the rest of the batch goes through.
!
Stripe enforces identifier uniqueness for at least 24 hours and accepts events up to 35 days old. vBilling never resends acknowledged events, and dead-letters events older than 35 days minus an hour of margin. Do not rewind the Stripe destination's cursor by more than 24 hours, or Stripe may count those events again; rewinding is meant for a newly added destination.

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 eventBilling state
invoice.payment_faileddelinquent
invoice.paid, invoice.payment_succeededactive
customer.subscription.created, .updated, .resumedBy subscription status: past_due is delinquent; unpaid, canceled and incomplete_expired are suspended; active and trialing are active
customer.subscription.deletedsuspended
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/vbilling

vBilling 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/stripe

Events 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:

ModeEffect
observe (default)Record the state only.
annotateAlso annotate the tenant cluster, label its namespace vbilling.vcluster.com/billing-state, and emit a Kubernetes event.
enforceAlso 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 limitWhat vBilling does
Meter events at most 35 days oldOlder 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 hoursAcknowledged events are never resent. Keep cursor rewinds within 24 hours.
Event names up to 100 charactersLonger split meter names end in a hash.
20 prices per subscriptionAutomatic 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 meterMeters are split by splitMetersBy. For pricing on many dimensions, use Metronome.
Customer search is not offered to every accountvBilling lists customers instead, still requiring an exact metadata[vbilling_tenant_id] match.

Troubleshooting

The invoice shows no usage

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