Skip to main content

Billing & metering

Usage metering + multi-rail billing as a shared capability — one account buys across products. METERING: ingests per-request usage (the provider-gateway usage feed C4 + stt/tts/etc), aggregates per (org, product, period), enforces plan quotas. BILLING: multi-rail (Dodo/PayPal/crypto/Razorpay; Stripe DORMANT), subscriptions, invoices, dunning. Metering NEVER blocks a product runtime (queue + reconcile). Payment secrets resolved server-side (Infisical), never in body/DB/logs. SELLER OF RECORD differs by rail (ADR-110 D3): dodo is a Merchant of Record — it is the legal seller and the registered taxpayer, so /v1/tax/quote SUPPRESSES our tax computation for it (quoting it would double-tax the customer). Every other rail is tax-exclusive: we are the seller and our tax is added. stripe is retained but DORMANT — it cannot settle for an India LLP (no PA-CB licence; invite-only) and refuses every charge/refund/capture with 422 regardless of credentials (ADR-110 D4).

  • Group: Sellable plane
  • Contract: contracts/billing-metering/v1/openapi.yaml
  • Public access: none — internal-only capability. It is NOT exposed on the public API gateway (https://api.vagarylabs.com); it is reachable only inside the fleet (container/tailnet) by first-party callers. There is no customer-facing endpoint to call.
  • Auth (internal): bearerToken first-party bearer

Endpoints

MethodPathSummary
POST/v1/usageIngest a usage event (from provider-gateway / stt / tts / …) — queued, never blocks caller
GET/v1/usage/summaryaggregated usage per (org, product, period)
GET/v1/usage/quotasC5 Wave-0 — batch quota-counter USED per metric for (org, period) in one call (the read a product dashboard consumes, e.g. voice getUsageQuotas). Returns USED only; the caller overlays its OWN tier->limit (I5). Core never dictates a limit here (org_plans defaults 'free').
POST/v1/rails/selectResolve which rail a customer rides and WHO THE SELLER OF RECORD IS, without charging anything (ADR-110 D3 + its E14 addendum). India routes to razorpay with Vagary Labs LLP as seller; rest of world routes to the dodo Merchant of Record, which becomes the legal seller, issues its own invoice under its own particulars, and collects and remits the customer's VAT/GST — so our tax computation is SUPPRESSED for it (tax_mode inclusive).

A product calls this BEFORE sending anyone to checkout, because the answer changes what the checkout page must say: ADR-110's Consequences make that disclosure load-bearing ("Shipping the rail before the pages is publishing an incorrect statement of who the counterparty is"). Stateless, no rail call, no side effects. customer_country is optional — absent or unrecognised resolves to the MoR, the fail-safe direction. unattended_renewal_supported is FALSE on both rails: see RailSelection. | | POST | /v1/charge | Execute a charge on a rail (dodo/paypal/crypto/razorpay; stripe is DORMANT) — the capability PRIMITIVE. One-off or recurring (billing-metering runs dunning internally). Products build their own subscription/plan UX (product-face, I5) on top of this primitive; the capability never exposes SaaS packaging. dodo is the Merchant-of-Record rail: it creates a hosted checkout session and returns requires_action + a checkout link, and its verified webhook settles it. stripe returns 422 status: dormant for EVERY charge regardless of credentials (ADR-110 D4) — it is refusing on a legal constraint, not a missing key, and no dunning record is opened for it.

SEGMENT ROUTING (ADR-110 D3 + its E14 addendum). rail is OPTIONAL: send customer_country instead and the capability selects the rail — India to razorpay with Vagary Labs LLP as seller of record, rest of world to the dodo Merchant of Record. Rail choice is a TAX decision, so it is owned here rather than by product code (payment-rail-build-vs-buy.md §3 rules 4-5). When the capability selected the rail, the response carries a rail_selection block (schema RailSelection) naming the rail, the seller of record and the tax mode; on an explicit-rail charge that block is ABSENT and the response is unchanged. An EXPLICIT rail always wins and is NOT overridden by the country — D3's B2B/enterprise segment routes direct by human decision, not by the customer's country. Sending neither is still a 422; select_rail: true is the explicit opt-in for "apply the policy, country unknown", which resolves to the MoR. | | POST | /v1/charges/{rail}/{provider_charge_id}/capture | Capture a TWO-PHASE charge — complete an order/authorization that /v1/charge created and returned as requires_action (e.g. a PayPal order with intent=CAPTURE). The rail captures the funds server-side (secrets resolved from env, never in a body); when the settlement store holds the charge its state flips pending → succeeded (idempotent — reuses the verified-webhook mark path). A rail with no separate capture step (crypto settles via its inbound webhook) returns unsupported (422). organization_id is optional (I6 ownership when provided). Additive S2 lifecycle-convergence endpoint. | | GET | /v1/charges/{rail}/{provider_charge_id} | Read a charge's CURRENT settlement state by its rail provider id (the id /v1/charge recorded). This is the webhook-push model: it returns the settlement's cached pending/succeeded/failed state — it does NOT re-query the provider. A charge stays pending until its verified webhook (or a /capture) moves it. Serves the platform's PayPal get_order / crypto get_payment + check_payment lifecycle reads. | | GET | /v1/quota/check | is (org, metric) within quota? (read-only, NON-enforcing display). TENANT-SCOPED: organization_id is resolved against the verified credential, not trusted verbatim — a JWT caller asking for another org's counters is 403. | | POST | /v1/quota/consume | ATOMIC check-and-consume — the ENFORCING quota gate (body {organization_id, metric, amount?=1, product?, idempotency_key?, limit?}); use this, not /check, to gate a request. limit (optional, I5) — a caller that owns its own tier->limit policy (e.g. vagary-voice) passes the resolved per-tier limit and core enforces THAT atomically (negative=unlimited); absent -> the org plan quota. | | POST | /v1/webhooks/{rail} | Inbound payment-rail settlement webhook (dodo/stripe/paypal/crypto/razorpay). A SEPARATE ingress from the product-face: authed by the PROVIDER's signature over the RAW body (Stripe-Signature / X-CC-Webhook-Signature / X-Razorpay-Signature / PayPal transmission headers / Dodo's Standard-Webhooks webhook-id + webhook-timestamp + webhook-signature triple), NOT an api-key/bearer. The signature is VERIFIED per rail BEFORE the payload is trusted — a forged/unsigned POST is rejected (400) and can never flip a charge to paid. On a verified settlement event the charge's settlement state flips (pending → succeeded/failed); a failed recurring charge fires dunning. Idempotent: a redelivered event for an already-terminal charge is a 200 no-op. | | GET | /v1/settlements/{settlement_id} | Read a settlement record (its current pending/succeeded/failed state). TENANT-SCOPED: the record is looked up under the resolved organization, so a settlement owned by another tenant is a 404. | | GET | /v1/settlements | List an org's settlement records | | POST | /v1/refunds | Reverse a SETTLED charge (full or partial), per-rail, idempotent. Reverses off the settlement recorded at charge-time: the settlement transitions succeeded → partially_refunded → refunded and accumulates refunded_amount_cents. Scoped to the caller's organization_id (I6 ownership — one org can never reverse another's charge). Omit amount_cents for a FULL refund of the remaining balance. idempotency_key makes a redelivered refund a no-op (the same key never double-reverses). Rail secrets resolved server-side. Rails whose refund API needs an id v1 doesn't persist (paypal capture id / razorpay payment id) return a deferred rail_status (422) rather than a wrong-id call; crypto on-chain charges are unsupported. | | GET | /v1/refunds | List an org's refund records | | GET | /v1/refunds/{refund_id} | Read a refund record (its rail outcome + amount reversed). TENANT-SCOPED: a refund owned by another tenant is a 404. | | POST | /v1/tax/quote | Compute multi-jurisdiction tax for an amount (a stateless quote). A product quotes tax then charges the total via /v1/charge — tax is NOT folded into the charge primitive (charge stays byte-for-byte). No DB, no secrets: pure computation over the fleet's jurisdiction rate tables (US state sales tax incl. digital exemptions + country VAT/GST/consumption). Unsupported jurisdiction / product_type / rail → 422. RAIL-AWARE (ADR-110 D3): pass the OPTIONAL rail the charge will ride. On a Merchant-of-Record rail (dodo) the MoR is the legal seller and the registered taxpayer — it collects and remits the customer's tax itself — so OUR computation is suppressed: tax_cents is 0, total_cents equals the subtotal, and the response carries tax_collected_by: merchant_of_record plus the jurisdiction_tax_rate that WOULD have applied, so a suppressed quote is never mistaken for a 0% jurisdiction. Quoting our tax on an MoR charge would DOUBLE-TAX the customer. Omit rail for the unchanged tax-exclusive behaviour (we are the seller of record). | | GET | /v1/tax/jurisdictions | The jurisdictions (countries + US states) this capability can quote | | POST | /v1/tax/validate-id | Validate the FORMAT of a customer tax ID (EU VAT / US EIN / AU ABN / IN GSTIN). Format-only — no registry lookup, no DB, no secrets. Never 422s on a bad id: returns {valid:false, format:unknown, ...} so the caller decides. Missing tax_id/country → 422. | | GET | /v1/dunning/{record_id} | Read a dunning record (failed-payment retry schedule + state). TENANT-SCOPED: a record owned by another tenant is a 404. | | GET | /v1/dunning | List an org's dunning records | | POST | /v1/dunning/execute | Fire due dunning retries against the rail (ops-triggered internal executor). Body {"force": true} fires scheduled retries regardless of due_at (the normal schedule is exponential-backoff hours out). | | GET | /v1/rate-card | What we charge per unit — the customer-facing rate card (no org scope; identical for everyone) | | GET | /v1/plans | The purchasable plans and their quota envelopes | | GET | /v1/invoice | The org's metered usage for a period, rated into customer-visible invoice lines | | POST | /v1/cost/estimate | Cost BEFORE spend — quote what {metric, quantity} would cost this org, before it is incurred | | POST | /v1/organizations/{organization_id}/close | Stop billing for an org — the billing leg of the cross-plane org-delete cascade. IDEMPOTENT: success is asserted on live_rows_remaining == 0 and active_dunning_remaining == 0, never on released counts. | | GET | /health | liveness | | GET | /metrics | Prometheus |

Schemas

UsageEvent

FieldTypeDescription
organization_idstring
productstring
metricstringe.g. llm_tokens, stt_seconds, tts_characters, tts_audio_seconds. tts_characters counts synthesis INPUT; tts_audio_seconds counts audio the voice gateway actually PLAYED to the caller, which is what the customer consumed. They are not interchangeable — the same character count yields different durations across languages, voices and speaking rates. Emit whichever the product meters; do not derive one from the other.
quantitynumber
cost_centsnumber
provider_usedstring
idempotency_keystring

RailSelectRequest

FieldTypeDescription
customer_countrystringISO 3166-1 alpha-2 of the CUSTOMER. Optional: absent or unrecognised resolves to the Merchant-of-Record rail.

RailSelection

A routing decision. Also returned inline on /v1/charge (as rail_selection) whenever the capability chose the rail, so the seller of record a customer actually got is visible and loggable without re-deriving the policy.

FieldTypeDescription
customer_countrystringthe normalised alpha-2 that was used, or null when unknown/unusable
segmentstring
railstringnever stripe — it is dormant for this entity (ADR-110 D4) and selection must never name it
seller_of_recordstringwho the customer legally contracts with and who owes their tax. merchant_of_record matches the tax_collected_by a suppressed /v1/tax/quote emits, so the two surfaces share one vocabulary.
tax_modestringinclusive = the MoR is the registered taxpayer, so OUR computation is suppressed (adding it would double-tax the customer). Pass this straight to /v1/tax/quote.
unattended_renewal_supportedbooleanFALSE on both live rails. Stripe's subscription object auto-charged a stored card with no customer present; neither live rail can as wired — each implements the charge primitive only and returns requires_action + a hosted URL, and we hold no card-on-file by design (no PCI scope; payment-rail-build-vs-buy.md §2 lists it as a GAP). Every renewal period therefore needs a fresh customer-completed checkout. Declared here rather than left to be discovered when a renewal silently fails to collect.
renewal_modestringhow a renewal is collected given the above
reasonstringwhy this rail, in prose, citing the decision — for logs and operator review

ChargeRequest

Supply EITHER rail (explicit — the caller has already decided, e.g. D3's B2B/enterprise segment) OR customer_country (segment routing — the capability decides per ADR-110 D3). select_rail: true applies the policy when the country is genuinely unknown. Sending none of the three is a 422.

FieldTypeDescription
organization_idstring
railstringOPTIONAL when customer_country (or select_rail) is sent. An explicit rail is honoured as given and is NOT validated against the country.
customer_countrystringISO 3166-1 alpha-2 of the CUSTOMER. IN routes to razorpay (we are the seller of record; India-format FIRC is issued); anything else routes to the dodo Merchant of Record. An unrecognised value is treated as unknown and routes to the MoR — the fail-safe direction, since the MoR is the registered taxpayer and establishes the jurisdiction itself.
select_railbooleanApply ADR-110 D3 routing with NO country known (resolves to the Merchant-of-Record rail). Exists because JSON cannot distinguish an absent field from a null one, so "route me anyway" needs its own flag rather than being inferred from a missing rail. Absent = false.
amount_centsinteger
recurringbooleancapability runs dunning internally if true
plan_refstringopaque plan id the product defines (not a SaaS plan surface)
idempotency_keystring

WebhookAck

FieldTypeDescription
receivedboolean
settlementobject
notestring

Settlement

FieldTypeDescription
idstring
organization_idstring
railstring
provider_charge_idstringthe rail's charge/order id — the webhook join key
amount_centsinteger
currencystring
statusstringpending until the provider webhook confirms; a settled charge may then be reversed (partially_refunded/refunded)
recurringboolean
dunning_idstring
refunded_amount_centsintegerrunning total reversed off this charge (0 = none)

RefundRequest

FieldTypeDescription
settlement_idstringthe settled charge to reverse
organization_idstringI6 ownership — must own the settlement
amount_centsintegeromit for a FULL refund of the remaining balance; a positive integer for a partial
reasonstring
idempotency_keystringa redelivered refund with the same key never double-reverses

Refund

FieldTypeDescription
idstring
settlement_idstring
organization_idstring
railstring
provider_charge_idstring
provider_refund_idstring
amount_centsinteger
currencystring
reasonstring
statusstringthe reversal's own lifecycle

Generated by scripts/gen-capability-docs.py from contracts/billing-metering/v1/openapi.yaml — the contract IS the source of truth; edit the contract, not this page.