openapi: 3.0.3 info: title: Velox Billing API description: | Open-source usage-based billing engine. Handles pricing, subscriptions, usage metering, invoice generation, Stripe payments, dunning, and credits. version: "2026-04-07" license: name: MIT url: https://github.com/getvelox/velox/blob/main/LICENSE contact: name: Velox url: https://github.com/getvelox/velox servers: - url: http://localhost:8080 description: Local development security: - BearerAuth: [] components: securitySchemes: BearerAuth: type: http scheme: bearer description: | API key authentication. Use `Authorization: Bearer vlx_secret_...` Key types: vlx_platform_ (tenant mgmt), vlx_secret_ (full access), vlx_pub_ (restricted) schemas: Error: type: object properties: error: type: object properties: type: type: string enum: [invalid_request_error, authentication_error, rate_limit_error, api_error] code: type: string message: type: string request_id: type: string MemberInvitation: type: object required: [id, email, role, status, expires_at, created_at] properties: id: type: string example: vlx_minv_1a2b3c4d5e6f7890 email: type: string role: type: string description: Recorded for the future role split; not enforced (every member holds the full permission set). status: type: string enum: [pending, accepted, revoked, expired] description: Server-derived; treat as authoritative. invited_by_email: type: string expires_at: type: string format: date-time created_at: type: string format: date-time accepted_at: type: string format: date-time revoked_at: type: string format: date-time Customer: type: object properties: id: type: string example: vlx_cus_42014438d82ddf33 external_id: type: string display_name: type: string email: type: string additional_emails: type: array maxItems: 10 items: type: string description: | CC'd on billing documents and billing-state emails (invoice, receipt, dunning, payment failed, credit note) — never on credential-bearing emails (setup links, resets, invites). Normalized to lowercase bare addresses, deduped, capped at 10, never equal to the primary email. Stored encrypted at rest. status: type: string enum: [active, archived] created_at: type: string format: date-time test_clock_id: type: string description: | Test clock this customer is pinned to (test mode only). When set, the customer — and any invoices/subscriptions created for it — run on the clock's simulated time. Empty for live customers. The dashboard reads it to show the "test clock" badge + simulated-time banner on the customer and its invoices. Meter: type: object properties: id: type: string key: type: string name: type: string unit: type: string aggregation: type: string enum: [sum, count, max, last] rating_rule_version_id: type: string Plan: type: object properties: id: type: string code: type: string name: type: string currency: type: string billing_interval: type: string enum: [monthly, yearly] status: type: string enum: [draft, active, archived] base_amount_cents: type: integer format: int64 base_bill_timing: type: string enum: [in_advance, in_arrears] description: | When the recurring base fee is invoiced relative to the period it covers. in_arrears (default): base + usage billed at period end. in_advance: base billed at period start (day 1 on create; cycle-close invoice carries upcoming period's base + elapsed period's usage). Usage lines are always arrears regardless of this flag. See ADR-031. meter_ids: type: array items: type: string RatingRule: type: object properties: id: type: string rule_key: type: string name: type: string version: type: integer mode: type: string enum: [flat, graduated, package] currency: type: string flat_amount_cents: type: string description: >- Per-unit rate in cents, as a decimal string (e.g. "0.0003" = $3.00 per 1,000,000 units). Arbitrary precision; invoice line amounts and totals still round to whole cents. graduated_tiers: type: array items: type: object properties: up_to: type: integer unit_amount_cents: type: string description: Per-unit rate in cents, as a decimal string (see flat_amount_cents). Subscription: type: object properties: id: type: string code: type: string display_name: type: string customer_id: type: string plan_id: type: string status: type: string enum: [draft, trialing, active, canceled, archived] billing_time: type: string enum: [calendar, anniversary] trial_start_at: type: string format: date-time trial_end_at: type: string format: date-time UsageEvent: type: object properties: id: type: string customer_id: type: string meter_id: type: string quantity: type: integer format: int64 properties: type: object idempotency_key: type: string timestamp: type: string format: date-time InvoiceStatus: type: string enum: [draft, finalized, paid, voided, uncollectible] description: | Lifecycle state of the invoice. `uncollectible` marks an invoice a dunning policy has given up collecting (no further retries) while keeping it on the books — distinct from `voided` (cancelled, owes nothing). InvoicePaymentStatus: type: string enum: [pending, processing, succeeded, failed, unknown] description: | Payment-attempt status. `unknown` marks a Stripe charge attempt that returned an ambiguous error (5xx, timeout, connection reset) where we cannot tell whether Stripe processed the charge — a reconciler resolves these by querying Stripe after a cool-off window. InvoiceTaxStatus: type: string enum: [ok, pending, failed] description: | Whether tax has been successfully calculated. Happy path is `ok`. `pending` means a transient calculation failure is awaiting retry; `failed` means retries are exhausted (operator resolves manually). Pending/failed invoices are blocked from finalize so we never send a wrong tax amount. AttentionSeverity: type: string enum: [info, warning, critical] description: | Urgency of an Attention surface. Operators sort/filter on this and the dashboard renders one colour per level. Priority descending: Critical > Warning > Info. AttentionReason: type: string enum: - tax_calculation_failed - tax_location_required - payment_failed - payment_unconfirmed - payment_anomaly - payment_processing - payment_scheduled - awaiting_payment - no_payment_method - dunning_exhausted - collection_paused - commit_exposure description: | Closed, typed code naming why an invoice needs operator attention. Stable public-API contract — codes are never repurposed; deprecations keep the code reserved and add a successor. See `Attention.code` for the open, provider-specific sub-code. `payment_anomaly` (ADR-068) reports money that does not reconcile — double charge, captured-vs-booked amount mismatch, or a payment on a voided invoice — and is surfaced even on paid/voided invoices. `dunning_exhausted` reports a no-payment-method invoice whose automatic recovery has ended without collecting (2026-07-22). `collection_paused` reports an invoice that is queued for auto-charge but whose subscription has `pause_collection` set, so the charge sweeps deliberately skip it — it will not be charged automatically until collection resumes (2026-07-28). `commit_exposure` reports an unpaid invoice that funded a prepaid commit whose credit is already spendable (ADR-078 funds commits at issue, not at payment) — note this reason is only returned when no other reason applies; when one does, the exposure is folded into that reason's `message` and its `actions` instead, so clients must not treat the reason code as the presence test for exposure (2026-08-04). RecoveryWarning: type: object required: [blocked] properties: blocked: type: boolean description: Always false — this shape only ever warns. code: type: string enum: - tax_reversed_unrecoverable - recovery_superseded - relief_not_reissued message: type: string description: Operator-facing sentence naming the consequence that will go unreconciled if the payment is recorded. description: | What an operator must know BEFORE recording an OFFLINE payment on a written-off invoice (`status = uncollectible`) — a consequence that will go unreconciled, never a refusal. The money already arrived, and refusing to record it would only make the books wrong too. A written-off invoice is settled by RECORDING writers only — an offline payment, or the provider-search reconciler adopting a charge that turns out to have succeeded (ADR-108). Nothing initiates a card charge against one (ADR-113): recovery of a returned customer runs on normal rails via a fresh recovery invoice. `tax_reversed_unrecoverable` — the invoice's tax was reversed with the tax provider and cannot be re-reported automatically; the recorded payment collects tax already reported as not collected. `recovery_superseded` — the invoice was billed on a usage threshold and that usage has since been re-billed; the payment double-collects. `relief_not_reissued` — a credit the invoice was owed was never applied, so `amount_due_cents` is higher than what is owed and the customer may have overpaid. AttentionAction: type: string enum: - edit_billing_profile - retry_tax - retry_payment - wait_provider - rotate_api_key - reconcile_payment - review_registration - charge_now - send_reminder - add_payment_method - update_payment_method - connect_tax_provider - void_invoice description: | Operator's recommended next step. Closed enum because every code maps to a concrete server endpoint or frontend route, and audit logs key off the code. `void_invoice` is offered only on prepaid-commit exposure, and only while some of the granted credit is still unspent: voiding retires the unspent balance (ADR-078) and is the sole recovery for an unpaid invoice whose credit is already live. Credit the customer has already spent is never clawed back (2026-08-04). AttentionActionItem: type: object required: [code] properties: code: $ref: "#/components/schemas/AttentionAction" label: type: string description: | Default rendering label. UIs are free to substitute localized copy. Attention: type: object description: | Unified "this invoice needs operator attention" surface. Computed server-side from durable invoice fields (`tax_status`, `tax_error_code`, `payment_status`, `last_payment_error`). Never persisted — always derived. Omitted entirely when the invoice is healthy (matches Stripe's `last_finalization_error: null` ergonomic). See ADR-009. required: [severity, reason, message] properties: severity: $ref: "#/components/schemas/AttentionSeverity" reason: $ref: "#/components/schemas/AttentionReason" message: type: string description: Human-readable headline copy, safe to render verbatim. actions: type: array items: $ref: "#/components/schemas/AttentionActionItem" description: | Prescribed next steps. First item is the primary CTA; the rest are secondary. code: type: string description: | Open, dotted, provider-specific code (e.g. `tax.customer_data_invalid`, `payment.declined`). New codes ship without a contract bump. Stripe parity. doc_url: type: string description: | Deep link to operator-facing documentation for this reason. Stripe parity. param: type: string description: | Dotted-path pointer at the entity field the operator must edit to resolve the issue (e.g. `customer.address.postal_code`). Stripe parity. Empty when the resolution isn't field-scoped. detail: type: string description: | Raw provider payload (Stripe Tax JSON envelope, last payment error message). Disclosed in a collapsible section for diagnostic depth. since: type: string format: date-time description: | When the attention condition started — operators triage by age. tax_deferred_at for tax reasons; due_at for overdue; updated_at as a proxy for payment failures. InvoiceBillingReason: type: string enum: [subscription_cycle, subscription_create, subscription_cancel, subscription_update, manual, threshold] description: | Mirrors Stripe's `invoice.billing_reason`. Classifies the trigger that produced the invoice. Stamped at create time and never mutated. InvoiceLineType: type: string enum: [base_fee, usage, add_on, discount, tax] ItemChangeType: type: string enum: [plan, quantity, add, remove] description: | Classifies per-item proration artifacts so the dedup index can distinguish a plan-change and a quantity-change that happen to share the same `(subscription, item, timestamp)`. Invoice: type: object description: | A Velox invoice. Returned from create, finalize, void, list, get, rotate-public-token, and collect-payment endpoints. required: - id - customer_id - subscription_id - invoice_number - status - payment_status - currency - subtotal_cents - discount_cents - tax_amount_cents - tax_rate - total_amount_cents - amount_due_cents - amount_paid_cents - credits_applied_cents - billing_period_start - billing_period_end - net_payment_term_days - created_at - updated_at properties: id: type: string example: vlx_inv_42014438d82ddf33 tenant_id: type: string description: | Owning tenant. Returned for diagnostic clarity but redundant with the API key (which already determines the tenant). Omitted when empty. customer_id: type: string subscription_id: type: string description: Empty for one-off invoices not tied to a subscription. invoice_number: type: string description: Empty until the invoice is finalized. status: $ref: "#/components/schemas/InvoiceStatus" payment_status: $ref: "#/components/schemas/InvoicePaymentStatus" currency: type: string example: USD subtotal_cents: type: integer format: int64 discount_cents: type: integer format: int64 tax_amount_cents: type: integer format: int64 tax_rate: type: number format: double description: Tax rate as a percent (18.50 = 18.50%), up to 4-decimal precision. ADR-042/043. tax_name: type: string tax_country: type: string tax_id: type: string tax_provider: type: string description: | Durable audit snapshot of the upstream tax engine that ran the calculation (e.g. `stripe`, `none`, `manual`). tax_calculation_id: type: string description: | Provider-side calculation reference captured at build time. For Stripe Tax this expires after 24 h; persisted so finalized invoices remain reconstructable past expiry. tax_transaction_id: type: string description: | Provider-side committed transaction reference, captured at finalize. For Stripe Tax this is `tx_xxx` and is the handle the provider needs for a later reversal when a credit note is issued. tax_reverse_charge: type: boolean tax_exempt_reason: type: string tax_status: $ref: "#/components/schemas/InvoiceTaxStatus" tax_deferred_at: type: string format: date-time nullable: true tax_retry_count: type: integer tax_pending_reason: type: string tax_error_code: type: string description: | Typed taxonomy of `tax_pending_reason`. One of `customer_data_invalid`, `jurisdiction_unsupported`, `provider_outage`, `provider_auth`, `unknown`. Empty for invoices deferred before this column existed (migration 0067). attention: $ref: "#/components/schemas/Attention" recovery_warning: allOf: - $ref: "#/components/schemas/RecoveryWarning" description: | What an operator must know BEFORE recording an OFFLINE payment on a written-off invoice — a consequence that will go unreconciled, NOT a refusal. Absent when there is nothing to say. total_amount_cents: type: integer format: int64 amount_due_cents: type: integer format: int64 amount_paid_cents: type: integer format: int64 credits_applied_cents: type: integer format: int64 billing_period_start: type: string format: date-time billing_period_end: type: string format: date-time billing_period_display: type: string description: >- Human period string with the inclusive last covered day ("Jun 1, 2028 – Jun 30, 2028"), date-only in the invoice's billing timezone (ADR-058 / ADR-077). Computed on read; the raw billing_period_start/end stay half-open. Empty/omitted for one-off invoices with no period. billing_timezone: type: string description: >- IANA timezone the period boundaries are anchored in — the org billing timezone resolved and denormalized onto the invoice at issue (ADR-077). billing_period_display is rendered in this zone, so an issued invoice's dates stay fixed even if the org later changes its timezone. Empty for ad-hoc/legacy invoices. issued_at: type: string format: date-time nullable: true description: Set when the invoice transitions out of draft. due_at: type: string format: date-time nullable: true paid_at: type: string format: date-time nullable: true voided_at: type: string format: date-time nullable: true stripe_payment_intent_id: type: string last_payment_error: type: string auto_charge_pending: type: boolean public_token: type: string description: | Hosted-invoice-URL credential (Stripe-parity `hosted_invoice_url`). Generated at finalize; drafts have an empty token. Rotatable via `POST /v1/invoices/{id}/rotate-public-token`. net_payment_term_days: type: integer memo: type: string footer: type: string metadata: type: object additionalProperties: true created_at: type: string format: date-time updated_at: type: string format: date-time source_plan_changed_at: type: string format: date-time nullable: true source_subscription_item_id: type: string source_change_type: $ref: "#/components/schemas/ItemChangeType" billing_reason: $ref: "#/components/schemas/InvoiceBillingReason" stripe_invoice_id: type: string description: | Source Stripe invoice id (`in_xxx`) for an invoice imported from a Stripe account. Currently always empty: the Stripe importer was removed (deferred until a design-partner cutover) and nothing writes this field today. The column and its unique index remain for a future importer. Empty for Velox-native invoices. is_simulated: type: boolean description: | Whether this invoice's domain timestamps (created/issued/due/paid) were stamped on a test clock's simulated time rather than wall-clock. Captured at write time (engine: the subscription is clock-pinned; manual composer: the customer is clock-pinned). The dashboard renders a "simulated" badge from this authoritative flag instead of inferring it. Always false in live mode. InvoiceLineItem: type: object description: A single line on an invoice. required: - id - invoice_id - line_type - description - quantity - unit_amount_cents - amount_cents - tax_rate - tax_amount_cents - total_amount_cents - currency - created_at properties: id: type: string invoice_id: type: string tenant_id: type: string description: Owning tenant. Omitted when empty. line_type: $ref: "#/components/schemas/InvoiceLineType" meter_id: type: string meter_unit: type: string description: > The meter's unit ("tokens", "seconds", …), stamped on usage lines at write time so invoice surfaces can apply the display-scale convention (token rates read "$3.00 / 1M tokens"). Empty on non-usage lines and on lines written before the 0173 backfill. description: type: string quantity: type: integer format: int64 quantity_decimal: type: string description: > Exact (possibly fractional) usage quantity as a decimal string (e.g. "1.5"). The integer `quantity` is this truncated, kept for back-compat (Stripe quantity_decimal parity). "0" means none — use `quantity`. Line amounts stay whole cents. unit_amount_cents: type: integer format: int64 unit_amount_decimal: type: string description: > Full-precision per-unit price in DECIMAL CENTS (e.g. "0.3" = $0.003 per unit), the Stripe unit_amount_decimal model. Derived on read as amount_cents ÷ quantity, so it reconciles with the rounded line amount and, unlike the whole-cent `unit_amount_cents`, never collapses a sub-cent rate to 0. Render with the decimal-aware rate formatter; line amounts/totals stay whole cents. amount_cents: type: integer format: int64 tax_rate: type: number format: double description: Tax rate as a percent (18.50 = 18.50%), up to 4-decimal precision. ADR-042/043. tax_amount_cents: type: integer format: int64 tax_jurisdiction: type: string tax_code: type: string tax_reason: type: string description: | Stripe-canonical structured `taxability_reason` (`standard_rated`, `reverse_charge`, `not_collecting`, `customer_exempt`, `product_exempt`, `zero_rated`, etc.). Empty for non-Stripe providers. total_amount_cents: type: integer format: int64 currency: type: string pricing_mode: type: string rating_rule_version_id: type: string billing_period_start: type: string format: date-time nullable: true billing_period_end: type: string format: date-time nullable: true metadata: type: object additionalProperties: true created_at: type: string format: date-time commit_granted_cents: type: integer format: int64 nullable: true description: | Marks the line as a prepaid-commit purchase (ADR-078): when the invoice finalizes, a credit block of this many cents is granted to the customer. May differ from the line price — discounted commits sell $10k of credits for $9k. add_on lines on manual invoices only, at most one per invoice. Absent on normal lines. commit_expires_at: type: string format: date-time nullable: true description: | Expiry of the granted commit block. Absent/null = the credits never expire (the default). ProviderCostRate: type: object required: [id, provider, model, token_type, cost_per_token, currency] properties: id: type: string provider: type: string model: type: string token_type: type: string cost_per_token: type: string description: Decimal dollars per token. currency: type: string created_at: type: string format: date-time updated_at: type: string format: date-time MarginReport: type: object required: [customer_id, from, to, revenue_cents, cost_micros, by_model, unattributed_revenue_cents, unresolved_events] properties: customer_id: type: string from: type: string format: date-time to: type: string format: date-time revenue_cents: type: integer format: int64 description: Total rated usage revenue in the window. cost_micros: type: integer format: int64 description: Total stamped provider cost, micro-dollars. margin_bps: type: integer format: int64 description: (revenue − cost) / revenue in basis points. Omitted when revenue is 0. by_model: type: array items: type: object required: [model, cost_micros, attributed] properties: model: type: string cost_micros: type: integer format: int64 revenue_cents: type: integer format: int64 attributed: type: boolean description: True only when a pricing rule pins this model — margin is never heuristically allocated. unattributed_revenue_cents: type: integer format: int64 unresolved_events: type: integer format: int64 description: Token events with costable dims but no matching rate — add a rate to cost new events. cache_write_excluded: type: boolean InvoiceWithLineItems: type: object description: | Response shape for `GET /v1/invoices/{id}`. The handler returns the invoice and its line items as separate top-level keys (rather than nesting `line_items` inside the invoice) so the client can render the two arrays independently. required: [invoice, line_items] properties: invoice: $ref: "#/components/schemas/Invoice" line_items: type: array items: $ref: "#/components/schemas/InvoiceLineItem" CreditBalance: type: object properties: customer_id: type: string balance_cents: type: integer format: int64 total_granted: type: integer format: int64 total_used: type: integer format: int64 RecipeOverride: type: object description: >- One override key declared in a recipe's `overridable` list. Drives validation at instantiate time and the override form in the dashboard's preview dialog. required: [key, type, default] properties: key: type: string type: type: string enum: [string, int] default: description: Default value applied when the override is not supplied. enum: type: array items: type: string max_length: type: integer pattern: type: string RecipeMeter: type: object required: [key, name, unit, aggregation] properties: key: type: string name: type: string unit: type: string aggregation: type: string RecipeRatingRule: type: object required: [key, mode, currency, flat_amount_cents, overage_unit_amount_cents] properties: key: type: string name: type: string mode: type: string enum: [flat, graduated, package] currency: type: string flat_amount_cents: type: string description: Per-unit rate in cents, as a decimal string (see RatingRule.flat_amount_cents). graduated_tiers: type: array items: type: object properties: up_to: type: integer unit_amount_cents: type: string description: Per-unit rate in cents, as a decimal string. package_size: type: integer format: int64 package_amount_cents: type: integer format: int64 overage_unit_amount_cents: type: string description: Per-unit overage rate in cents, as a decimal string. RecipePricingRule: type: object required: [meter_key, rating_rule_key, dimension_match, aggregation_mode, priority] properties: meter_key: type: string rating_rule_key: type: string dimension_match: type: object additionalProperties: true aggregation_mode: type: string enum: [sum, count, last_during_period, last_ever, max] priority: type: integer RecipePlan: type: object required: [code, name, currency, billing_interval, base_amount_cents, meter_keys] properties: code: type: string name: type: string currency: type: string billing_interval: type: string enum: [monthly, yearly] base_amount_cents: type: integer format: int64 base_bill_timing: type: string enum: [in_advance, in_arrears] description: Optional; empty defaults to in_arrears at plan creation (ADR-031). meter_keys: type: array items: type: string RecipeDunningPolicy: type: object required: [name, max_retries, intervals_hours, final_subscription_action, final_invoice_action] properties: name: type: string max_retries: type: integer intervals_hours: type: array items: type: integer final_subscription_action: type: string enum: [none, pause, cancel] description: > What exhausting all retries does to the subscription behind the unpaid invoice. `pause` pauses collection only (the cycle keeps drafting invoices); `cancel` cancels the subscription. No-op on a one-off invoice, which has no subscription. final_invoice_action: type: string enum: [none, mark_uncollectible] description: > What exhausting all retries does to the unpaid invoice. `mark_uncollectible` writes it off as bad debt — the receivable closes, the invoice stays on the books for audit, and it remains settleable out of band. `none` leaves it finalized and due, which means it stays open until a human closes or collects it. Independent of `final_subscription_action` (ADR-112): the two were one enum until 2026-08-05, which made "cancel and write off" inexpressible. RecipeWebhook: type: object required: [events, url_placeholder] properties: events: type: array items: type: string url_placeholder: type: string RecipeCreates: type: object description: >- Per-role count of objects a recipe will produce when instantiated. Recipes create no product entity — there is no `products` count. required: [meters, rating_rules, pricing_rules, plans, dunning_policies, webhook_endpoints] properties: meters: type: integer rating_rules: type: integer pricing_rules: type: integer plans: type: integer dunning_policies: type: integer webhook_endpoints: type: integer RecipeCreatedObjects: type: object description: >- Per-role map of entity IDs a recipe apply produced (including ADOPTED catalog objects — apply reconnects to an existing meter/rule graph rather than duplicating it, ADR-085). Every field is omitted when empty; clients must guard reads. properties: meter_ids: type: array items: type: string rating_rule_ids: type: array items: type: string pricing_rule_ids: type: array items: type: string plan_ids: type: array items: type: string dunning_policy_id: type: string webhook_endpoint_id: type: string RecipeInstance: type: object description: >- The install badge: the record of a recipe being applied for a tenant (scoped per livemode since m0157). Field names are `recipe_key` / `recipe_version` — NOT `key`/`version`, which the recipe template itself uses. required: [id, recipe_key, recipe_version, overrides, created_objects, created_at] properties: id: type: string tenant_id: type: string description: Omitted when empty. recipe_key: type: string recipe_version: type: string overrides: type: object additionalProperties: true created_objects: $ref: "#/components/schemas/RecipeCreatedObjects" created_at: type: string format: date-time created_by: type: string description: Operator email or API key ID. Omitted when empty. Recipe: type: object description: >- A recipe template as served by list/detail: the full parsed YAML (meters, rating rules, pricing rules, plans, optional dunning policy and webhook), not just display metadata. required: [key, version, name, summary, overridable, meters, rating_rules, pricing_rules, plans] properties: key: type: string version: type: string name: type: string summary: type: string description: type: string overridable: type: array items: $ref: "#/components/schemas/RecipeOverride" meters: type: array items: $ref: "#/components/schemas/RecipeMeter" rating_rules: type: array items: $ref: "#/components/schemas/RecipeRatingRule" pricing_rules: type: array items: $ref: "#/components/schemas/RecipePricingRule" plans: type: array items: $ref: "#/components/schemas/RecipePlan" dunning_policy: $ref: "#/components/schemas/RecipeDunningPolicy" webhook: $ref: "#/components/schemas/RecipeWebhook" RecipeListItem: allOf: - $ref: "#/components/schemas/Recipe" - type: object required: [creates] properties: creates: $ref: "#/components/schemas/RecipeCreates" instantiated: $ref: "#/components/schemas/RecipeInstance" description: >- Present when this tenant (in the session's livemode) has installed the recipe; absent otherwise. Drives the "Installed" badge — re-apply is an idempotent no-op and there is no uninstall (ADR-085). RecipeDetail: allOf: - $ref: "#/components/schemas/Recipe" - type: object required: [creates] properties: creates: $ref: "#/components/schemas/RecipeCreates" RecipePreviewObjects: type: object description: >- Would-be-created object graph, grouped by role. All arrays are always present (possibly empty); optional singletons (dunning, webhook) are emitted as 0-or-1-length arrays so clients iterate without null guards. required: [meters, rating_rules, pricing_rules, plans, dunning_policies, webhook_endpoints] properties: meters: type: array items: $ref: "#/components/schemas/RecipeMeter" rating_rules: type: array items: $ref: "#/components/schemas/RecipeRatingRule" pricing_rules: type: array items: $ref: "#/components/schemas/RecipePricingRule" plans: type: array items: $ref: "#/components/schemas/RecipePlan" dunning_policies: type: array items: $ref: "#/components/schemas/RecipeDunningPolicy" webhook_endpoints: type: array items: $ref: "#/components/schemas/RecipeWebhook" RecipePreviewResult: type: object required: [key, version, objects, warnings] properties: key: type: string version: type: string objects: $ref: "#/components/schemas/RecipePreviewObjects" warnings: type: array items: type: string paths: /health: get: tags: [System] summary: Health check security: [] responses: "200": description: Service is healthy /v1/customers: post: tags: [Customers] summary: Create customer requestBody: required: true content: application/json: schema: type: object required: [external_id, display_name] properties: external_id: type: string display_name: type: string email: type: string additional_emails: type: array maxItems: 10 items: type: string responses: "201": description: Customer created content: application/json: schema: $ref: "#/components/schemas/Customer" "409": description: Customer already exists get: tags: [Customers] summary: List customers parameters: - name: status in: query schema: type: string - name: external_id in: query description: Exact-match filter on the caller-assigned external ID. schema: type: string - name: ids in: query description: Comma-separated customer IDs to fetch exactly. schema: type: string - name: search in: query description: | Case-insensitive substring match on display name, email, external ID, or ID. Matching runs post-decryption (PII columns are encrypted at rest), bounded to the first 5000 rows of the tenant's filtered set. schema: type: string - name: sort in: query description: One of display_name, email, external_id, status, created_at (default). schema: type: string - name: dir in: query schema: type: string enum: [asc, desc] - name: limit in: query schema: type: integer - name: offset in: query schema: type: integer responses: "200": description: Customer list /v1/customers/{id}/rotate-cost-dashboard-token: post: tags: [Customers] summary: Rotate the public cost-dashboard token (ADR-032) description: | Mints a fresh `vlx_pcd_…` token and writes it to the customer row. The previous token is invalidated immediately (read-only public surface; no grace window). The plaintext token is the sole credential for `GET /v1/public/cost-dashboard/{token}` and is shown ONCE — Velox never returns it again after this response. Audit log records the rotation without the token. parameters: - name: id in: path required: true schema: type: string responses: "200": description: Rotated content: application/json: schema: type: object required: [token, public_url] properties: token: type: string example: vlx_pcd_a3f9c4... public_url: type: string example: https://api.velox.dev/v1/public/cost-dashboard/vlx_pcd_a3f9c4... "404": description: Customer not found /v1/public/cost-dashboard/{token}: get: tags: [Public] summary: Public cost-dashboard projection (ADR-032) description: | Unauthenticated. The `vlx_pcd_…` token in the URL is the sole credential — partners embed via iframe / fetch from their own app without an API key. Sanitized projection: customer_id, tenant_id, billing_period, subscriptions, usage[], totals, projected_total_cents. PII (email, display_name, external_id, metadata, billing_profile) is NEVER on this response. Wrong prefix or unknown token → 401 (anti-enumeration). No active sub → 200 with `billing_period.source = "no_subscription"` and empty arrays. parameters: - name: token in: path required: true schema: type: string responses: "200": description: Projection content: application/json: schema: type: object properties: customer_id: { type: string } tenant_id: { type: string } livemode: { type: boolean } billing_period: type: object properties: start: { type: string, format: date-time } end: { type: string, format: date-time } source: type: string enum: [subscription, no_subscription] description: > Branch on this before rendering the period. When it is `no_subscription` the customer has no active subscription, every array is empty, and `start`/`end` carry the zero time (`0001-01-01T00:00:00Z`) rather than a real range — display them and the page reads "January 1, year 1". subscriptions: type: array items: type: object properties: id: { type: string } plan_name: { type: string } currency: { type: string } current_period_start: { type: string, format: date-time } current_period_end: { type: string, format: date-time } usage: type: array items: type: object properties: meter_key: { type: string } meter_name: { type: string } unit: { type: string } currency: { type: string } total_quantity: { type: string } total_amount_cents: { type: integer, format: int64 } rules: type: array items: type: object properties: rule_key: { type: string } dimension_match: type: object additionalProperties: { type: string } quantity: { type: string } amount_cents: { type: integer, format: int64 } unit_amount_decimal: type: string description: > Per-unit price in DECIMAL CENTS (ADR-045, the Stripe unit_amount_decimal model) — NOT dollars. "0.0003" is 0.0003 CENTS per unit ($3.00 per 1M units); multiplying it by `quantity` yields CENTS, matching the sibling `amount_cents`. Reading it as dollars overstates the cost 100x. totals: type: array description: One entry per currency present in `usage`. items: type: object properties: currency: { type: string } amount_cents: { type: integer, format: int64 } projected_total_cents: type: integer format: int64 description: > Projected METERED cost for the current period, in cents. Recurring base fees are not included — an in_advance base is already invoiced at period open. "401": description: Invalid token "429": description: Rate limited (60/min/IP) /v1/meters: post: tags: [Pricing] summary: Create meter requestBody: required: true content: application/json: schema: type: object required: [key, name] properties: key: type: string name: type: string unit: type: string default: unit aggregation: type: string enum: [sum, count, max, last] default: sum rating_rule_version_id: type: string responses: "201": description: Meter created get: tags: [Pricing] summary: List meters responses: "200": description: Meter list /v1/meters/{id}: patch: tags: [Pricing] summary: Update meter (incl. the default rating-rule binding) description: | Fields omitted are left unchanged. `rating_rule_version_id` sets the meter's DEFAULT rating rule — the rate applied to usage no dimension pricing rule claims (the remedy for silently-unbilled unmatched usage); pass an empty string to clear it. The rule must exist on the tenant. parameters: - name: id in: path required: true schema: type: string requestBody: required: true content: application/json: schema: type: object properties: name: type: string unit: type: string aggregation: type: string enum: [sum, count, max, last] rating_rule_version_id: type: string description: Default rating rule; empty string clears. responses: "200": description: Updated meter "404": description: Meter not found "422": description: Validation failure (unknown rule id, bad aggregation) /v1/credit-notes: post: tags: [CreditNotes] summary: Create a credit note (line-based, or commit relief) description: | Two shapes. LINE-BASED (regular invoices): lines[] + refund/credit/out_of_band allocation; capped by the invoice's remaining creditable gross. COMMIT RELIEF (ADR-080, paid commit invoices only): pass commit_relief and NO lines — the server retires commit credits and derives the refundable cash from the telescoping price-ratio anchor (discounted commits refund at the purchase rate, never at face). The relief credit note is created AND issued in one transaction; the credit-balance channel is forbidden on relief. Unpaid commit invoices: void the invoice instead. requestBody: required: true content: application/json: schema: type: object required: [invoice_id] properties: invoice_id: type: string reason: type: string lines: type: array items: type: object properties: description: type: string quantity: type: integer unit_amount_cents: type: integer refund_amount_cents: type: integer credit_amount_cents: type: integer out_of_band_amount_cents: type: integer auto_issue: type: boolean commit_relief: type: object description: Mutually exclusive with lines/credit_amount_cents. properties: retire_cents: type: integer description: Commit credits to retire (1..remaining). retire_all: type: boolean description: Retire the LIVE remaining (absorbs racing drawdowns). refund_amount_cents: type: integer description: Optional allocation; must sum with out_of_band to the derived cash. out_of_band_amount_cents: type: integer responses: "201": description: Credit note (issued immediately for commit relief) "409": description: State gate (unpaid commit → void; fully-consumed commit; in-flight charge) "422": description: Validation (over-retire with live remaining; allocation mismatch; zero-cash slice) /v1/credits/grants/{customer_id}: get: tags: [Credits] summary: Per-grant burndown + kind subtotals description: | The customer's credit blocks with drawdown state — granted / drawn / remaining per grant, expiry, and remaining split by cost-basis class (commit = money-backed, promotional = free, other = legacy/adjustments). include_exhausted=true adds fully-drawn blocks for history. parameters: - name: customer_id in: path required: true schema: type: string - name: include_exhausted in: query schema: type: boolean responses: "200": description: Grants with remaining + kind subtotals /v1/webhook-endpoints/endpoints: post: tags: [Webhooks] summary: Create webhook endpoint description: | Returns the endpoint plus its signing secret (shown once). Event names are validated against the emit catalog — subscribing to an event Velox never emits is a 422, not silence. Omitted/empty events = all events ("*"); "invoice.*"-style prefix wildcards are supported. requestBody: required: true content: application/json: schema: type: object required: [url] properties: url: type: string description: type: string events: type: array items: type: string responses: "201": description: Endpoint created (response carries the one-time signing secret) "422": description: Invalid URL or unknown event type get: tags: [Webhooks] summary: List webhook endpoints responses: "200": description: Endpoint list /v1/webhook-endpoints/endpoints/{id}: patch: tags: [Webhooks] summary: Update webhook endpoint (no secret rotation) description: | Mutates url / description / events / active WITHOUT rotating the signing secret — the receiver keeps verifying while the target or event set changes. Fields omitted are left unchanged; events are validated against the emit catalog. This is also how a recipe-created endpoint (inactive, placeholder URL) is pointed at a real receiver and activated. parameters: - name: id in: path required: true schema: type: string requestBody: required: true content: application/json: schema: type: object properties: url: type: string description: type: string events: type: array items: type: string active: type: boolean responses: "200": description: Updated endpoint "404": description: Endpoint not found "422": description: Invalid URL or unknown event type delete: tags: [Webhooks] summary: Delete (disable) webhook endpoint parameters: - name: id in: path required: true schema: type: string responses: "200": description: Endpoint disabled /v1/webhook-endpoints/endpoints/{id}/rotate-secret: post: tags: [Webhooks] summary: Rotate signing secret (72h dual-signing grace) parameters: - name: id in: path required: true schema: type: string responses: "200": description: New secret (old secret keeps verifying during the grace window) /v1/webhook-endpoints/events: get: tags: [Webhooks] summary: List recent outbound events (newest 50) responses: "200": description: Event list /v1/plans: post: tags: [Pricing] summary: Create plan requestBody: required: true content: application/json: schema: type: object required: [code, name, currency, billing_interval] properties: code: type: string name: type: string currency: type: string billing_interval: type: string enum: [monthly, yearly] base_amount_cents: type: integer base_bill_timing: type: string enum: [in_advance, in_arrears] description: | Optional. Defaults to in_arrears. See Plan.base_bill_timing for semantics (ADR-031). meter_ids: type: array items: type: string responses: "201": description: Plan created get: tags: [Pricing] summary: List plans responses: "200": description: Plan list /v1/rating-rules: post: tags: [Pricing] summary: Create rating rule requestBody: required: true content: application/json: schema: type: object required: [rule_key, name, mode, currency] properties: rule_key: type: string name: type: string mode: type: string enum: [flat, graduated, package] currency: type: string flat_amount_cents: type: string description: >- Per-unit rate in cents, as a decimal string (e.g. "0.0003" = $3.00 per 1,000,000 units). graduated_tiers: type: array items: type: object properties: up_to: type: integer unit_amount_cents: type: string description: Per-unit rate in cents, as a decimal string. package_size: type: integer package_amount_cents: type: integer overage_unit_amount_cents: type: string description: >- Package-mode per-unit overage rate in cents, as a decimal string. Fixed fees (package_amount_cents) stay integer cents. responses: "201": description: Rating rule created get: tags: [Pricing] summary: List rating rules responses: "200": description: Rating rule list /v1/subscriptions: post: tags: [Subscriptions] summary: Create subscription requestBody: required: true content: application/json: schema: type: object required: [code, display_name, customer_id, items] properties: code: type: string display_name: type: string customer_id: type: string items: type: array description: Plan items — a subscription bills one or more plans. items: type: object required: [plan_id] properties: plan_id: type: string quantity: type: integer default: 1 billing_time: type: string enum: [calendar, anniversary] default: calendar trial_days: type: integer start_now: type: boolean responses: "201": description: Subscription created get: tags: [Subscriptions] summary: List subscriptions parameters: - name: customer_id in: query schema: type: string - name: plan_id in: query description: Filter to subscriptions with a live item on this plan. schema: type: string - name: status in: query schema: type: string - name: search in: query description: Case-insensitive substring match on display name or code. schema: type: string - name: sort in: query description: One of display_name, status, next_billing_at, trial_end_at, created_at (default). schema: type: string - name: dir in: query schema: type: string enum: [asc, desc] - name: limit in: query schema: type: integer - name: offset in: query schema: type: integer responses: "200": description: Subscription list /v1/subscriptions/{id}/activate: post: tags: [Subscriptions] summary: Activate draft subscription parameters: - name: id in: path required: true schema: type: string responses: "200": description: Subscription activated /v1/usage-events: post: tags: [Usage] summary: Ingest usage event requestBody: required: true content: application/json: schema: type: object required: [external_customer_id, event_name, quantity] properties: external_customer_id: type: string description: The customer's external_id (your identifier), not the internal vlx_cus_ id. event_name: type: string description: The meter key (e.g. "tokens"). quantity: oneOf: - type: number - type: string description: Decimal quantity — accepts a JSON number (5) or string ("5.5") for exact fractional values. dimensions: type: object additionalProperties: true description: Scalar dimension values used for pricing-rule dispatch (e.g. {"model", "token_type"}). properties: type: object additionalProperties: true idempotency_key: type: string timestamp: type: string format: date-time responses: "201": description: Event ingested "200": description: | Idempotency replay — the original event is returned unchanged with an `Idempotent-Replayed: true` header. No duplicate row is written. get: tags: [Usage] summary: List usage events parameters: - name: customer_id in: query schema: type: string - name: meter_id in: query schema: type: string - name: dimensions in: query description: | Comma-separated key=value pairs matched against the event's dimensions via JSONB containment (multiple pairs AND). Values that parse as JSON booleans/numbers are matched typed ("cached=true", "attempt=2"); everything else as a string. Malformed pairs → 422. Example: `model=gpt-4o,token_type=input`. schema: type: string responses: "200": description: Event list "422": description: Malformed dimensions filter /v1/usage-events/batch: post: tags: [Usage] summary: Batch ingest usage events (max 1000, all-or-nothing) description: | The batch is atomic: every event validates, then all of them commit in one transaction. On any validation failure NOTHING is ingested, so retrying the whole batch is always safe — including for events without idempotency keys. Replays of already-ingested keys are counted in `deduplicated` and are NOT errors. requestBody: required: true content: application/json: schema: type: array items: type: object required: [external_customer_id, event_name, quantity] properties: external_customer_id: type: string description: The customer's external_id (your identifier), not the internal vlx_cus_ id. event_name: type: string description: The meter key (e.g. "tokens"). quantity: oneOf: - type: number - type: string description: Decimal quantity — accepts a JSON number (5) or string ("5.5") for exact fractional values. dimensions: type: object additionalProperties: true idempotency_key: type: string timestamp: type: string format: date-time responses: "201": description: Batch committed content: application/json: schema: type: object properties: ingested: type: integer description: Newly written events. deduplicated: type: integer description: Idempotency replays — already ingested, counted as success. total: type: integer "422": description: | Batch rejected — one or more events failed validation and NOTHING was ingested. `errors[]` names every failing index (`event[i]: reason`); fix them and retry the whole batch. "413": description: Request body over the 1MB cap — send smaller batches. /v1/invoices: get: tags: [Invoices] summary: List invoices parameters: - name: customer_id in: query schema: type: string - name: subscription_id in: query schema: type: string - name: status in: query schema: type: string - name: payment_status in: query schema: type: string - name: search in: query description: Case-insensitive substring match on invoice number. schema: type: string - name: from in: query description: | Lower bound on created_at, inclusive. RFC3339 instant or bare YYYY-MM-DD (anchored at 00:00:00Z). schema: type: string - name: to in: query description: | Upper bound on created_at, inclusive. RFC3339 instant or bare YYYY-MM-DD (anchored at 23:59:59Z). schema: type: string - name: overdue in: query description: | When true, restrict to open invoices past their due date that haven't settled (finalized + due_at < now + payment_status not succeeded/processing). Backs the dashboard's "Past due" segment. schema: type: boolean - name: ids in: query description: Comma-separated invoice IDs to fetch exactly. schema: type: string - name: sort in: query description: One of invoice_number, amount_due_cents, billing_period_start, due_at, issued_at, status, payment_status, created_at (default). schema: type: string - name: dir in: query schema: type: string enum: [asc, desc] - name: limit in: query schema: type: integer - name: offset in: query schema: type: integer - name: after in: query description: Opaque cursor (default created_at sort only); takes precedence over offset. schema: type: string responses: "200": description: Invoice list /v1/invoices/create_preview: post: tags: [Invoices] summary: Preview the next invoice for a customer (Stripe-equivalent) description: | Answers "what is my next bill going to look like?" as a full-period ESTIMATE. Usage lines are rated through the same per-meter aggregation path as the cycle scan, but the estimate deliberately does NOT replicate usage-cap scaling or mid-period segment proration (ADR-045) — for capped or mid-period-changed subscriptions the response carries `warnings[]` naming the divergence and the finalized invoice can differ (lower, for capped usage). Composes across customer / subscription / pricing; registered as a sibling of `/v1/invoices` (chi picks the more specific pattern, otherwise `/{id}` would capture `create_preview` as an invoice ID). See [`docs/design-create-preview.md`](../docs/design-create-preview.md). requestBody: required: true content: application/json: schema: type: object required: [customer_id] properties: customer_id: type: string description: Customer to preview the invoice for. subscription_id: type: string description: | Optional. Defaults to the customer's primary active subscription if not supplied. period: type: object description: | Optional explicit window. Both bounds must be supplied together; partial windows are rejected. Defaults to the resolved subscription's current cycle. required: [from, to] properties: from: type: string format: date-time to: type: string format: date-time responses: "200": description: Preview result "400": description: Invalid request body content: application/json: schema: $ref: "#/components/schemas/Error" "404": description: Customer or subscription not found content: application/json: schema: $ref: "#/components/schemas/Error" "422": description: Validation error (missing customer_id, partial period, no active sub, ...) content: application/json: schema: $ref: "#/components/schemas/Error" /v1/invoices/{id}: get: tags: [Invoices] summary: Get invoice with line items operationId: getInvoice parameters: - name: id in: path required: true schema: type: string responses: "200": description: Invoice with its line items. content: application/json: schema: $ref: "#/components/schemas/InvoiceWithLineItems" "404": description: Invoice not found. content: application/json: schema: $ref: "#/components/schemas/Error" /v1/invoices/{id}/pdf: get: tags: [Invoices] summary: Download invoice PDF parameters: - name: id in: path required: true schema: type: string responses: "200": description: PDF file content: application/pdf: {} /v1/invoices/{id}/send: post: tags: [Invoices] summary: Email the invoice (PDF attached) to a recipient description: | Sends the invoice document email. `additional_emails` is tri-state (ADR-082): absent → the customer's stored additional_emails are CC'd (so legacy `{email}`-only bodies now CC by default); explicit `[]` → primary only for this send; explicit list → validated exact override (same rules as the customer field — normalize, dedupe, not-the-recipient, cap 10). parameters: - name: id in: path required: true schema: type: string requestBody: required: true content: application/json: schema: type: object required: [email] properties: email: type: string format: email additional_emails: type: array maxItems: 10 items: type: string responses: "200": description: Email enqueued "404": description: Invoice not found "422": description: Invalid recipient or CC entry /v1/credit-notes/{id}/send: post: tags: [Credit Notes] summary: Email the credit-note document (PDF attached) description: | Sends the credit-note PDF to the customer (ADR-082 rider — CNs previously had no send surface at all). Issued credit notes only: drafts and voided CNs return 422. Recipient contract matches the invoice send (`email` + tri-state `additional_emails`). The send is recorded on the applied invoice's timeline and the customer's Sent-emails section. parameters: - name: id in: path required: true schema: type: string requestBody: required: true content: application/json: schema: type: object required: [email] properties: email: type: string format: email additional_emails: type: array maxItems: 10 items: type: string responses: "200": description: Email enqueued "404": description: Credit note not found "422": description: Not issued (draft/voided), or invalid recipient /v1/invoices/{id}/finalize: post: tags: [Invoices] summary: Finalize draft invoice parameters: - name: id in: path required: true schema: type: string responses: "200": description: Invoice finalized /v1/invoices/{id}/retry-tax: post: tags: [Invoices] summary: Retry tax calculation on a deferred invoice description: | Re-runs tax calculation against a draft invoice whose `tax_status` is `pending` or `failed`. Backs the operator- facing "Retry tax" action surfaced by the unified `Attention` shape. Idempotent: each call increments `tax_retry_count` and rewrites the per-line and invoice-level tax fields. A retry that succeeds clears `tax_pending_reason` / `tax_error_code` and unblocks finalize. A retry that still fails writes the new typed code so the dashboard banner refreshes — operators get an immediate signal of whether their fix worked. Returns the updated invoice (carrying the re-derived `attention`) on success or post-retry failure. A retry that is still failing is **not** an HTTP error — the caller renders the new attention. parameters: - name: id in: path required: true schema: type: string responses: "200": description: Invoice updated; check `attention` for the new state. content: application/json: schema: $ref: "#/components/schemas/Invoice" "404": description: Invoice not found. "409": description: | Invoice is not in a retryable state (status != draft, or tax_status not in pending/failed). /v1/auth/login: post: tags: [Auth] summary: Sign in with email and password; mint an httpOnly session cookie description: | Validates email + password, mints a server-side session row, and sets `Set-Cookie: velox_session=...; HttpOnly; SameSite=Lax; Path=/`. Subsequent dashboard requests authenticate via the cookie; sessions are bound to `users.id` and are independent of API key lifecycle. See ADR-011. requestBody: required: true content: application/json: schema: type: object required: [email, password] properties: email: type: string format: email password: type: string responses: "200": description: Session minted; cookie set on response content: application/json: schema: type: object required: [user_id, tenant_id, email, livemode, expires_at] properties: user_id: type: string tenant_id: type: string email: type: string livemode: type: boolean expires_at: type: string format: date-time "401": description: Invalid email or password content: application/json: schema: $ref: "#/components/schemas/Error" "429": description: | Rate limited — the shared per-IP request limit on `/v1/auth` was exceeded. There is no per-account lockout or login throttle in v1 (ADR-094). content: application/json: schema: $ref: "#/components/schemas/Error" /v1/auth/logout: post: tags: [Auth] summary: Revoke the current session and clear the cookie description: | Marks the session row as revoked server-side and clears the cookie on the response. Idempotent — a missing or stale cookie is treated as already-logged-out. responses: "204": description: Session revoked /v1/auth/mode: post: tags: [Auth] summary: Switch the dashboard session between test and live mode description: | Updates `dashboard_sessions.livemode` for the cookie session. All subsequent requests inherit the new mode via session middleware, so customers / invoices / subscriptions / API keys reads filter to the active mode without per-call mode parameters. The same operator flips back and forth without re-authenticating. requestBody: required: true content: application/json: schema: type: object required: [livemode] properties: livemode: type: boolean responses: "200": description: Mode updated content: application/json: schema: type: object required: [livemode] properties: livemode: type: boolean "401": description: Missing or expired session content: application/json: schema: $ref: "#/components/schemas/Error" /v1/auth/password-reset/request: post: tags: [Auth] summary: Request a password reset link description: | Always returns the same 200 body regardless of whether the email exists, to avoid account enumeration. If a user matches, a single-use 1-hour token is generated and the reset link is emailed via SMTP (requires `SMTP_HOST` and `DASHBOARD_BASE_URL`; there is no stdout fallback). `email_delivery` reports whether this deployment can deliver reset emails at all — deployment posture, computed independently of any match, so it leaks no account existence. requestBody: required: true content: application/json: schema: type: object required: [email] properties: email: type: string format: email responses: "200": description: Request accepted (identical body regardless of email existence) content: application/json: schema: type: object required: [message, email_delivery] properties: message: type: string email_delivery: type: string enum: [ok, not_configured] description: | Whether this deployment can deliver reset emails (SMTP configured AND `DASHBOARD_BASE_URL` set). Deployment posture, never account state. /v1/auth/password-reset/confirm: post: tags: [Auth] summary: Set a new password using a reset token description: | Consumes a single-use reset token and updates the user's password. The token is invalidated on use; reuse or expired tokens return 422. requestBody: required: true content: application/json: schema: type: object required: [token, password] properties: token: type: string password: type: string minLength: 12 responses: "204": description: Password updated "422": description: Token invalid/expired or password does not meet policy content: application/json: schema: $ref: "#/components/schemas/Error" /v1/auth/invite/{token}: get: tags: [Members] summary: Preview a team invitation (accept-page context) description: | Unauthenticated — the token IS the credential (mirrors password reset). Returns the invited email, workspace name, and whether the invitee needs to create an account. Invalid, expired, revoked, and already-used tokens all return the same generic 422 (no state oracle for token guessing). ADR-081. parameters: - name: token in: path required: true schema: type: string responses: "200": description: Invitation preview content: application/json: schema: type: object required: [email, tenant_id, tenant_name, needs_new_account, expires_at] properties: email: type: string tenant_id: type: string tenant_name: type: string needs_new_account: type: boolean invited_by_email: type: string expires_at: type: string format: date-time "422": description: Token invalid, expired, revoked, or already used content: application/json: schema: $ref: "#/components/schemas/Error" /v1/auth/accept-invite: post: tags: [Members] summary: Accept a team invitation description: | Consumes the single-use invite token (CAS — a replay or a race loses) and attaches the membership. If no account exists for the invited email, `password` is required (min 12 chars), the account is created, and a session cookie is minted (`session_minted: true`). An EXISTING account is attached WITHOUT a session — possession of the email alone must not log into an already-privileged account; the user signs in with their own password afterwards. ADR-081. requestBody: required: true content: application/json: schema: type: object required: [token] properties: token: type: string password: type: string minLength: 12 description: Required (and validated) only when no account exists for the invited email; ignored otherwise. responses: "200": description: Membership attached; cookie set when session_minted is true content: application/json: schema: type: object required: [user_id, email, tenant_id, session_minted, livemode, expires_at] properties: user_id: type: string email: type: string tenant_id: type: string session_minted: type: boolean livemode: type: boolean expires_at: type: string description: Session expiry (RFC 3339) when session_minted; empty string otherwise. "422": description: Token invalid/expired/revoked/used, or password does not meet policy content: application/json: schema: $ref: "#/components/schemas/Error" /v1/members/: get: tags: [Members] summary: List workspace members and invitations description: | Dashboard-session only (403 for API keys — membership is a human act with a human actor). Members are every user attached to the tenant; invitations carry a server-derived status (pending/accepted/revoked/expired). No RBAC yet: `role` is recorded but every member holds the full permission set. ADR-081. responses: "200": description: Members + invitations content: application/json: schema: type: object required: [members, invitations] properties: members: type: array items: type: object required: [user_id, email, role, joined_at] properties: user_id: type: string email: type: string role: type: string joined_at: type: string format: date-time invitations: type: array items: $ref: "#/components/schemas/MemberInvitation" "403": description: Caller is not a dashboard session content: application/json: schema: $ref: "#/components/schemas/Error" /v1/members/invite: post: tags: [Members] summary: Invite a teammate by email description: | Creates a pending invitation (7-day expiry, one pending per email) and sends the accept link via the transactional email outbox. Requires `DASHBOARD_BASE_URL` — accept links are never derived from request headers. 409 when the email is already a member or already has a pending invite. ADR-081. requestBody: required: true content: application/json: schema: type: object required: [email] properties: email: type: string format: email responses: "201": description: Invitation created and email enqueued content: application/json: schema: $ref: "#/components/schemas/MemberInvitation" "409": description: Already a member, or a pending invitation exists content: application/json: schema: $ref: "#/components/schemas/Error" "422": description: Invalid email content: application/json: schema: $ref: "#/components/schemas/Error" /v1/members/invitations/{id}: delete: tags: [Members] summary: Revoke a pending invitation description: | Stamps the invitation revoked; its accept link stops working immediately (revoke racing an accept: exactly one wins). Only pending invitations can be revoked — accepted/revoked ones 404. parameters: - name: id in: path required: true schema: type: string responses: "204": description: Invitation revoked "404": description: No pending invitation with this id /v1/members/{user_id}: delete: tags: [Members] summary: Remove a member from the workspace description: | Deletes the membership AND revokes all of the user's sessions (sessions pin the tenant — without revocation an active session would keep full access). Guards: self-removal is blocked (another member must remove you) and the last member cannot be removed. ADR-081. parameters: - name: user_id in: path required: true schema: type: string responses: "204": description: Member removed and sessions revoked "404": description: User is not a member of this workspace "409": description: Self-removal or last-member removal blocked content: application/json: schema: $ref: "#/components/schemas/Error" /v1/whoami: get: tags: [Auth] summary: Resolve the current credential to its tenant context description: | Returns the resolved auth context for whichever credential the request carries — `velox_session` cookie (dashboard) or `Authorization: Bearer` (SDK / curl). Cookie path returns `user_id` + `email`; Bearer path returns `key_id` + `key_type`. Both always return `tenant_id` + `livemode`. responses: "200": description: Resolved auth context content: application/json: schema: type: object required: [tenant_id, livemode] properties: tenant_id: type: string livemode: type: boolean user_id: type: string email: type: string key_id: type: string key_type: type: string enum: [platform, secret, publishable] "401": description: Missing, revoked, or expired credential content: application/json: schema: $ref: "#/components/schemas/Error" /v1/billing/run: post: tags: [Billing] summary: Trigger billing cycle responses: "200": description: Billing cycle result content: application/json: schema: type: object properties: invoices_generated: type: integer errors: type: array items: type: string /v1/billing/preview/{subscription_id}: get: tags: [Billing] summary: Invoice preview (dry run) parameters: - name: subscription_id in: path required: true schema: type: string responses: "200": description: Invoice preview /v1/provider-costs: get: tags: [ProviderCosts] summary: List provider cost rates description: | The operator's COGS table (ADR-079): what THEY pay LLM providers per token, keyed by provider/model/token_type. Current-rate semantics — one row per key, edited in place; per-event stamps are the history. responses: "200": description: Rates content: application/json: schema: type: object properties: data: type: array items: $ref: "#/components/schemas/ProviderCostRate" put: tags: [ProviderCosts] summary: Create or update a provider cost rate requestBody: required: true content: application/json: schema: type: object required: [provider, model, token_type, cost_per_token] properties: provider: type: string model: type: string description: Model family token or raw snapshot id — raw ids match first at ingest. token_type: type: string description: ADR-044 role — input / output / cache_read. cost_per_token: type: string description: Decimal dollars per token (e.g. "0.000003"). currency: type: string default: USD responses: "200": description: Upserted rate content: application/json: schema: $ref: "#/components/schemas/ProviderCostRate" /v1/provider-costs/{id}: delete: tags: [ProviderCosts] summary: Delete a provider cost rate description: Stamped events keep their snapshot — deletion never rewrites history. parameters: - name: id in: path required: true schema: type: string responses: "204": description: Deleted /v1/customers/{id}/margin: get: tags: [ProviderCosts] summary: Per-customer margin report (operator only) description: | Rated usage revenue vs stamped provider COGS for a window (ADR-079). A usage unit-economics view — base fees, credits, and taxes are not included. Per-model margin renders only where pricing rules pin the model; other revenue is reported unattributed, never allocated by heuristic. parameters: - name: id in: path required: true schema: type: string - name: from in: query schema: type: string format: date-time - name: to in: query schema: type: string format: date-time responses: "200": description: Margin report content: application/json: schema: $ref: "#/components/schemas/MarginReport" /v1/credits/grant: post: tags: [Credits] summary: Grant credits to customer requestBody: required: true content: application/json: schema: type: object required: [customer_id, amount_cents] properties: customer_id: type: string amount_cents: type: integer format: int64 description: type: string expires_at: type: string format: date-time description: Optional expiry; must be in the future. Omitted = never expires. grant_kind: type: string enum: [promotional] description: | Optional cost-basis class (ADR-078). `promotional` marks free/marketing credits, drained BEFORE all paid-class blocks. Omitted = unclassified (drains in the paid class). `commit` is reserved for the invoice-finalize funding path and rejected here. responses: "201": description: Credits granted /v1/credits/balance/{customer_id}: get: tags: [Credits] summary: Get customer credit balance parameters: - name: customer_id in: path required: true schema: type: string responses: "200": description: Credit balance content: application/json: schema: $ref: "#/components/schemas/CreditBalance" /v1/test-clocks: get: tags: [TestClocks] summary: List test clocks for the active tenant description: | Test mode only — returns 403 in live mode. Test clocks let operators simulate time-bound billing flows without waiting for wall-clock advances. See ADR / docs for the simulator semantics. responses: "200": description: Test clocks content: application/json: schema: type: object properties: data: type: array items: { type: object } "403": description: Caller is in live mode content: application/json: schema: $ref: "#/components/schemas/Error" post: tags: [TestClocks] summary: Create a test clock description: | Test mode only. The clock starts in `ready` state at `frozen_time`. Use `/advance` to move it forward. requestBody: required: true content: application/json: schema: type: object required: [frozen_time] properties: name: type: string maxLength: 200 frozen_time: type: string format: date-time responses: "201": description: Clock created content: application/json: schema: { type: object } /v1/test-clocks/{id}: get: tags: [TestClocks] summary: Get a test clock by id parameters: - in: path name: id required: true schema: { type: string } responses: "200": description: Clock content: application/json: schema: { type: object } "404": description: Clock not found delete: tags: [TestClocks] summary: Delete a test clock (cascades to pinned subscriptions) parameters: - in: path name: id required: true schema: { type: string } responses: "200": description: Clock deleted /v1/test-clocks/{id}/subscriptions: get: tags: [TestClocks] summary: List subscriptions pinned to this clock parameters: - in: path name: id required: true schema: { type: string } responses: "200": description: Subscriptions content: application/json: schema: type: object properties: data: type: array items: { type: object } "404": description: Clock not found /v1/test-clocks/{id}/advance: post: tags: [TestClocks] summary: Advance the clock to a new frozen_time description: | Moves the clock forward and runs billing catchup asynchronously for every subscription pinned to this clock. The 200 returns as soon as the clock is marked `advancing` — a background worker drains the catchup; poll `GET /v1/test-clocks/{id}` until status leaves `advancing`. Concurrent advances 409 while catchup runs. Catchup failures park the clock in `internal_failure` — resume it with `POST /v1/test-clocks/{id}/retry-advance` (deletion is not required). ADR-018. parameters: - in: path name: id required: true schema: { type: string } requestBody: required: true content: application/json: schema: type: object required: [frozen_time] properties: frozen_time: type: string format: date-time responses: "200": description: Advance accepted — clock returned in `advancing` state content: application/json: schema: { type: object } "409": description: Clock is not in `ready` state "422": description: New frozen_time is not strictly after the current /v1/test-clocks/{id}/retry-advance: post: tags: [TestClocks] summary: Retry catchup on a clock parked in internal_failure description: | Resumes a clock parked in `internal_failure` after a prior catchup error. Catchup is idempotent (only subscriptions with `next_billing_at <= frozen_time` are processed), so resuming from where the previous attempt stopped is safe; `frozen_time` keeps the value stamped by the original advance. Asynchronous like advance: the 200 returns the clock back in `advancing`; poll `GET /v1/test-clocks/{id}` until status leaves `advancing`. ADR-018. parameters: - in: path name: id required: true schema: { type: string } responses: "200": description: Retry accepted — clock returned in `advancing` state content: application/json: schema: { type: object } "404": description: Clock not found "409": description: Clock is not in `internal_failure` state /v1/recipes: get: tags: [Recipes] summary: List recipes description: | Every recipe in the embedded registry, tagged with this tenant's installation state in the session's livemode. `instantiated` present means installed (the dashboard renders an "Installed" badge instead of the Install CTA); re-apply is an idempotent no-op and there is no uninstall (ADR-085). operationId: listRecipes responses: "200": description: Recipe catalog with per-tenant install state content: application/json: schema: type: object required: [data] properties: data: type: array items: $ref: "#/components/schemas/RecipeListItem" /v1/recipes/instances: get: tags: [Recipes] summary: List installed recipe instances operationId: listRecipeInstances responses: "200": description: Install badges for this tenant (session's livemode) content: application/json: schema: type: object required: [data] properties: data: type: array items: $ref: "#/components/schemas/RecipeInstance" /v1/recipes/{key}: get: tags: [Recipes] summary: Get recipe description: Registry only — no DB read, no install state. operationId: getRecipe parameters: - in: path name: key required: true schema: { type: string } responses: "200": description: Recipe template with creates summary content: application/json: schema: $ref: "#/components/schemas/RecipeDetail" "404": description: Unknown recipe key /v1/recipes/{key}/preview: post: tags: [Recipes] summary: Preview recipe description: | Renders the recipe with caller-supplied overrides and returns the would-be-created object graph. Pure in-memory — no DB writes, no audit row. operationId: previewRecipe parameters: - in: path name: key required: true schema: { type: string } requestBody: content: application/json: schema: type: object properties: overrides: type: object additionalProperties: true responses: "200": description: Rendered object graph content: application/json: schema: $ref: "#/components/schemas/RecipePreviewResult" "404": description: Unknown recipe key "422": description: Overrides failed the recipe's overridable schema /v1/recipes/{key}/instantiate: post: tags: [Recipes] summary: Instantiate recipe (idempotent apply) description: | Applies the recipe for the tenant under one transaction: rating rules and meters are adopted-or-created, pricing rules bound, a fresh born-unique plan generated, optional dunning policy and (inactive) webhook endpoint created, then the install badge written. Idempotent per (tenant, livemode): re-posting an installed recipe returns the EXISTING instance — 201 both times, never a 409, never a duplicate plan (ADR-085; badge livemode-scoped per m0157). operationId: instantiateRecipe parameters: - in: path name: key required: true schema: { type: string } requestBody: content: application/json: schema: type: object properties: overrides: type: object additionalProperties: true responses: "201": description: Install badge (fresh, or existing on idempotent re-apply) content: application/json: schema: $ref: "#/components/schemas/RecipeInstance" "404": description: Unknown recipe key "409": description: >- An existing meter with the same key diverges from the recipe (aggregation mismatch) — reconcile the meter before instantiating. "422": description: Overrides failed the recipe's overridable schema