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
| Method | Path | Summary |
|---|
POST | /v1/usage | Ingest a usage event (from provider-gateway / stt / tts / …) — queued, never blocks caller |
GET | /v1/usage/summary | aggregated usage per (org, product, period) |
GET | /v1/usage/quotas | C5 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/select | Resolve 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
| Field | Type | Description |
|---|
organization_id | string | |
product | string | |
metric | string | e.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. |
quantity | number | |
cost_cents | number | |
provider_used | string | |
idempotency_key | string | |
RailSelectRequest
| Field | Type | Description |
|---|
customer_country | string | ISO 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.
| Field | Type | Description |
|---|
customer_country | string | the normalised alpha-2 that was used, or null when unknown/unusable |
segment | string | |
rail | string | never stripe — it is dormant for this entity (ADR-110 D4) and selection must never name it |
seller_of_record | string | who 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_mode | string | inclusive = 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_supported | boolean | FALSE 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_mode | string | how a renewal is collected given the above |
reason | string | why 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.
| Field | Type | Description |
|---|
organization_id | string | |
rail | string | OPTIONAL when customer_country (or select_rail) is sent. An explicit rail is honoured as given and is NOT validated against the country. |
customer_country | string | ISO 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_rail | boolean | Apply 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_cents | integer | |
recurring | boolean | capability runs dunning internally if true |
plan_ref | string | opaque plan id the product defines (not a SaaS plan surface) |
idempotency_key | string | |
WebhookAck
| Field | Type | Description |
|---|
received | boolean | |
settlement | object | |
note | string | |
Settlement
| Field | Type | Description |
|---|
id | string | |
organization_id | string | |
rail | string | |
provider_charge_id | string | the rail's charge/order id — the webhook join key |
amount_cents | integer | |
currency | string | |
status | string | pending until the provider webhook confirms; a settled charge may then be reversed (partially_refunded/refunded) |
recurring | boolean | |
dunning_id | string | |
refunded_amount_cents | integer | running total reversed off this charge (0 = none) |
RefundRequest
| Field | Type | Description |
|---|
settlement_id | string | the settled charge to reverse |
organization_id | string | I6 ownership — must own the settlement |
amount_cents | integer | omit for a FULL refund of the remaining balance; a positive integer for a partial |
reason | string | |
idempotency_key | string | a redelivered refund with the same key never double-reverses |
Refund
| Field | Type | Description |
|---|
id | string | |
settlement_id | string | |
organization_id | string | |
rail | string | |
provider_charge_id | string | |
provider_refund_id | string | |
amount_cents | integer | |
currency | string | |
reason | string | |
status | string | the 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.