Skip to content
SaaS4Builders
API Reference

Subscriptions API

Tenant subscription endpoints: checkout, cancellation, plan changes, proration preview, billing portal, seats, entitlements, and onboarding flow.

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:sanctumtenant.resolvetenant.memberonboarding.complete


Subscription States

Every subscription has a status that determines what actions are available and whether the tenant has access to plan features.

StatusFeature AccessCan Change PlanCan CancelDescription
activeYesYesYesSubscription is current and paid
trialingYesYesYesWithin trial period, no payment yet
past_dueNoYesYesPayment failed, awaiting retry
canceledNoNoNoSubscription has ended
unpaidNoNoNoPayment repeatedly failed
pausedNoNoNoSubscription is paused
incompleteNoNoNoInitial payment not completed
incomplete_expiredNoNoNoInitial payment window expired
Only 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:

FieldTypeDescription
idUUIDSubscription identifier
statusenumSee Subscription States
planobjectNested plan with prices and features
currencystringISO 4217 currency code (immutable after creation)
price_centsintPrice per unit in the subscription's currency
quantityintNumber of units (seats for seat-based plans)
interval_unitenumday, week, month, year
interval_countintNumber of interval units per billing period
current_period_startdatetimeStart of current billing period
current_period_enddatetimeEnd of current billing period
trial_ends_atdatetime|nullEnd of trial period, if applicable
cancel_at_period_endbooleanWhether cancellation is scheduled
canceled_atdatetime|nullWhen cancellation was requested
cancellation_reasonstring|nullUser-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:

ParamTypeDescription
currencystring (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
}
FieldTypeRequiredValidation
plan_idUUIDYesMust reference an existing plan
currencystringYes3-char ISO code, must exist in currencies table
success_urlURLYesRedirect URL after successful payment
cancel_urlURLYesRedirect URL if user cancels checkout
quantityintNoNumber 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:

CodeStatusDescription
billing_not_ready422Tenant is missing required billing fields (returned with missing_fields array)
seat_pricing_misconfigured422Seat-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
}
FieldTypeRequiredDescription
reasonstringNoOptional cancellation reason (max 500 chars)
immediatelybooleanNoIf 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.

When 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:

ParamTypeRequiredDescription
new_plan_idUUIDYesTarget plan to switch to
quantityintNoNew 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
    }
  }
}
FieldTypeDescription
creditmoneyAmount credited for unused time on the current plan
chargemoneyAmount charged for the new plan's remaining period
netmoneyNet amount the user will pay (charge minus credit)
breakdownobjectDetailed proration calculation
This endpoint uses 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"
}
FieldTypeRequiredDescription
new_plan_idUUIDYesTarget plan
quantityintNoNew seat count
success_urlURLNoRequired only for free-to-paid transitions
cancel_urlURLNoRequired 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

CodeStatusDescription
subscription_cannot_be_upgraded422Subscription status does not allow plan changes
proration_not_supported422Proration cannot be calculated for this transition
plan_not_available_in_currency422Target plan has no price in the subscription's currency
billing_not_ready422Tenant missing required billing fields (returned with missing_fields)
plan_change_error422Generic plan change failure
The API enforces several validation guards: the subscription must be in an upgradable status, the target plan must be different from the current one, usage-based plans cannot be changed this way, the currency must match, and the target plan must pass eligibility checks.

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"
}
FieldTypeRequiredDescription
return_urlURLNoWhere 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
  }
}
FieldTypeDescription
current_seatsintCurrent number of team members
seat_limitint|nullMaximum seats allowed by the plan (null = unlimited)
price_per_seatmoneyUnit price per seat
total_seat_costmoneyTotal cost for all occupied seats
subscription_quantityintQuantity on the Stripe subscription
available_seatsintRemaining seats before hitting the limit
is_seat_basedbooleanWhether the current plan uses seat-based pricing
is_syncedbooleanWhether the subscription quantity matches the actual seat count

Response (404): If no active subscription exists.

For plans with 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
    }
  ]
}
FieldTypeDescription
keystringFeature code (stable identifier)
enabledbooleanAlways true for active entitlements
typeenumboolean or quota
limitint|nullQuota limit (null for boolean entitlements or unlimited quotas)
usedint|nullCurrent usage count (reserved for future implementation)
remainingint|nullRemaining 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
}
FieldTypeRequiredValidation
namestringYesMax 255
emailstringYesValid email, unique
passwordstringYesMin 8 chars, must match password_confirmation
plan_slugstringYesMust reference an existing plan slug
currencystringYes3-char ISO code, must exist in currencies table
tenant_namestringNoCustom tenant name (defaults to user name)
quantityintNoSeat 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"
}
Unlike most endpoints, this response is not wrapped in a {"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.

MethodPathDescription
GET/api/v1/admin/tenants/{tenant}/subscriptionsList all subscriptions for a tenant
POST/api/v1/admin/tenants/{tenant}/subscriptions/{subscription}/cancelCancel 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