This endpoint requires billing or admin roles. The Go API Gateway enforces RequireAnyRole("billing_admin", "finance", "tenant_admin", "platform_admin", "system_admin", "super_admin") on all /v1/billing/* routes.
Billing & Subscriptions API
Manage subscriptions, add-ons, discounts, invoices, refunds, dunning, and usage metering.
Overview
The Billing API is served at /v1/billing/* through the Go API Gateway. It provides subscription lifecycle management, add-on and discount CRUD, invoice recording, refund tracking, dunning management, and usage-based billing.
| Feature | Description |
|---|---|
| Subscriptions | Create, get, update, pause, and resume subscriptions |
| Add-ons | CRUD for subscription add-ons, attach/detach from subscriptions |
| Discounts | CRUD for discount codes, validate and apply to subscriptions |
| Invoices | Create and record invoices from Stripe |
| Refunds | List and record refunds |
| Dunning | Track payment failures and recovery |
| Usage | Record and query usage-based billing metrics |
| Events | Record and list subscription lifecycle events |
Subscriptions
Create Subscription
POST /v1/billing/subscriptions
Authorization: Bearer {access_token}
Content-Type: application/json
Request:
{
"plan_id": "uuid",
"stripe_subscription_id": "sub_...",
"current_period_end": "2026-02-28T00:00:00Z",
"cancel_at_period_end": false,
"status": "active"
}
| Field | Type | Required | Description |
|---|---|---|---|
plan_id | string (UUID) | Yes | Plan identifier |
stripe_subscription_id | string | Yes | Stripe subscription ID |
current_period_end | string (RFC3339) | Yes | End of current billing period |
cancel_at_period_end | boolean | No | Whether to cancel at period end |
status | string | Yes | One of: active, trialing, past_due, canceled, unpaid, incomplete, incomplete_expired, paused |
Get Subscription
GET /v1/billing/subscriptions/{subscription_id}
Authorization: Bearer {access_token}
| Parameter | Type | Description |
|---|---|---|
subscription_id | path (string) | Subscription identifier |
Update Subscription
PATCH /v1/billing/subscriptions/{subscription_id}
Authorization: Bearer {access_token}
Content-Type: application/json
Request (all fields optional):
{
"current_period_end": "2026-03-31T00:00:00Z",
"status": "active",
"plan_id": "uuid",
"cancel_at_period_end": true
}
Pause Subscription
POST /v1/billing/subscriptions/{subscription_id}/pause
Authorization: Bearer {access_token}
Proxied to the Rust Platform service.
Resume Subscription
POST /v1/billing/subscriptions/{subscription_id}/resume
Authorization: Bearer {access_token}
Proxied to the Rust Platform service.
Add-ons
Create Add-on
POST /v1/billing/addons
Authorization: Bearer {access_token}
Content-Type: application/json
Request:
{
"name": "Extra Location",
"description": "Additional restaurant location",
"addon_type": "per_location",
"price_cents": 9900,
"currency": "USD",
"stripe_price_id": "price_...",
"metadata_json": "{}"
}
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Add-on name (1-255 chars) |
description | string | No | Description (max 1000 chars) |
addon_type | string | Yes | One of: recurring, one_time, per_location |
price_cents | integer | Yes | Price in cents (min 0) |
currency | string | Yes | ISO 4217 currency code (3 chars) |
stripe_price_id | string | No | Stripe price ID |
metadata_json | string | No | JSON metadata |
List Add-ons
GET /v1/billing/addons?active_only=true
Authorization: Bearer {access_token}
| Parameter | Type | Description |
|---|---|---|
active_only | query (boolean) | Filter to only active add-ons |
Get Add-on
GET /v1/billing/addons/{addon_id}
Authorization: Bearer {access_token}
Update Add-on
PATCH /v1/billing/addons/{addon_id}
Authorization: Bearer {access_token}
Content-Type: application/json
Request (all fields optional):
{
"name": "Updated Name",
"description": "Updated description",
"price_cents": 14900,
"stripe_price_id": "price_...",
"is_active": true,
"metadata_json": "{}"
}
Delete Add-on
Soft-deletes the add-on by marking it inactive.
DELETE /v1/billing/addons/{addon_id}
Authorization: Bearer {access_token}
Returns 204 No Content on success.
Subscription Add-ons
Add Add-on to Subscription
POST /v1/billing/subscriptions/{subscription_id}/addons
Authorization: Bearer {access_token}
Content-Type: application/json
Request:
{
"addon_id": "uuid",
"quantity": 1
}
| Field | Type | Required | Description |
|---|---|---|---|
addon_id | string (UUID) | Yes | Add-on identifier |
quantity | integer | Yes | Quantity (min 1) |
List Subscription Add-ons
GET /v1/billing/subscriptions/{subscription_id}/addons
Authorization: Bearer {access_token}
Remove Add-on from Subscription
DELETE /v1/billing/subscriptions/{subscription_id}/addons/{addon_id}
Authorization: Bearer {access_token}
Returns 204 No Content on success.
Discounts
Create Discount
POST /v1/billing/discounts
Authorization: Bearer {access_token}
Content-Type: application/json
Request:
{
"code": "SUMMER2026",
"name": "Summer Promotion",
"description": "20% off for summer",
"discount_type": "percentage",
"discount_value": 20,
"duration": "repeating",
"duration_months": 3,
"max_redemptions": 100,
"valid_from": "2026-06-01T00:00:00Z",
"valid_to": "2026-09-01T00:00:00Z",
"applies_to_plan_ids": ["uuid1", "uuid2"],
"stripe_coupon_id": "coupon_..."
}
| Field | Type | Required | Description |
|---|---|---|---|
code | string | Yes | Discount code (1-100 chars) |
name | string | Yes | Display name (1-255 chars) |
description | string | No | Description (max 1000 chars) |
discount_type | string | Yes | One of: percentage, fixed_amount |
discount_value | integer | Yes | Discount value (min 1) |
duration | string | Yes | One of: once, repeating, forever |
duration_months | integer | No | Months for repeating duration (min 1) |
max_redemptions | integer | No | Maximum redemptions (min 1) |
valid_from | string (RFC3339) | Yes | Start date |
valid_to | string (RFC3339) | No | End date |
applies_to_plan_ids | array of UUIDs | No | Restrict to specific plans |
stripe_coupon_id | string | No | Stripe coupon ID |
List Discounts
GET /v1/billing/discounts?active_only=true
Authorization: Bearer {access_token}
| Parameter | Type | Description |
|---|---|---|
active_only | query (boolean) | Filter to only active discounts |
Get Discount
GET /v1/billing/discounts/{discount_id}
Authorization: Bearer {access_token}
Get Discount by Code
GET /v1/billing/discounts/by-code?code=SUMMER2026
Authorization: Bearer {access_token}
| Parameter | Type | Required | Description |
|---|---|---|---|
code | query (string) | Yes | Discount code to look up |
Validate Discount
Validates a discount code and returns the calculated discount amount.
POST /v1/billing/discounts/validate
Authorization: Bearer {access_token}
Content-Type: application/json
Request:
{
"code": "SUMMER2026",
"plan_id": "uuid",
"cart_total_cents": 29900
}
| Field | Type | Required | Description |
|---|---|---|---|
code | string | Yes | Discount code (1-100 chars) |
plan_id | string (UUID) | No | Plan to validate against |
cart_total_cents | integer | Yes | Cart total in cents (min 0) |
Update Discount
PATCH /v1/billing/discounts/{discount_id}
Authorization: Bearer {access_token}
Content-Type: application/json
Request (all fields optional):
{
"name": "Updated Name",
"description": "Updated description",
"max_redemptions": 200,
"valid_to": "2026-12-31T00:00:00Z",
"is_active": false
}
Delete Discount
Soft-deletes the discount by marking it inactive.
DELETE /v1/billing/discounts/{discount_id}
Authorization: Bearer {access_token}
Returns 204 No Content on success.
Subscription Discounts
Apply Discount to Subscription
POST /v1/billing/subscriptions/{subscription_id}/discount
Authorization: Bearer {access_token}
Content-Type: application/json
Request:
{
"discount_code": "SUMMER2026"
}
Remove Discount from Subscription
DELETE /v1/billing/subscriptions/{subscription_id}/discount
Authorization: Bearer {access_token}
Returns 204 No Content on success.
Invoices
Create Invoice
POST /v1/billing/invoices
Authorization: Bearer {access_token}
Content-Type: application/json
Request:
{
"stripe_invoice_id": "in_...",
"subscription_id": "uuid",
"amount_due": 29900,
"amount_paid": 0,
"currency": "USD",
"status": "open",
"invoice_pdf": "https://...",
"hosted_invoice_url": "https://..."
}
| Field | Type | Required | Description |
|---|---|---|---|
stripe_invoice_id | string | Yes | Stripe invoice ID |
subscription_id | string (UUID) | No | Associated subscription |
amount_due | integer | Yes | Amount due in cents (min 0) |
amount_paid | integer | No | Amount paid in cents (min 0) |
currency | string | Yes | ISO 4217 currency code (3 chars) |
status | string | Yes | One of: draft, open, paid, void, uncollectible |
invoice_pdf | string (URL) | No | PDF download URL |
hosted_invoice_url | string (URL) | No | Hosted invoice page URL |
Record Invoice (Webhook)
Records or updates an invoice from Stripe webhook events. Uses the same request body as Create Invoice.
POST /v1/billing/invoices/record
Authorization: Bearer {access_token}
Content-Type: application/json
Refunds
List Refunds
GET /v1/billing/refunds?limit=100
Authorization: Bearer {access_token}
| Parameter | Type | Description |
|---|---|---|
limit | query (integer) | Maximum number of refunds to return (default: 100) |
Record Refund
POST /v1/billing/refunds
Authorization: Bearer {access_token}
Content-Type: application/json
Request:
{
"stripe_refund_id": "re_...",
"stripe_charge_id": "ch_...",
"invoice_id": "uuid",
"amount": 2990,
"currency": "USD",
"status": "succeeded",
"reason": "requested_by_customer",
"description": "Customer requested refund"
}
| Field | Type | Required | Description |
|---|---|---|---|
stripe_refund_id | string | Yes | Stripe refund ID |
stripe_charge_id | string | Yes | Stripe charge ID |
invoice_id | string (UUID) | No | Associated invoice |
amount | integer | Yes | Refund amount in cents (min 1) |
currency | string | Yes | ISO 4217 currency code (3 chars) |
status | string | Yes | One of: pending, succeeded, failed, canceled |
reason | string | No | One of: duplicate, fraudulent, requested_by_customer |
description | string | No | Description (max 1000 chars) |
Dunning
Dunning management tracks payment failures and recovery for subscriptions.
Get Dunning State
GET /v1/billing/subscriptions/{subscription_id}/dunning
Authorization: Bearer {access_token}
| Parameter | Type | Description |
|---|---|---|
subscription_id | path (UUID) | Subscription identifier |
Record Payment Failure
POST /v1/billing/subscriptions/{subscription_id}/dunning/failure
Authorization: Bearer {access_token}
Content-Type: application/json
Request:
{
"stripe_subscription_id": "sub_...",
"failure_code": "card_declined",
"failure_message": "Your card was declined"
}
| Field | Type | Required | Description |
|---|---|---|---|
stripe_subscription_id | string | Yes | Stripe subscription ID |
failure_code | string | No | Stripe failure code |
failure_message | string | No | Human-readable failure message |
Mark Dunning Recovered
POST /v1/billing/subscriptions/{subscription_id}/dunning/recovered
Authorization: Bearer {access_token}
Usage
Record Usage
POST /v1/billing/usage
Authorization: Bearer {access_token}
Content-Type: application/json
Request:
{
"subscription_id": "uuid",
"metric_name": "ai_tokens",
"quantity": 1500,
"timestamp": "2026-02-18T12:00:00Z",
"idempotency_key": "unique-key-123"
}
| Field | Type | Required | Description |
|---|---|---|---|
subscription_id | string (UUID) | Yes | Subscription identifier |
metric_name | string | Yes | Metric name (1-100 chars) |
quantity | integer | Yes | Usage quantity (min 1) |
timestamp | string (RFC3339) | Yes | Timestamp of usage |
idempotency_key | string | Yes | Unique key for deduplication (1-255 chars) |
Get Usage Summary
GET /v1/billing/subscriptions/{subscription_id}/usage?metric_name=ai_tokens&start_time=2026-02-01T00:00:00Z&end_time=2026-02-28T23:59:59Z
Authorization: Bearer {access_token}
| Parameter | Type | Required | Description |
|---|---|---|---|
subscription_id | path (UUID) | Yes | Subscription identifier |
metric_name | query (string) | Yes | Metric name |
start_time | query (RFC3339) | Yes | Start of time range |
end_time | query (RFC3339) | Yes | End of time range |
Subscription Events
List Subscription Events
GET /v1/billing/events?subscription_id=uuid&limit=100
Authorization: Bearer {access_token}
| Parameter | Type | Required | Description |
|---|---|---|---|
subscription_id | query (UUID) | No | Filter by subscription |
limit | query (integer) | No | Maximum events (default: 100) |
Record Subscription Event
POST /v1/billing/events
Authorization: Bearer {access_token}
Content-Type: application/json
Request:
{
"subscription_id": "uuid",
"event_type": "upgraded",
"from_plan_id": "uuid",
"to_plan_id": "uuid",
"from_mrr_cents": 9900,
"to_mrr_cents": 29900,
"currency": "USD",
"reason": "Customer requested upgrade",
"event_at": "2026-02-18T15:00:00Z"
}
| Field | Type | Required | Description |
|---|---|---|---|
subscription_id | string (UUID) | Yes | Subscription identifier |
event_type | string | Yes | One of: created, activated, upgraded, downgraded, paused, resumed, canceled, churned, reactivated |
from_plan_id | string | No | Previous plan ID |
to_plan_id | string | No | New plan ID |
from_mrr_cents | integer | No | Previous MRR in cents |
to_mrr_cents | integer | No | New MRR in cents |
currency | string | Yes | ISO 4217 currency code (3 chars) |
reason | string | No | Reason for the event (max 1000 chars) |
event_at | string (RFC3339) | Yes | When the event occurred |
Error Responses
Validation Error (400)
{
"error": "validation_error",
"message": "addon_id must be a valid UUID"
}
Missing Tenant (401)
Returned when the JWT does not contain a tenant_id.
{
"error": "missing_tenant",
"message": "tenant_id not found in context"
}
Upstream Error (502)
Returned when the Rust Platform service is unavailable or returns an error.
Related Documentation
- Tenant Management - Tenant operations
- Feature Gating - Plan-based features