Subscriptions API
The subscription API manages the relationship between tenants and plans. All tenant-scoped endpoints require authentication, tenant membership, and completed onboarding.
Base path: /api/v1/tenant/{tenantId}/...Middleware: auth:sanctum → tenant.resolve → tenant.member → onboarding.complete
Subscription States
Every subscription has a status that determines what actions are available and whether the tenant has access to plan features.
| Status | Feature Access | Can Change Plan | Can Cancel | Description |
|---|---|---|---|---|
active | Yes | Yes | Yes | Subscription is current and paid |
trialing | Yes | Yes | Yes | Within trial period, no payment yet |
past_due | No | Yes | Yes | Payment failed, awaiting retry |
canceled | No | No | No | Subscription has ended |
unpaid | No | No | No | Payment repeatedly failed |
paused | No | No | No | Subscription is paused |
incomplete | No | No | No | Initial payment not completed |
incomplete_expired | No | No | No | Initial payment window expired |
active and trialing subscriptions grant access to plan features. The past_due status allows plan changes (to let users fix their situation) but does not grant feature access.These states are defined in backend/app/Domain/Billing/Enums/SubscriptionStatus.php.
Get Current Subscription
GET /api/v1/tenant/{tenantId}/subscription
Returns the tenant's current subscription, or null if none exists.
Auth: Bearer token + tenant member + onboarding complete
Response (200) — with subscription:
{
"data": {
"id": "aab14200-1234-4567-89ab-cdef01234567",
"status": "active",
"plan": {
"id": "660e8400-e29b-41d4-a716-446655440001",
"name": "Pro",
"description": "For growing teams",
"pricing_type": "seat",
"billing_cycle": "monthly",
"interval_unit": "month",
"interval_count": 1,
"trial_days": 14,
"prices": [
{ "currency": "EUR", "price_cents": 2999 }
],
"features": [
{ "code": "team-members", "name": "Team Members", "type": "quota", "value": 25 }
]
},
"currency": "EUR",
"price_cents": 2999,
"quantity": 5,
"interval_unit": "month",
"interval_count": 1,
"current_period_start": "2026-03-01T00:00:00.000000Z",
"current_period_end": "2026-04-01T00:00:00.000000Z",
"trial_ends_at": null,
"cancel_at_period_end": false,
"canceled_at": null,
"cancellation_reason": null,
"created_at": "2026-01-15T10:00:00.000000Z",
"updated_at": "2026-03-01T00:00:00.000000Z"
}
}
Response (200) — no subscription:
{
"data": null
}
Subscription fields:
| Field | Type | Description |
|---|---|---|
id | UUID | Subscription identifier |
status | enum | See Subscription States |
plan | object | Nested plan with prices and features |
currency | string | ISO 4217 currency code (immutable after creation) |
price_cents | int | Price per unit in the subscription's currency |
quantity | int | Number of units (seats for seat-based plans) |
interval_unit | enum | day, week, month, year |
interval_count | int | Number of interval units per billing period |
current_period_start | datetime | Start of current billing period |
current_period_end | datetime | End of current billing period |
trial_ends_at | datetime|null | End of trial period, if applicable |
cancel_at_period_end | boolean | Whether cancellation is scheduled |
canceled_at | datetime|null | When cancellation was requested |
cancellation_reason | string|null | User-provided cancellation reason |
List Available Plans
GET /api/v1/tenant/{tenantId}/plans
Returns plans available for the tenant, including eligibility information based on the tenant's current usage.
Auth: Bearer token + tenant member + onboarding complete
Query Parameters:
| Param | Type | Description |
|---|---|---|
currency | string (3 chars) | Currency for prices. Defaults to the tenant's preferred currency. |
Response (200): Array of PlanResource objects. Each plan includes prices, features, and an eligibility object:
{
"data": [
{
"id": "660e8400-...",
"name": "Pro",
"pricing_type": "seat",
"billing_cycle": "monthly",
"interval_unit": "month",
"interval_count": 1,
"trial_days": 14,
"prices": [{ "currency": "EUR", "price_cents": 2999 }],
"features": [
{ "code": "team-members", "name": "Team Members", "type": "quota", "value": 25 }
],
"eligibility": { "selectable": true, "violations": [] }
},
{
"id": "660e8400-...",
"name": "Starter",
"pricing_type": "flat",
"prices": [{ "currency": "EUR", "price_cents": 999 }],
"features": [
{ "code": "team-members", "name": "Team Members", "type": "quota", "value": 3 }
],
"eligibility": {
"selectable": false,
"violations": [
{ "feature_code": "team-members", "current_usage": 8, "plan_limit": 3 }
]
}
}
]
}
A plan is not selectable if the tenant's current usage exceeds the plan's quota limits — violations details which features block the switch.
Create Checkout Session
POST /api/v1/tenant/{tenantId}/checkout
Creates a Stripe Checkout session for subscribing to a plan. Returns a URL to redirect the user to Stripe's hosted checkout page.
Auth: Bearer token + tenant member + onboarding complete
Request Body:
{
"plan_id": "660e8400-e29b-41d4-a716-446655440001",
"currency": "EUR",
"success_url": "https://app.example.com/dashboard?checkout=success",
"cancel_url": "https://app.example.com/dashboard?checkout=canceled",
"quantity": 5
}
| Field | Type | Required | Validation |
|---|---|---|---|
plan_id | UUID | Yes | Must reference an existing plan |
currency | string | Yes | 3-char ISO code, must exist in currencies table |
success_url | URL | Yes | Redirect URL after successful payment |
cancel_url | URL | Yes | Redirect URL if user cancels checkout |
quantity | int | No | Number of seats. Defaults to 1. Only relevant for seat-based plans. |
Response (200):
{
"data": {
"session_id": "cs_test_a1b2c3d4e5f6g7h8i9j0",
"url": "https://checkout.stripe.com/c/pay/cs_test_a1b2c3d4e5f6g7h8i9j0"
}
}
Error Responses:
| Code | Status | Description |
|---|---|---|
billing_not_ready | 422 | Tenant is missing required billing fields (returned with missing_fields array) |
seat_pricing_misconfigured | 422 | Seat-based plan has no price in the requested currency |
After redirect, Stripe triggers webhooks that activate the subscription. See Stripe Integration for the full checkout flow.
Cancel Subscription
POST /api/v1/tenant/{tenantId}/subscription/cancel
Cancel the current subscription. By default, cancellation takes effect at the end of the current billing period.
Auth: Bearer token + tenant member + onboarding complete
Request Body:
{
"reason": "Switching to a competitor",
"immediately": false
}
| Field | Type | Required | Description |
|---|---|---|---|
reason | string | No | Optional cancellation reason (max 500 chars) |
immediately | boolean | No | If true, cancel now. If false (default), cancel at period end. |
Response (200): Updated SubscriptionResource with cancel_at_period_end: true (or status: "canceled" if immediate).
Response (404): If no active subscription exists.
immediately: false, the subscription remains active until current_period_end. The user can resume before that date. When immediately: true, access is revoked immediately and the subscription cannot be resumed.Resume Subscription
POST /api/v1/tenant/{tenantId}/subscription/resume
Resume a subscription that was scheduled for cancellation (i.e., cancel_at_period_end: true).
Auth: Bearer token + tenant member + onboarding complete
Request Body: None
Response (200): Updated SubscriptionResource with cancel_at_period_end: false and canceled_at: null.
Response (404): If no active subscription exists.
This only works when the subscription has cancel_at_period_end: true. If the subscription is already fully canceled (past the period end), it cannot be resumed — the user must create a new subscription via checkout.
Preview Plan Change
GET /api/v1/tenant/{tenantId}/subscription/preview-change
Preview the proration cost of changing plans. Returns credit/charge amounts without making any changes.
Auth: Bearer token + tenant member + onboarding complete
Query Parameters:
| Param | Type | Required | Description |
|---|---|---|---|
new_plan_id | UUID | Yes | Target plan to switch to |
quantity | int | No | New seat count (for seat-based plans) |
Response (200):
{
"data": {
"credit": {
"cents": 1500,
"currency": "EUR"
},
"charge": {
"cents": 3000,
"currency": "EUR"
},
"net": {
"cents": 1500,
"currency": "EUR"
},
"breakdown": {
"method": "calendar_day",
"currency": "EUR",
"period_start": "2026-03-01",
"period_end": "2026-03-31",
"days_remaining": 15,
"total_days": 31
}
}
}
| Field | Type | Description |
|---|---|---|
credit | money | Amount credited for unused time on the current plan |
charge | money | Amount charged for the new plan's remaining period |
net | money | Net amount the user will pay (charge minus credit) |
breakdown | object | Detailed proration calculation |
cents as the money key (not amount_cents). This is the only endpoint with this inconsistency.Response (404): If no active subscription exists.
For more on how proration is calculated, see Subscriptions & Lifecycle.
Change Plan
POST /api/v1/tenant/{tenantId}/subscription/change-plan
Execute a plan change. Returns different response shapes depending on the transition type.
Auth: Bearer token + tenant member + onboarding complete
Request Body:
{
"new_plan_id": "660e8400-e29b-41d4-a716-446655440020",
"quantity": 10,
"success_url": "https://app.example.com/dashboard?change=success",
"cancel_url": "https://app.example.com/dashboard?change=canceled"
}
| Field | Type | Required | Description |
|---|---|---|---|
new_plan_id | UUID | Yes | Target plan |
quantity | int | No | New seat count |
success_url | URL | No | Required only for free-to-paid transitions |
cancel_url | URL | No | Required only for free-to-paid transitions |
Response: Direct Update (Paid-to-Paid or Paid-to-Free)
When changing between paid plans or downgrading to free, the change is applied directly via Stripe:
{
"data": {
"action": "updated",
"subscription": {
"id": "aab14200-1234-4567-89ab-cdef01234567",
"status": "active",
"plan": { ... },
"currency": "EUR",
"price_cents": 4999,
"quantity": 10,
...
}
}
}
Response: Checkout Required (Free-to-Paid)
When switching from a free plan to a paid plan, the user must complete a Stripe Checkout:
{
"data": {
"action": "checkout_required",
"checkout_url": "https://checkout.stripe.com/c/pay/cs_test_xyz",
"session_id": "cs_test_xyz"
}
}
Redirect the user to checkout_url. After successful payment, Stripe webhooks activate the new subscription.
Domain Error Codes
| Code | Status | Description |
|---|---|---|
subscription_cannot_be_upgraded | 422 | Subscription status does not allow plan changes |
proration_not_supported | 422 | Proration cannot be calculated for this transition |
plan_not_available_in_currency | 422 | Target plan has no price in the subscription's currency |
billing_not_ready | 422 | Tenant missing required billing fields (returned with missing_fields) |
plan_change_error | 422 | Generic plan change failure |
Billing Portal
POST /api/v1/tenant/{tenantId}/docs/billing/portal
Create a Stripe Billing Portal session for managing payment methods, viewing invoices, and updating billing details.
Auth: Bearer token + billing.manage permission (or platform admin)
Request Body:
{
"return_url": "https://app.example.com/dashboard/docs/settings/docs/billing"
}
| Field | Type | Required | Description |
|---|---|---|---|
return_url | URL | No | Where to redirect after the portal session. Defaults to the billing settings page. Must be on the configured frontend domain. |
Response (200):
{
"data": {
"url": "https://docs/billing.stripe.com/p/session/test_YWNjd"
}
}
Redirect the user to url. The portal session is short-lived.
Error (422): Returns an error if the tenant does not have a Stripe customer record (e.g., never completed checkout).
Seat Billing
GET /api/v1/tenant/{tenantId}/subscription/seats
Returns seat billing details for tenants on seat-based plans.
Auth: Bearer token + tenant member + onboarding complete
Response (200):
{
"data": {
"current_seats": 8,
"seat_limit": 25,
"price_per_seat": {
"amount_cents": 2999,
"currency": "EUR"
},
"total_seat_cost": {
"amount_cents": 23992,
"currency": "EUR"
},
"subscription_quantity": 8,
"available_seats": 17,
"is_seat_based": true,
"is_synced": true
}
}
| Field | Type | Description |
|---|---|---|
current_seats | int | Current number of team members |
seat_limit | int|null | Maximum seats allowed by the plan (null = unlimited) |
price_per_seat | money | Unit price per seat |
total_seat_cost | money | Total cost for all occupied seats |
subscription_quantity | int | Quantity on the Stripe subscription |
available_seats | int | Remaining seats before hitting the limit |
is_seat_based | boolean | Whether the current plan uses seat-based pricing |
is_synced | boolean | Whether the subscription quantity matches the actual seat count |
Response (404): If no active subscription exists.
flat or usage pricing types, is_seat_based will be false and the seat-related fields will reflect defaults.For more on how seat billing integrates with team management, see Seat-Based Billing Integration.
Tenant Entitlements
GET /api/v1/tenant/{tenantId}/entitlements
Returns the tenant's resolved entitlements based on their active subscription's plan.
Auth: Bearer token + tenant member + onboarding complete
Response (200):
{
"data": [
{
"key": "team-members",
"enabled": true,
"type": "quota",
"limit": 25,
"used": null,
"remaining": null
},
{
"key": "priority-support",
"enabled": true,
"type": "boolean",
"limit": null,
"used": null,
"remaining": null
}
]
}
| Field | Type | Description |
|---|---|---|
key | string | Feature code (stable identifier) |
enabled | boolean | Always true for active entitlements |
type | enum | boolean or quota |
limit | int|null | Quota limit (null for boolean entitlements or unlimited quotas) |
used | int|null | Current usage count (reserved for future implementation) |
remaining | int|null | Remaining quota (reserved for future implementation) |
Returns an empty data array if the tenant has no active subscription.
Onboarding Flow
The onboarding endpoints handle the registration-to-checkout flow for new tenants. They are separate from the standard subscription endpoints because they combine user registration, tenant creation, and subscription checkout into a single flow.
POST /api/v1/onboarding/start
Start the onboarding process: register a user, create a tenant, and initiate Stripe Checkout.
Auth: None (public endpoint) Rate limit: 5/minute
Request Body:
{
"name": "John Doe",
"email": "john@example.com",
"password": "securepassword",
"password_confirmation": "securepassword",
"plan_slug": "pro-monthly",
"currency": "EUR",
"tenant_name": "Acme Corp",
"quantity": 5
}
| Field | Type | Required | Validation |
|---|---|---|---|
name | string | Yes | Max 255 |
email | string | Yes | Valid email, unique |
password | string | Yes | Min 8 chars, must match password_confirmation |
plan_slug | string | Yes | Must reference an existing plan slug |
currency | string | Yes | 3-char ISO code, must exist in currencies table |
tenant_name | string | No | Custom tenant name (defaults to user name) |
quantity | int | No | Seat count (defaults to 1) |
Response (201):
{
"user": { "id": 42, "name": "John Doe", "email": "john@example.com", ... },
"tenant": { "id": "550e8400-...", "name": "Acme Corp", "slug": "acme-corp", ... },
"checkout_url": "https://checkout.stripe.com/c/pay/cs_test_a1b2c3d4"
}
{"data": ...} envelope. The user, tenant, and checkout_url keys are at the top level.In SPA mode, a session cookie is set. In API mode, accessToken and refreshToken fields are included. Redirect the user to checkout_url to complete payment.
Error (422): PLAN_NOT_AVAILABLE_IN_CURRENCY — the selected plan has no price in the requested currency (includes available_currencies array).
GET /api/v1/onboarding/status
Check onboarding progress for the authenticated tenant.
Auth: Bearer token + tenant context Rate limit: 10/minute
Response (200): Returns tenant_id, onboarding_completed, has_subscription, subscription_status, plan_name, has_billing_details, and billing_details (legal_name, address, city, postal_code, country, vat_number, billing_email).
Use this to determine which step the user is on: if has_subscription is false, they need checkout; if has_billing_details is false, they need billing information.
POST /api/v1/onboarding/retry-checkout
Create a new checkout session for a tenant that didn't complete initial payment.
Auth: Bearer token + tenant context Rate limit: 10/minute
Request Body: {plan_slug, currency, quantity?} — same currency/plan validation as start.
Response (200): {"data": {"checkout_url": "https://checkout.stripe.com/..."}}.
Error (422): If the tenant already has a subscription, or the plan is unavailable in the currency.
PATCH /api/v1/onboarding/complete-setup
Provide billing details and mark onboarding complete.
Auth: Bearer token + tenant context Rate limit: 10/minute
Request Body: All fields optional — legal_name (max 255), address (max 255), city (max 255), postal_code (max 20), country (ISO 2-char), vat_number (max 50), billing_email (email, max 255).
Response (200): Updated TenantResource.
Admin Subscription Endpoints
Platform administrators can view and manage tenant subscriptions. All require platform.admin middleware.
| Method | Path | Description |
|---|---|---|
| GET | /api/v1/admin/tenants/{tenant}/subscriptions | List all subscriptions for a tenant |
| POST | /api/v1/admin/tenants/{tenant}/subscriptions/{subscription}/cancel | Cancel a subscription |
The cancel endpoint accepts {reason?, immediately?} — same as the tenant cancel endpoint. Returns updated SubscriptionResource.
Error (400): SUBSCRIPTION_CANNOT_BE_CANCELED if the status does not allow cancellation.
What's Next
- Invoices API — Invoice listing and PDF download
- Usage & Meters API — Usage tracking and quota enforcement
- Subscriptions & Lifecycle — Full subscription lifecycle and state transitions
- Currency Rules — Multi-currency architecture and invariants