API Conventions
Every endpoint in SaaS4Builders lives under the /api/v1 prefix, returns JSON with snake_case keys, and follows the conventions documented on this page. Read this before consuming any endpoint.
Versioning
All routes are registered under a single version prefix:
/api/v1/...
The API follows strict backward-compatibility rules within a version:
- No breaking changes within V1 — fields are never removed or renamed
- Additive changes only — new optional fields may appear in responses at any time
- Enum values may be extended — new values can be added to existing enums, so treat unknown values gracefully
- Breaking changes require a new version (
/api/v2)
Authentication
SaaS4Builders uses Laravel Sanctum with two authentication modes.
Bearer Token Mode
External clients and API consumers use token-based authentication:
Authorization: Bearer {access_token}
Tokens are obtained via the login or registration endpoints. The system uses an access/refresh token pair — access tokens are short-lived, and the /auth/refresh endpoint issues new ones using a refresh token.
SPA Cookie Mode
The Nuxt frontend uses cookie-based session authentication automatically. Requests from the configured frontend domain receive session cookies — no manual token management is needed. CSRF tokens are required for state-changing requests.
Public Endpoints
Several endpoints require no authentication:
| Endpoint Group | Example |
|---|---|
| Catalog | GET /api/v1/catalog/plans |
| Currencies | GET /api/v1/currencies |
| Public settings | GET /api/v1/docs/settings/public |
| Invitations (view) | GET /api/v1/invitations/{token} |
| Onboarding start | POST /api/v1/onboarding/start |
| Auth (login, register) | POST /api/v1/auth/login |
For full auth flow details, see Authentication.
Pagination
Paginated Lists
Admin and tenant list endpoints return paginated results with metadata:
{
"data": [
{ "id": "550e8400-e29b-41d4-a716-446655440000", "name": "Acme Corp" }
],
"meta": {
"current_page": 1,
"last_page": 3,
"per_page": 25,
"total": 72
}
}
A links object with first, last, prev, and next URLs is also present in the response (from Laravel's default paginator), but data and meta are the contractual fields your frontend should rely on.
Query parameters:
| Param | Type | Default | Max | Description |
|---|---|---|---|---|
page | int | 1 | — | Page number |
per_page | int | 25 | 100 | Items per page |
Non-Paginated Lists
Public catalog endpoints and some tenant endpoints return simple lists without pagination metadata:
{
"data": [
{ "id": "550e8400-e29b-41d4-a716-446655440000", "name": "Pro Plan" }
]
}
Lists are always wrapped in a top-level data key — the API never returns raw arrays.
Filtering, Sorting & Includes
List endpoints that support dynamic queries use Spatie QueryBuilder with strict whitelisting. Only explicitly allowed filters, sorts, and includes are accepted.
Filtering
Filters use the filter[field] query parameter syntax:
GET /api/v1/admin/plans?filter[pricing_type]=seat&filter[is_active]=true
Filter types available across the API:
| Type | Syntax | Behavior |
|---|---|---|
| Exact match | filter[status]=active | WHERE status = 'active' |
| Partial search | filter[name]=acme | WHERE name LIKE '%acme%' |
| Global search | filter[search]=acme | Searches across multiple fields |
| Date range | filter[created_after]=2026-01-01 | WHERE created_at >= '2026-01-01' |
Sorting
Sort with sort=field (ascending) or sort=-field (descending):
GET /api/v1/admin/products?sort=-created_at
GET /api/v1/admin/plans?sort=sort_order,-created_at
Multiple sort fields are comma-separated. If no sort is provided, a sensible default is applied (typically -created_at).
Includes
Load related resources on demand with include:
GET /api/v1/admin/plans?include=product,entitlements,entitlements.feature
Nested relationships use dot notation. Only whitelisted includes are accepted.
Error Responses
All errors return JSON. The format depends on the error type.
Validation Errors (422)
Returned when request data fails validation rules:
{
"message": "The plan id field is required.",
"errors": {
"plan_id": [
"The plan id field is required."
]
}
}
The errors object maps field names to arrays of error messages.
Domain Errors (422)
Business rule violations return a stable, machine-readable code alongside the human-readable message:
{
"message": "This subscription cannot be upgraded from its current status.",
"code": "subscription_cannot_be_upgraded"
}
code field when present, not the message. The message is human-readable and may change; the code is stable and non-localized.Domain error codes used across the API include:
| Code | Context | Description |
|---|---|---|
subscription_cannot_be_upgraded | Plan change | Subscription status does not allow upgrades |
proration_not_supported | Plan change | Proration cannot be calculated for this plan change |
plan_not_available_in_currency | Checkout, plan change | Plan has no price in the requested currency |
billing_not_ready | Checkout, plan change | Tenant is missing required billing fields |
plan_change_error | Plan change | Generic plan change failure |
PLAN_NOT_AVAILABLE_IN_CURRENCY | Onboarding | Plan not available in the requested currency |
TENANT_CONTEXT_REQUIRED | Onboarding | Request requires tenant context |
SUBSCRIPTION_CANNOT_BE_CANCELED | Admin cancel | Subscription status does not allow cancellation |
snake_case (e.g., billing_not_ready) while others use UPPER_SNAKE_CASE (e.g., PLAN_NOT_AVAILABLE_IN_CURRENCY). Your frontend should match codes case-sensitively.Standard HTTP Errors
| Status | Body | When |
|---|---|---|
| 401 | {"message": "Unauthenticated."} | Missing or invalid auth token/session |
| 403 | {"message": "Forbidden."} | Authenticated but lacking permission |
| 404 | {"message": "Not found"} | Resource does not exist |
| 419 | {"message": "CSRF token mismatch"} | Invalid CSRF token in SPA mode |
| 429 | {"message": "Too many requests."} | Rate limit exceeded (includes Retry-After header) |
Rate Limiting
Endpoints are rate-limited to protect the API and external service quotas.
| Endpoint Group | Limit | Notes |
|---|---|---|
| Public catalog | 60/minute | Products, plans, features, currencies |
| Auth login | 5/minute | Brute-force protection |
| Auth register | 10/minute | |
| Password reset | 5/minute | Forgot + reset endpoints |
| Token refresh | 10/minute | |
| OAuth | 10/minute | Redirect + callback + exchange |
| Onboarding start | 5/minute | |
| Onboarding (authenticated) | 10/minute | Status, retry, complete |
| Invitations (public) | 30/minute | View + accept |
| Avatar upload/delete | 10/minute | |
| Push subscriptions | 10/minute | |
| Stripe price operations | 30/minute | Admin Stripe sync endpoints |
| Studio access | 60/minute |
Authenticated tenant and admin endpoints without an explicit throttle use Laravel's default rate limiter.
When rate-limited, the response includes a Retry-After header with the number of seconds to wait.
Data Type Conventions
snake_case Contract
All JSON keys in API requests and responses use snake_case:
{
"tenant_id": "550e8400-e29b-41d4-a716-446655440000",
"plan_id": "660e8400-e29b-41d4-a716-446655440001",
"created_at": "2026-03-27T14:30:00.000000Z",
"cancel_at_period_end": false
}
The Nuxt frontend automatically transforms these to camelCase via the API client layer, and converts outgoing request bodies back to snake_case.
Money
Money values are represented as an integer cent amount plus a currency code. Never floating point.
{
"amount_cents": 2999,
"currency": "EUR"
}
Some nested money objects follow the same pattern:
{
"subtotal": { "amount_cents": 2500, "currency": "EUR" },
"tax": { "amount_cents": 499, "currency": "EUR" },
"total": { "amount_cents": 2999, "currency": "EUR" }
}
cents as the key name instead of amount_cents. This is the only exception to the standard money format.Dates and Times
- Datetime values: ISO-8601 UTC format —
2026-03-27T14:30:00.000000Z - Date-only values:
YYYY-MM-DDformat —2026-03-27
All datetimes are UTC. If timezone context matters, it is always explicit in the ISO value.
IDs
- Most entities (products, plans, features, tenants, subscriptions, invoices): UUID strings
- Users: Integer IDs (auto-increment)
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"user_id": 42
}
Booleans
Strict JSON booleans (true / false), never 1 / 0 or string equivalents.
Enums
Enum values are stable lowercase snake_case strings. The key enums used across the API:
| Enum | Values |
|---|---|
pricing_type | flat, seat, usage |
billing_cycle | monthly, yearly, quarterly, weekly |
interval_unit | day, week, month, year |
subscription_status | active, trialing, past_due, canceled, unpaid, paused, incomplete, incomplete_expired |
entitlement_type | boolean, quota |
invoice_status | draft, open, paid, void, uncollectible |
Nullable vs Optional
- Nullable fields (always present, value can be
null):"canceled_at": null - Optional fields (may be absent from the response entirely): used for conditional includes like
"plan": {...}when anincludeis requested
The API prefers nullable over optional. Fields that are absent typically indicate an include was not requested.
Tenancy Scoping
API endpoints are organized into three scopes:
Public Endpoints
No authentication, no tenant context. Used for pricing pages and public information.
GET /api/v1/catalog/plans
GET /api/v1/currencies
Tenant Endpoints
Path-based tenant scoping with a full middleware chain:
GET /api/v1/tenant/{tenantId}/subscription
POST /api/v1/tenant/{tenantId}/checkout
Middleware chain: auth:sanctum → tenant.resolve → tenant.member → onboarding.complete
The {tenantId} in the path is the tenant's UUID. All data returned is scoped to that tenant — cross-tenant data access is impossible.
Admin Endpoints
Platform administration under the /admin prefix:
GET /api/v1/admin/products
GET /api/v1/admin/tenants/{tenant}/subscriptions
Middleware chain: auth:sanctum → impersonation.prevent → platform.admin
Admin endpoints require the user to be a platform administrator. Individual actions may require additional granular permissions (e.g., products.view, plans.create).
For details on tenant resolution and data isolation, see Multi-Tenancy.
Impersonation
Platform administrators can impersonate tenant users for support purposes. When impersonation is active:
- An
X-Impersonation-Tokenheader is included in requests - The
GET /auth/meendpoint returns the impersonation context:actor_admin,effective_user,effective_tenant, andis_impersonating: true - Admin endpoints are blocked (
impersonation.preventmiddleware) — impersonated sessions can only access tenant-scoped endpoints
See Impersonation for the full impersonation flow.
Backward Compatibility
The V1 API guarantees backward compatibility:
- Fields are never removed from existing responses
- New optional fields may appear at any time — your code should ignore unknown fields
- New enum values may be added — handle unknown values gracefully
- New endpoints may be added — they do not affect existing ones
- Error codes are stable — once introduced, a code string never changes meaning
If a breaking change becomes necessary, it will be introduced in a new API version (/api/v2).
What's Next
- Products & Plans API — Catalog and pricing endpoints
- Subscriptions API — Subscription lifecycle endpoints
Frontend Testing (Vitest)
How to write frontend tests: Vitest configuration, Nuxt test utilities, mock factories, store tests, composable tests, component tests, API module tests, and coverage.
Products & Plans API
Public catalog endpoints and admin CRUD for products, plans, features, entitlements, prices, and currencies in SaaS4Builders.