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/sagarsuperuser/velox/blob/main/LICENSE contact: name: Velox url: https://github.com/sagarsuperuser/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 Customer: type: object properties: id: type: string example: vlx_cus_42014438d82ddf33 external_id: type: string display_name: type: string email: type: string 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 - overdue - payment_processing - payment_scheduled - awaiting_payment - no_payment_method 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. 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 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. 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`, `payment_overdue`). 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`, `lifecycle.overdue`). 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 - payment_overdue - 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" 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 tenant timezone (ADR-058). Computed on read; the raw billing_period_start/end stay half-open. Empty/omitted for one-off invoices with no period. 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 payment_overdue: type: boolean 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`) populated by `velox-import` for invoices imported from a Stripe account. 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 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 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 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 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-031) 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-031) 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 "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/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, plan_id] properties: code: type: string display_name: type: string customer_id: type: string plan_id: type: string 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: [customer_id, meter_id, quantity] properties: customer_id: type: string meter_id: type: string subscription_id: type: string quantity: type: integer format: int64 properties: type: object idempotency_key: type: string timestamp: type: string format: date-time responses: "201": description: Event ingested get: tags: [Usage] summary: List usage events responses: "200": description: Event list /v1/usage-events/batch: post: tags: [Usage] summary: Batch ingest usage events (max 1000) requestBody: required: true content: application/json: schema: type: array items: type: object required: [customer_id, meter_id, quantity] properties: customer_id: type: string meter_id: type: string quantity: type: integer responses: "201": description: All events ingested "206": description: Partial success /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?" using the same line-set the cycle scan would emit if billing fired right now — so dashboard projected-bill matches the eventual finalized invoice. 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}/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: Account locked after too many failed attempts (15-min cooldown) 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 204 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 delivered out-of-band (SMTP delivery deferred — currently logged to stdout). requestBody: required: true content: application/json: schema: type: object required: [email] properties: email: type: string format: email responses: "204": description: Request accepted (regardless of email existence) /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/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/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 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 synchronously runs billing catchup for every subscription pinned to this clock. While catchup runs, the clock is in `advancing` state and concurrent advances 409. Catchup failures flip the clock to `internal_failure` — operator must inspect and delete to unstick. 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: Clock advanced 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