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 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 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: integer format: int64 graduated_tiers: type: array items: type: object properties: up_to: type: integer unit_amount_cents: type: integer 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, active, paused, 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] description: Lifecycle state of the invoice. 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 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 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, 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, apply-coupon, 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_bp - 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_bp: type: integer format: int64 description: Tax rate in basis points (1850 = 18.50%). 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 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. 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_bp - 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 unit_amount_cents: type: integer format: int64 amount_cents: type: integer format: int64 tax_rate_bp: type: integer format: int64 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: limit in: query schema: type: integer - name: offset in: query schema: type: integer responses: "200": description: Customer list /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 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: integer graduated_tiers: type: array items: type: object properties: up_to: type: integer unit_amount_cents: type: integer package_size: type: integer package_amount_cents: type: integer 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 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}/change-plan: post: tags: [Subscriptions] summary: Change subscription plan parameters: - name: id in: path required: true schema: type: string requestBody: required: true content: application/json: schema: type: object required: [new_plan_id] properties: new_plan_id: type: string immediate: type: boolean responses: "200": description: Plan changed /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 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/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/dunning/policy: get: tags: [Dunning] summary: Get dunning policy responses: "200": description: Dunning policy put: tags: [Dunning] summary: Configure dunning policy requestBody: required: true content: application/json: schema: type: object properties: name: type: string enabled: type: boolean retry_schedule: type: array items: type: string max_retry_attempts: type: integer final_action: type: string enum: [manual_review, pause, write_off_later] responses: "200": description: Policy updated /v1/webhooks/stripe: post: tags: [Webhooks] summary: Stripe webhook receiver security: [] description: Receives Stripe webhook events. Verified via Stripe-Signature header. responses: "200": description: Event processed /v1/metrics: get: tags: [System] summary: Prometheus metrics security: [] responses: "200": description: Prometheus metrics content: text/plain: {}