Metronome

The Metronome billing adapter ships vBilling usage to Metronome for rating and invoicing. vBilling creates the customers, billable metrics and contracts; your rate cards set the prices. With the Stripe link on, Stripe collects payment, invoices and tax.

!
The adapter's tests validate every request body against Metronome's published OpenAPI schemas and check webhook verification against Metronome's documented test vector. They do not run against a live Metronome account, so run vBilling against a Metronome sandbox before you invoice from it.

When to choose Metronome

Both billing platforms receive the same usage. They differ in where pricing lives.

Stripe Billing Meters

One meter per metric, split per SKU, with a metered price on each. A good fit for a pay-as-you-go price list. See Stripe.

Metronome

One billable metric per metric, priced on dimensions such as region, SKU and capacity type. Contracts add commits, credits and rate cards in the currency you invoice in.

ModeSettingsWho does what
Metronomeadapters: [metronome]Metronome rates usage and produces invoices.
Metronome + Stripeadapters: [metronome]
metronome.stripeLink: true
Metronome rates usage. vBilling creates each tenant's Stripe customer and links it, so Stripe collects payment, invoices and tax. The stripe adapter is not needed.
Plus a data feedadapters: [metronome, webhook]The same events also stream as signed CloudEvents to your data platform. Each destination has its own cursor, so one outage never blocks the other.

Install

You need a Metronome API token. For Metronome + Stripe, also connect your Stripe account in Metronome and give vBilling a key for the same Stripe account: the customer IDs it links must exist there. Keep both in Secrets; values passed with --set are stored in the Helm release.

kubectl create namespace vbilling-system kubectl -n vbilling-system create secret generic vbilling-metronome --from-file=api-token=./metronome-token helm upgrade --install vbilling deploy/helm/vbilling -n vbilling-system \ --set adapters='{metronome}' \ --set metronome.existingSecret=vbilling-metronome \ --set metronome.rateCard=payg-usd \ --set region=us-east-1

For Metronome + Stripe, add a Stripe Secret and turn on the link:

kubectl -n vbilling-system create secret generic vbilling-stripe --from-file=api-key=./stripe-key helm upgrade vbilling deploy/helm/vbilling -n vbilling-system --reuse-values \ --set metronome.stripeLink=true \ --set stripe.existingSecret=vbilling-stripe

Settings

Helm valueEnvironment variableDefaultDescription
adaptersADAPTERS[lago]Include metronome.
metronome.existingSecretMETRONOME_API_TOKEN, METRONOME_WEBHOOK_SECRET(none)Secret with the key api-token and, for webhooks, webhook-secret.
metronome.rateCardMETRONOME_RATE_CARD(none)Rate card alias or ID. Each tenant gets a contract on it; a tenant's vbilling.vcluster.com/plan overrides it.
metronome.stripeLinkMETRONOME_STRIPE_LINKfalseCreate each tenant's Stripe customer and link it in Metronome. Needs stripe.existingSecret.
metronome.stripeCollectionMethodMETRONOME_STRIPE_COLLECTION_METHODcharge_automaticallyOr send_invoice.
stripe.existingSecretSTRIPE_API_KEY, STRIPE_WEBHOOK_SECRET(none)Secret with the key api-key and, for Stripe webhooks, webhook-secret.
metronome.apiBaseMETRONOME_API_BASEhttps://api.metronome.comAPI base URL.
metronome.apiToken, metronome.webhookSecret(none)Inline alternatives to the Secret. Avoid them.

What vBilling creates in Metronome

Customers

Each tenant becomes one customer whose ingest alias is the tenant ID: vcluster-<namespace>-<name> by default, or the value of vbilling.vcluster.com/tenant. vBilling looks the customer up by alias before creating it, and a 409 on the alias resolves to the existing customer, so a tenant never gets two.

Billable metrics

At startup vBilling creates one SUM billable metric per billable metric code. It sums the value property, filters on the event type (the metric code) and requires value and every group-key property to be present. Each metric gets one compound group key: the metric's pricing dimensions plus tenant_cluster. Existing metrics are matched by ID or by name and never duplicated; an archived metric is created again at the next start.

Billable metricGroup key
GPU Hours [vcluster_gpu_hours]region, sku, capacity_type, billing_mode, tenant_cluster
CPU Core Hours [vcluster_cpu_core_hours]region, capacity_type, billing_mode, tenant_cluster
Memory GiB Hours [vcluster_memory_gb_hours]region, capacity_type, billing_mode, tenant_cluster
Storage GiB Hours [vcluster_storage_gb_hours]region, sku, tenant_cluster
Tenant Cluster Hours [vcluster_instance_hours]region, tenant_class, tenant_cluster
Dedicated Node Hours [vcluster_private_node_hours]region, sku, capacity_type, tenant_cluster
Network Egress GiB [vcluster_network_egress_gb]region, tenant_cluster
Load Balancer Hours [vcluster_lb_hours]region, tenant_cluster

Custom metrics, from CUSTOM_METRICS or defined in PromQL, get a billable metric too, grouped on the keys you declare. The informational metrics vcluster_gpu_utilization and vcluster_gpu_downtime_hours are never sent to Metronome.

Contracts

When a tenant has a rate card, vBilling creates one contract per tenant and rate card. The rate card is the tenant's vbilling.vcluster.com/plan, or metronome.rateCard when the tenant has none. A UUID is used as the rate card ID, anything else as its alias.

With metronome.stripeLink=true, vBilling finds or creates the tenant's Stripe customer the same way the Stripe adapter does (by the metadata vbilling_tenant_id, or the customer pinned with vbilling.vcluster.com/stripe-customer-id). It then creates the Metronome customer with Stripe as its billing provider, delivery method direct_to_billing_provider and your collection method. Contracts carry the same billing provider. No Stripe meters or prices are created in this mode.

i
A customer that already exists in Metronome, found by its alias or pinned by ID, gets the same Stripe configuration if it has none, and is left as it is if it already has one.

Pricing on dimensions

Add a usage product per billable metric to your rate card. A product prices on a pricing group key and splits invoice lines with a presentation group key. Metronome requires the two together to form one compound group key of the billable metric, and vBilling defines exactly one, so split its keys between them:

# Usage product on "GPU Hours [vcluster_gpu_hours]" pricing_group_key: [region, sku, capacity_type, billing_mode] presentation_group_key: [tenant_cluster] # Usage product on "Dedicated Node Hours [vcluster_private_node_hours]" pricing_group_key: [region, sku, capacity_type] presentation_group_key: [tenant_cluster]

Then give each combination you sell its own rate:

Price whole nodes either by node-hours or by their GPU, CPU and memory hours, not both (see what gets billed). Group-key properties an event does not carry are sent as none, so every event still matches its metric. vBilling sends quantities only: the rate card sets prices and currency.

Delivery

vBilling posts events to /v1/ingest in batches of 100, one event per tenant, metric, window and set of dimensions. customer_id is the Metronome customer ID once vBilling knows it, and the ingest alias before that:

{ "transaction_id": "vb1_7c4e9a0d5b...", "customer_id": "vcluster-team-a-team-a", "event_type": "vcluster_gpu_hours", "timestamp": "2026-10-05T10:00:00Z", "properties": { "value": "0.133333333", "unit": "gpu-hours", "window_start": "2026-10-05T10:00:00Z", "window_end": "2026-10-05T10:01:00Z", "schema_version": "vbilling.usage/v1", "source": "collector", "region": "us-east-1", "sku": "NVIDIA-H100-80GB-HBM3", "resource_id": "gpu-node-1", "capacity_type": "on-demand", "billing_mode": "private_node", "gpu_type": "NVIDIA-H100-80GB-HBM3", "gpu_profile": "full", "tenant_cluster": "vcluster-team-a-team-a" } }
CaseWhat happens
Retries and replaysMetronome ignores a repeated transaction_id for 34 days, so nothing counts twice. vBilling sends no Idempotency-Key, because Metronome would cache an error response under it.
Events older than 34 daysDead-lettered as older than Metronome's 34-day ingest window (vBilling keeps an hour of margin).
HTTP 400, 413, 422The batch is split until the rejected events are isolated. Those are dead-lettered; the rest are delivered.
HTTP 401, 403, 404, 408, 409, 429, 5xx, network errorsRetried with backoff, never dead-lettered. Only Metronome waits: other destinations keep delivering from their own cursors.

Check delivery, list dead letters and replay them (add your API token as a bearer token if you set one):

kubectl -n vbilling-system port-forward svc/vbilling 8080 curl -s localhost:8080/api/v1/destinations | jq '.destinations[] | select(.name == "metronome")' curl -s localhost:8080/api/v1/destinations/metronome/dead-letters | jq curl -s -X POST localhost:8080/api/v1/destinations/metronome/dead-letters/replay

Adding Metronome to a running vBilling starts delivery at the next committed window. To send the retained ledger as well (persistence.retention, default 168h), rewind its cursor; transaction IDs keep the overlap from counting twice:

curl -s -X POST localhost:8080/api/v1/destinations/metronome/cursor -d '{"seq": 0}'

Reconciliation

GET /api/v1/reconcile?destination=metronome recomputes each tenant's usage from the ledger and compares it with what Metronome's usage API (/v1/usage) recorded. Metronome reports whole UTC days only, so from and to must be UTC midnights (YYYY-MM-DD works). Without them, the previous full UTC day is checked. Narrow it with tenant or metric. project, region, sku 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 Metronome reports totals per customer.

curl -s 'localhost:8080/api/v1/reconcile?destination=metronome&from=2026-10-04&to=2026-10-05' | jq { "from": "2026-10-04T00:00:00Z", "to": "2026-10-05T00:00:00Z", "results": [{ "destination": "metronome", "ok": true, "rows": [{ "tenant": "vcluster-team-a-team-a", "metric": "vcluster_gpu_hours", "ledger": 192, "dead_lettered": 0, "expected": 192, "recorded": 192, "difference": 0, "match": true }] }] }

expected is the ledger total minus dead letters, and a row matches within 0.1%. Reconcile once a day has closed and lag_records on /api/v1/destinations is back to 0, so no events are still in flight.

Webhooks and billing state

Create a webhook in Metronome pointing at https://<your vBilling host>/webhooks/metronome and add its secret to the Secret as webhook-secret. The Service is ClusterIP; expose only the /webhooks/ paths to the internet. Billing states are always available: Metronome's webhooks set them once webhook-secret is set.

kubectl -n vbilling-system create secret generic vbilling-metronome \ --from-file=api-token=./metronome-token --from-file=webhook-secret=./metronome-webhook-secret \ --dry-run=client -o yaml | kubectl apply -f - kubectl -n vbilling-system rollout restart statefulset/vbilling

vBilling verifies Metronome-Webhook-Signature: hex HMAC-SHA256 with the secret over X-Metronome-Date (or Date), a newline and the raw body, within 5 minutes of that date. A bad signature gets a 400. Verified events that carry no billing signal, belong to a customer that is not a vBilling tenant, or repeat an event already applied get a 200 with "applied": false; other failures return a 500 so Metronome retries.

Metronome eventBilling state
alerts.*, for example alerts.spend_threshold_reached or alerts.low_remaining_contract_credit_balance_reachedwarning, with the alert's name as the reason
payment_gate.payment_status with payment_status: faileddelinquent
payment_gate.payment_status with payment_status: paidactive

Map any event type to another state with enforcement.rules (ENFORCEMENT_RULES). A hard spend cap suspends the tenant when its spend alert fires:

# values.yaml enforcement: mode: enforce rules: "alerts.spend_threshold_reached=suspended" admissionPolicy: enabled: true
enforcement.modeEffect
observe (default)Records the state. It appears in GET /api/v1/billing-states, the metrics and the dashboard.
annotateAlso annotates the tenant cluster, labels its namespace vbilling.vcluster.com/billing-state and emits a Kubernetes event.
enforceAlso labels suspended namespaces vbilling.vcluster.com/suspended=true. With enforcement.admissionPolicy.enabled=true (Kubernetes 1.30+), a ValidatingAdmissionPolicy denies new pods in those namespaces of the control plane cluster. Running workloads keep running.

Metronome + Stripe: payment failures on invoices Stripe collects reach vBilling through Stripe's webhooks, not Metronome's, so point Stripe's webhooks at /webhooks/stripe as well and add webhook-secret to the vbilling-stripe Secret. vBilling resolves them through the linked Stripe customers. See Stripe webhooks. To override a state by hand, use PUT /api/v1/billing-states/<tenant> with {"state": "active", "reason": "..."}.

Troubleshooting

The pod exits at startup

initialize adapters: METRONOME_API_TOKEN is required when the metronome adapter is enabled means the token did not reach the pod. The chart mounts Secret keys as optional, so a wrong Secret name or key shows up here: the key must be api-token in the Secret named by metronome.existingSecret. METRONOME_STRIPE_LINK needs STRIPE_API_KEY to create Stripe customers is the same problem with the key api-key in stripe.existingSecret.

Delivery to Metronome is paused

[pipeline] metronome: bootstrap failed (delivery paused, will retry): list billable metrics: metronome GET /v1/billable-metrics: HTTP 401 means Metronome rejected the token. Usage keeps accumulating in the ledger and other destinations keep delivering. Fix the token, then restart vBilling, which reads Secrets at startup.

Tenants get no contract

Contract errors name the tenant and rate card: create contract for <tenant> on <rate card>. Check that the alias or ID exists in Metronome, and check the tenant's vbilling.vcluster.com/plan if it has one. When Metronome answers 400, the log reads [pipeline] metronome: tenant <tenant> rejected and usage keeps flowing to the customer without a contract; other errors pause delivery and retry.

Dead letters older than 34 days

older than Metronome's 34-day ingest window comes from a cursor rewind or an outage longer than Metronome accepts backdated events. Metronome cannot take them. They stay in the dead-letter list, and reconciliation reports them as dead_lettered.

Reconciliation fails

metronome reconciliation needs whole UTC days (got ... to ...) means from or to is not a UTC midnight. Pass dates as YYYY-MM-DD, or RFC 3339 times at 00:00:00Z.