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
  • Console: internal — operated by Vagary Labs; no customer console page for this capability.
  • Runbook: operational checklist for billing-metering — internal (Vagary Labs ops; not part of this public site): docs/runbooks/capability-operations.md#billing-metering
  • 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
PUT/internal/voice/allocations/{allocation_id}Authorize an immutable monthly internal Voice allocation
PUT/internal/voice/allocations/{allocation_id}/monthly-policyExplicitly authorize, pause or resume a fixed recurring monthly internal ceiling
GET/internal/voice/allocations/lookupResolve internal allocation classification from a verified organization
POST/internal/voice/allocations/{allocation_id}/reservationsReserve an internal demo session before provider initialization
GET/internal/voice/allocations/{allocation_id}/reservations/by-operationRecover existing internal reservation evidence by exact operation binding
GET/internal/voice/allocations/{allocation_id}/reservations/{reservation_id}Read an attributed internal session reservation
POST/internal/voice/allocations/{allocation_id}/reservations/{reservation_id}/attemptsAdmit exactly one bounded provider attempt before external I/O
POST/internal/voice/allocations/{allocation_id}/reservations/{reservation_id}/usageRecord measured usage for an admitted internal session
GET/v1/voice/internal-profilesList versioned internal execution profiles
POST/v1/voice/internal-profilesRegister an immutable internal execution profile version
POST/v1/voice/internal-allocationsAuthorize a registry internal allocation for the current month
GET/v1/voice/internal-allocations/{allocation_id}Read a registry allocation with its effective caps and the figures reserve enforces
PATCH/v1/voice/internal-allocations/{allocation_id}Append a cap revision to a registry allocation
POST/internal/voice/allocations/{allocation_id}/reservations/{reservation_id}/commitRetain the internal allocation debit after completion or uncertain I/O
POST/internal/voice/allocations/{allocation_id}/reservations/{reservation_id}/releaseRelease a hold only when no provider attempt was admitted
GET/v2/voice/statusDistinguish never-funded from active or closed paid history
POST/v2/voice/free/quota/reservationsSame legacy Free arithmetic with mandatory paid-schema and same-lock funding fence
GET/v2/voice/readinessRevalidate original receipt without reserving again
GET/v2/voice/execution-decisionResolve internal execution rights from receipt and frozen supported profile
POST/v2/voice/usage/attemptsRetain authenticated pending work before provider execution
POST/v2/voice/usage/attempts/{event_id}/completeRetain measured or unknown completion on original attempt
POST/internal/refunds/reconcile-entitlementsRetry the access corrections that did not land after a succeeded refund
POST/internal/agreements/reconcileCreate the commercial agreement for every settled charge that lacks one
POST/internal/voice/settlements/confirmAtomically record verified external receipt, exact Voice period and evidence outbox
GET/v2/voice/entitlementResolve the active paid Voice period from Billing's settled provenance
POST/v2/voice/quota/reservationsReserve paid minutes without accepting a caller limit
POST/v2/voice/quota/reservations/{reservation_id}/finalizeFinalize actual usage on its original paid receipt and counter
GET/v2/voice/coverage-contextResolve immutable coverage through an explicit tenant/session/reservation association
POST/v1/usageIngest a usage event (from provider-gateway / stt / tts / …) — queued, never blocks caller
GET/v1/usage/summaryaggregated usage per (org, product, period)
POST/v1/usage/erase-subjectGDPR Art-17 subject erasure by PSEUDONYMISATION (first-party admin only)
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/entitlement | What did this organization BUY? (ADR-120) The shared entitlement read: core is the single commercial settlement authority, so org_plans is where a purchase lands for every product, and this is the one route that answers what landed. Returns the PLAN and never a limit — I5 leaves the tier->limit policy with the product, which resolves a limit and passes it to /v1/quota/consume for atomic enforcement. A quotas field here would move that policy into core and reverse a ratified decision. TENANT-SCOPED: organization_id is resolved against the verified credential, not trusted verbatim. An org with no row reads as the default plan, because no purchase IS an entitlement — the free one — so callers get one shape always. | | POST | /v1/entitlement | Assign an org's plan directly — the WRITE half GET /v1/entitlement never had. set_plan (the only write to org_plans) previously had exactly two callers, both settlement-triggered (a charge SETTLING, or a full refund releasing it); there was no route for a trial grant, a negotiated enterprise contract, or an admin correction to land through. ADMIN-ONLY: requires a first-party SERVICE_TOKEN credential — a verified end-customer JWT is rejected even for its own org, because this route can grant a plan for free and "you are org X" must not mean "you may be org X's admin". | | 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/quota/reservations | Reserve Voice allowance with a durable quota-only receipt | | POST | /v1/quota/reservations/{reservation_id}/finalize | Replace a Voice reservation with actual completed units in its original period | | 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 | /v1/orgs/{org_id}/margin | Margin: revenue vs COGS per (product, capability, model, provider) for an org's current period. Revenue is re-rated from the CURRENT rate card (not stored), COGS = cost_cents (billable provider cost) + internal_cost_cents (fleet-key, non-billable). Emits a best-effort negative_margin notification (fire-and-forget POST to notifications; failure never blocks the response) when any line's margin is below zero. | | PUT | /v1/spend-domains/{spend_domain_id}/policy | Set or update the budget/quota/alert policy for a spend domain (upsert; returns the policy as stored). ADMIN-ONLY: spend_domain_policy is keyed by spend_domain_id alone (no organization_id column), so there is no stored owner to check a caller against — this fails CLOSED to a first-party SERVICE_TOKEN credential rather than trusting the id off the path (OW-1020). A verified end-customer JWT is rejected even for its own org. | | GET | /v1/spend-domains/{spend_domain_id}/policy | Read the budget/quota/alert policy for a spend domain. ADMIN-ONLY, same gate as PUT (OW-1020). | | DELETE | /v1/spend-domains/{spend_domain_id}/policy | Delete the policy for a spend domain. ADMIN-ONLY, same gate as PUT (OW-1020). | | GET | /v1/spend-domains/{spend_domain_id}/spend | Sum period-to-date cost_cents for a spend domain (the current calendar-month period; not caller-selectable). Optional ?project_id= scopes to one project's spend within the domain. ADMIN-ONLY, same gate as the policy routes (OW-1020) — the table has no organization_id to authorize a tenant caller against. | | GET | /v1/orgs/{org_id}/spend | Sum period-to-date cost_cents for an entire org (the current calendar-month period). | | POST | /v1/contracts | Create or update the (single, active) enterprise contract for an org — negotiated quota overrides, overage behavior (hard_stop | soft_overage | grace_buffer) and commit terms. An org that already has a contract is updated in place (only the fields present in the body change); the first call for an org creates one. GATED OFF in this deployment (see the block comment above): _ENTERPRISE_STORES_ENABLED is hardcoded False, so this answers 503 on every authenticated call today; the 422/200 responses below are the real handler behavior once a durable backend exists and the gate is lifted. | | GET | /v1/contracts/{org_id} | Get the active enterprise contract for an org. GATED OFF in this deployment — see the block comment above /v1/contracts; this answers 503 on every authenticated call today. | | GET | /v1/contracts/{org_id}/quota-check | Contract-aware quota check for an org/metric: resolves the effective limit (contract override, or the plan-tier default) and, if over, the overage behavior's consequence (hard-stop denial / allow-with-grace / allow-with-overage-billing). GATED OFF in this deployment — see the block comment above /v1/contracts; this answers 503 on every authenticated call today. | | POST | /v1/invoices/persist | Persist rated invoice line items for an org/period (called at period-close). Replaces any existing lines for the same org/period — idempotent re-rate, not an append. GATED OFF in this deployment: this operation shares CreditStore's gate (_ENTERPRISE_STORES_ENABLED, hardcoded False — see the block comment above /v1/contracts), so it answers 503 with credit_store_unconfigured on every authenticated call today. | | GET | /v1/invoices/{org_id}/{period} | The customer-visible invoice for an org/period, INCLUDING usage credits netted against the persisted lines. GATED OFF in this deployment — same gate as POST /v1/invoices/persist; answers 503 with credit_store_unconfigured on every authenticated call today. | | POST | /v1/credits | Issue a usage credit against a specific (org, period, product, metric) invoice line — a support/ops dispute adjustment. Rejects (400) if the named line has no matching persisted invoice line, if credit_cents is not positive, or if reason/issued_by are blank (both required, logged for audit). GATED OFF in this deployment: same gate as invoices; answers 503 with credit_store_unconfigured on every authenticated call today. | | GET | /v1/credits/{org_id}/{period} | List all usage credits issued for an org/period. GATED OFF in this deployment — same gate as POST /v1/credits; answers 503 with credit_store_unconfigured on every authenticated call today. | | POST | /v1/reconciliation/run | Run vendor-invoice reconciliation for a period (ADR-130): parses vendor usage exports (Anthropic CSV / OpenAI usage JSON / Google billing CSV) per provider, compares them against this service's own metered summaries for the same period, and reports per-model variance plus alerts for any model exceeding the variance threshold. Accepts either a multi-provider vendor_csvs map or a single provider+csv pair (folded into the map if both are given). ADMIN-ONLY (first-party SERVICE_TOKEN caller; a verified end-customer JWT is rejected) — this aggregates metered cost across EVERY tenant for the period, so there is no single tenant to scope a customer credential to. See the block comment above /v1/contracts for a verified, currently unfixed defect in this admin-only call shape: no request can actually supply the organization_id this route's own auth chain requires, so this operation 422s for every caller today whenever BILLING_METERING_DSN IS configured (and 503s when it is not) — the 200 below is real logic, proven correct at the unit level against a live period's summaries, but not reachable over HTTP as currently wired. | | GET | /v1/reconciliation/risk-bucket | The exact=False (estimated) reconciliation risk for a period, by EVENT count — the share of metered events whose cost was a documented estimate rather than a provider-billed measurement. A narrower, standalone sibling of the risk_bucket embedded in POST /v1/reconciliation/run's response (see that operation's schema note for the difference). ADMIN-ONLY, same gate and same verified always-422 limitation as POST /v1/reconciliation/run — see that operation's summary and the block comment above /v1/contracts. | | POST | /v1/trials/start | Start a trial for an org (default plan "pro", default 14 days, per-metric trial caps default to DEFAULT_TRIAL_CAPS unless spend_cap_cents overrides them). Idempotent: an org with an already- ACTIVE trial gets that same trial back rather than a second one. Now answers a REAL 201 with the TrialSummary object — the Flask tuple-return bug (return <dict>, 201, which FastAPI serialized as a 200 whose body was the 2-element array [<object>, 201]) is fixed; verified in test/enterprise_routes_unit.py. GATED OFF in this deployment — see the block comment above /v1/contracts; answers 503 with trial_store_unconfigured on every authenticated call today. | | POST | /v1/trials/convert | Convert a trial to a paid plan and record the conversion event. source must be one of self_serve | sales_assisted | auto_convert (default self_serve). Now answers a REAL 201 with the conversion event object — same tuple-return fix as POST /v1/trials/start, verified in test/enterprise_routes_unit.py. A ValueError for "no trial found" is now mapped to 404 rather than an unhandled exception. GATED OFF in this deployment — see the block comment above /v1/contracts; answers 503 with trial_store_unconfigured on every authenticated call today. | | GET | /v1/trials/{org} | Get the current trial status for an org. Now calls the real format_trial_summary function (the code previously called a TrialStore.format_trial method that does not exist). GATED OFF in this deployment — see the block comment above /v1/contracts; answers 503 with trial_store_unconfigured on every authenticated call today. | | GET | /v1/trials/{org}/check | Intended to check whether an org's trial usage is within its trial cap. GENUINELY UNDETERMINABLE, reported rather than guessed: TrialStore has no check_quota method (the original call site's target); the closest match, check_trial_cap(org, metric, current_usage), needs a metric this route's own contract has never accepted, and trial_cap can hold several independently-capped metrics (see TrialStore.start_trial / DEFAULT_TRIAL_CAPS) — there is no single correct metric to default to, and inventing one would be a guess about money-adjacent state. This operation therefore answers a stable 501, not a wrong number and not a 500. Closing it for real needs either a metric (+ current_usage) query parameter added to this route's contract, or a different, all-metrics response shape this contract does not currently define — both are caller-facing decisions outside this pass's scope. GATED OFF in this deployment on top of that — see the block comment above /v1/contracts: _ENTERPRISE_STORES_ENABLED is checked BEFORE the 501 below, so this answers 503 with trial_store_unconfigured on every authenticated call today; the 501 is real (reachable with the store gate flipped, per test/enterprise_routes_unit.py) but currently masked by the 503. | | GET | /health | liveness | | GET | /metrics | Prometheus |

Schemas​

VoiceInternalIdentifier​

VoiceInternalProfileHash​

SHA-256 of the enabled server-owned immutable profile, never selected by an anonymous caller.

VoiceInternalAllocationRequest​

FieldTypeDescription
organization_idobject
project_idobject
environment_idobject
profile_hashobject
periodstringCurrent UTC month; renewal must be explicitly authorized.
currencystring
cap_centsintegerInternal allocation ceiling; not customer money or measured provider cost.
authorized_byobject
authorization_refobject

VoiceInternalMonthlyPolicyRequest​

FieldTypeDescription
organization_idobject
project_idobject
environment_idobject
profile_hashobject
currencystring
cap_centsinteger
enabledbooleanFalse pauses future allocation and provider admission without deleting evidence.
expected_revisionintegerZero for initial authorization; current policy revision for a new change.
authorized_byobject
authorization_refobject

VoiceInternalReservationRequest​

FieldTypeDescription
organization_idobject
project_idobject
environment_idobject
profile_hashobject
operation_idobject
purposestringDefaults to visitor. Must match the allocation kind (acquisition=visitor, qualification=verification).
correlation_idobject
publication_idobject
generationinteger

VoiceInternalUsageRequest​

FieldTypeDescription
organization_idobject
project_idobject
environment_idobject
profile_hashobject
operation_idobject
usage_idobject
capabilitystring
unitsobjectMeasured units named as in the profile pricing rates, e.g. audio_ms, input_tokens, characters, media_ms, calls.
correlation_idobject

VoiceInternalUsageResult​

FieldTypeDescription
wire_versionstring
usage_idstring
allocation_idstring
reservation_idstring
operation_idstring
organization_idstring
profile_hashobject
capabilitystring
unitsobject
cost_microsintegerRegistry list-price rating in USD micros; not a vendor invoice.
correlation_id['string', 'null']
settled_cents['integer', 'null']
cost_basisstring
billableboolean
customer_invoice_excludedboolean

VoiceInternalProfileRequest​

FieldTypeDescription
profile_idstring
profile_versioninteger
profileobjectHashed execution envelope: version, max_execution_ms and per-capability model, max_attempts, limits and totals.
limitsobject
pricingobject
created_byobject

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. The Edge route table section is synced from the live edge catalog snapshot (v1) — re-sync with python3 scripts/sync-edge-catalog.py.