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 billing_failure: $ref: "#/components/schemas/BillingFailure" BillingFailure: type: object required: [attempts, max_attempts, last_error, failed_at, parked] description: | Present on a subscription while the billing run is failing on the work that subscription has due now (a period close, or a scheduled mid-period cancel). Absent otherwise: a subscription that bills cleanly, or whose due work changed, carries no `billing_failure`. After each failed attempt the billing run waits before the next one: 15 minutes, 1 hour, 4 hours, 12 hours, then 24 hours three times. The eighth failure parks the subscription (`parked: true`, no `next_attempt_at`). A parked subscription is not attempted again until `POST /v1/subscriptions/{id}/retry-billing` is called. Never set on a subscription attached to a test clock. properties: attempts: type: integer description: Failed attempts on the work that is due now. At least 1. max_attempts: type: integer description: The attempt at which the subscription parks (8). last_error: type: string description: | Why the latest attempt failed: a sentence an operator can act on, or a generic line carrying the subscription id as the reference to search the server log for. Never raw error text. failed_at: type: string format: date-time description: When the latest attempt failed. next_attempt_at: type: string format: date-time description: | The billing run does not attempt the subscription before this instant. The attempt happens on the first run at or after it, so on an hourly schedule it can be up to an hour later. Absent when parked. parked: type: boolean description: True when automatic attempts have stopped. 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.example.com/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: billing in: query description: | `failing` returns only subscriptions that carry a `billing_failure`: those waiting for their next billing attempt and those that are parked. Any other value is a 400. schema: type: string enum: [failing] - 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/subscriptions/{id}/retry-billing: post: tags: [Subscriptions] summary: Retry billing on a subscription whose billing is failing description: | Clears the subscription's `billing_failure` and bills it now, with the same attempt the billing run makes. Use it after fixing the cause of a failure, to skip the wait, or to restart a parked subscription. An audit log entry records the retry. Once the failure is cleared, the outcome of the attempt is never an HTTP error. A 200 has three outcomes: - **Billed.** No `billing_failure`, and `still_due` is false. A subscription that was overdue for several periods is billed for each of them, one invoice per period. - **Failed again.** The subscription carries a new `billing_failure` with `attempts: 1`, and the billing run then retries on its own schedule. This includes an attempt that finished without an error but left the subscription due. - **Not completed.** No `billing_failure`, and `still_due` is true. The attempt was cut short by a lost database connection or a deadlock, and nothing was saved. The subscription is still unbilled, and the next billing run attempts it. Calling this endpoint again answers 409, because the failure is already cleared. The call bills inline. If it times out part-way through a long catch-up, the response is an error, the invoices already created stay, and the next billing run finishes the rest. The failure may already be cleared at that point, so a repeat call can answer 409. parameters: - name: id in: path required: true schema: type: string responses: "200": description: | The retry ran. Read the outcome from two fields. `subscription.billing_failure` is present when the attempt failed again. When it is absent, `still_due` false means the subscription billed or had nothing left to bill, and `still_due` true means the attempt did not complete and the subscription is still unbilled. content: application/json: schema: type: object required: [subscription, invoices_generated] properties: subscription: $ref: "#/components/schemas/Subscription" invoices_generated: type: integer description: | Period-close invoices this call created. The final invoice of a scheduled cancel and the first invoice at a trial end are not counted, so 0 does not mean the retry failed. Read success from `subscription.billing_failure` and `still_due`. still_due: type: boolean description: | True when, after the attempt, the subscription has no `billing_failure` and is still due for billing. The attempt did not complete and nothing was saved; the next billing run attempts the subscription. "404": description: Subscription not found. "409": description: | The retry was refused. Code `billing_not_failing`: the subscription has no `billing_failure`, so there is nothing to retry. Code `invalid_state`: the subscription is attached to a test clock; use the clock's retry advance instead. /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