Products & Plans API
The catalog is the foundation of the billing system. It defines what you sell (products), how you price it (plans with prices), and what capabilities each plan grants (features and entitlements).
The API exposes two sets of endpoints:
- Public catalog — No authentication required. Designed for pricing pages. Returns locale-resolved strings and active items only.
- Admin CRUD — Platform administrator access. Full management of the catalog with translations, filters, and Stripe synchronization.
Public Catalog Endpoints
These endpoints require no authentication, are cached for 60 seconds, and return non-paginated lists wrapped in {"data": [...]}. They are rate-limited to 60 requests per minute.
GET /api/v1/catalog/products
Returns all active products.
Auth: None Rate limit: 60/minute
Response (200):
{
"data": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "SaaS Platform",
"slug": "saas-platform",
"description": "Complete SaaS platform for builders"
}
]
}
The name and description fields are resolved to the current locale based on the Accept-Language header. Only products where is_active = true are returned.
GET /api/v1/catalog/plans
Returns all active plans with their prices and entitlements.
Auth: None Rate limit: 60/minute
Query Parameters:
| Param | Type | Required | Description |
|---|---|---|---|
currency | string (3 chars) | No | Filter prices by currency code (e.g., EUR). When omitted, all prices are included. |
Response (200):
{
"data": [
{
"id": "660e8400-e29b-41d4-a716-446655440001",
"product_id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Pro",
"description": "For growing teams",
"slug": "pro",
"pricing_type": "seat",
"billing_cycle": "monthly",
"interval_unit": "month",
"interval_count": 1,
"trial_days": 14,
"sort_order": 2,
"metadata": null,
"prices": [
{
"id": "770e8400-e29b-41d4-a716-446655440002",
"currency": "EUR",
"price_cents": 2999
},
{
"id": "770e8400-e29b-41d4-a716-446655440003",
"currency": "USD",
"price_cents": 3299
}
],
"entitlements": [
{
"id": "880e8400-e29b-41d4-a716-446655440004",
"feature_id": "990e8400-e29b-41d4-a716-446655440005",
"type": "quota",
"value": 25,
"feature": {
"code": "team-members",
"name": "Team Members"
}
},
{
"id": "880e8400-e29b-41d4-a716-446655440006",
"feature_id": "990e8400-e29b-41d4-a716-446655440007",
"type": "boolean",
"value": null,
"feature": {
"code": "priority-support",
"name": "Priority Support"
}
}
],
"created_at": "2026-01-15T10:00:00.000000Z",
"updated_at": "2026-03-20T14:30:00.000000Z"
}
]
}
Plans are sorted by sort_order. Only plans where both the plan and its parent product are active are returned. Each plan eagerly loads its prices and entitlements with feature details.
billing_cycle field (e.g., monthly, yearly) is a legacy convenience field. The canonical interval is defined by interval_unit + interval_count (e.g., month / 1 for monthly, month / 3 for quarterly).GET /api/v1/catalog/features
Returns all active features.
Auth: None Rate limit: 60/minute
Response (200):
{
"data": [
{
"id": "990e8400-e29b-41d4-a716-446655440005",
"code": "team-members",
"name": "Team Members",
"description": "Maximum number of team members allowed"
},
{
"id": "990e8400-e29b-41d4-a716-446655440007",
"code": "priority-support",
"name": "Priority Support",
"description": "Access to priority support channel"
}
]
}
The code field is a stable, kebab-case identifier used throughout the system to reference features programmatically. The name and description are locale-resolved.
GET /api/v1/currencies
Returns all active currencies.
Auth: None Rate limit: 60/minute
Response (200):
{
"data": [
{
"code": "EUR",
"name": "Euro",
"symbol": "\u20ac",
"minor_units": 2
},
{
"code": "USD",
"name": "US Dollar",
"symbol": "$",
"minor_units": 2
},
{
"code": "JPY",
"name": "Japanese Yen",
"symbol": "\u00a5",
"minor_units": 0
}
]
}
The minor_units field indicates the number of decimal places for the currency (0 for zero-decimal currencies like JPY, 2 for EUR/USD, 3 for BHD/KWD). This is critical for correctly interpreting price_cents values — for JPY, price_cents: 3000 means 3000 JPY, not 30.00 JPY.
For more on currency handling, see Currency Rules.
Translatable Fields
The catalog supports four locales: English (en), French (fr), Spanish (es), and Italian (it).
Admin endpoints accept and return translations grouped by locale:
{
"name": {
"en": "Professional",
"fr": "Professionnel",
"es": "Profesional",
"it": "Professionale"
},
"description": {
"en": "For growing teams",
"fr": "Pour les equipes en croissance"
}
}
The English locale (en) is required when creating a resource. Other locales are optional and can be added later via update.
Public and tenant endpoints return a single locale-resolved value:
{
"name": "Professional",
"description": "For growing teams"
}
The locale is determined by the Accept-Language header. If the requested locale has no translation, English is used as the fallback.
Admin Product Endpoints
All admin endpoints require auth:sanctum + platform.admin middleware. Individual actions require specific permissions.
GET /api/v1/admin/products
List all products with filtering and pagination.
Auth: Bearer token
Permission: products.view
Query Parameters:
| Param | Type | Description |
|---|---|---|
per_page | int | Items per page (default: 25, max: 100) |
sort | string | Sort field. Allowed: created_at. Prefix with - for descending. |
filter[name] | string | Partial match on product name (searches across locales) |
filter[is_active] | boolean | Filter by active status |
filter[search] | string | Global search across product names |
include | string | Comma-separated. Allowed: plans, plansCount |
Response (200):
{
"data": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"slug": "saas-platform",
"is_active": true,
"metadata": null,
"translations": {
"en": { "name": "SaaS Platform", "description": "Complete SaaS platform" },
"fr": { "name": "Plateforme SaaS", "description": "Plateforme SaaS complete" }
},
"plans_count": 3,
"created_at": "2026-01-15T10:00:00.000000Z",
"updated_at": "2026-03-20T14:30:00.000000Z"
}
],
"meta": {
"current_page": 1,
"last_page": 1,
"per_page": 25,
"total": 1
}
}
Default sort is -created_at (newest first).
POST /api/v1/admin/products
Create a new product.
Auth: Bearer token
Permission: products.create
Request Body:
{
"name": {
"en": "SaaS Platform",
"fr": "Plateforme SaaS"
},
"description": {
"en": "Complete SaaS platform for builders",
"fr": "Plateforme SaaS complete pour les createurs"
},
"slug": "saas-platform",
"is_active": true,
"metadata": null
}
| Field | Type | Required | Validation |
|---|---|---|---|
name | object | Yes | name.en required, string, max 255 |
description | object | No | description.en required if present, string, max 65535 |
slug | string | Yes | Unique, alpha_dash, max 255 |
is_active | boolean | No | Defaults to true |
metadata | object | No | Arbitrary key-value pairs |
Response (201): ProductResource with loaded translations.
GET /api/v1/admin/products/{product}
Permission: products.view — Returns ProductResource with translations and plans count.
PATCH /api/v1/admin/products/{product}
Permission: products.update — Same fields as POST, all optional. Returns updated ProductResource.
DELETE /api/v1/admin/products/{product}
Permission: products.delete — Returns 204 No Content.
Admin Plan Endpoints
GET /api/v1/admin/plans
List all plans with filtering, sorting, and relationship includes.
Auth: Bearer token
Permission: plans.view
Query Parameters:
| Param | Type | Description |
|---|---|---|
per_page | int | Items per page (default: 25, max: 100) |
sort | string | Allowed: sort_order, created_at |
filter[name] | string | Partial match on plan name |
filter[is_active] | boolean | Filter by active status |
filter[product_id] | UUID | Filter by parent product |
filter[pricing_type] | enum | flat, seat, or usage |
filter[billing_cycle] | enum | monthly, yearly, quarterly, weekly |
filter[search] | string | Global search across plan names |
include | string | Allowed: product, entitlements, entitlements.feature |
Default sort is sort_order (ascending).
Response (200): Paginated PlanResource with fields: id, product_id, slug, pricing_type, billing_cycle, interval_unit, interval_count, trial_days, is_active, sort_order, metadata, translations, entitlements[], prices[], created_at, updated_at.
Admin plan resources include translations (all locales), prices (with stripe_price_id), and entitlements (with nested features) by default — unlike public resources which only show locale-resolved names and omit Stripe IDs.
POST /api/v1/admin/plans
Create a new plan.
Auth: Bearer token
Permission: plans.create
Request Body:
{
"product_id": "550e8400-e29b-41d4-a716-446655440000",
"name": {
"en": "Pro Monthly",
"fr": "Pro Mensuel"
},
"slug": "pro-monthly",
"pricing_type": "seat",
"billing_cycle": "monthly",
"trial_days": 14,
"sort_order": 2,
"is_active": true,
"metadata": null
}
| Field | Type | Required | Validation |
|---|---|---|---|
product_id | UUID | Yes | Must reference an existing product |
name | object | Yes | name.en required, string, max 255 |
slug | string | Yes | Unique, alpha_dash, max 255 |
pricing_type | enum | Yes | flat, seat, or usage |
billing_cycle | enum | Yes | monthly, yearly, quarterly, weekly |
trial_days | int | No | Defaults to 0. Min: 0. |
sort_order | int | No | Defaults to 0. Min: 0. |
is_active | boolean | No | Defaults to true |
metadata | object | No | Arbitrary key-value pairs |
Response (201): PlanResource with loaded translations, entitlements, and prices.
GET /api/v1/admin/plans/{plan}
Permission: plans.view — Returns PlanResource with translations, entitlements (including features), and prices.
PATCH /api/v1/admin/plans/{plan}
Permission: plans.update — All fields optional. You can also sync entitlements and prices inline:
{
"name": { "en": "Pro Plus" },
"trial_days": 30,
"entitlements": [
{ "feature_id": "990e8400-...", "type": "quota", "value": 50 },
{ "feature_id": "990e8400-...", "type": "boolean" }
],
"prices": [
{ "currency": "EUR", "price_cents": 4999, "stripe_price_id": "price_1abc123" },
{ "currency": "USD", "price_cents": 5499 }
]
}
entitlements or prices arrays are included, they perform a sync operation — the plan's set is replaced entirely. Omit these fields to leave them unchanged.Entitlement validation: type: "boolean" must NOT include a value; type: "quota" must include value as a positive integer.
DELETE /api/v1/admin/plans/{plan}
Permission: plans.delete — Returns 204 No Content.
POST /api/v1/admin/plans/{plan}/duplicate
Permission: plans.create — Duplicates the plan including entitlements and prices. Returns the new PlanResource.
Admin Feature Endpoints
GET /api/v1/admin/features
List all features with filtering and pagination.
Auth: Bearer token
Permission: features.view
Query Parameters:
| Param | Type | Description |
|---|---|---|
per_page | int | Items per page (default: 25, max: 100) |
sort | string | Allowed: code, created_at |
filter[name] | string | Partial match on feature name |
filter[code] | string | Partial match on feature code |
filter[is_active] | boolean | Filter by active status |
filter[search] | string | Global search across feature names |
Response (200):
{
"data": [
{
"id": "990e8400-e29b-41d4-a716-446655440005",
"code": "team-members",
"is_active": true,
"is_system": true,
"metadata": null,
"translations": {
"en": { "name": "Team Members", "description": "Maximum number of team members" },
"fr": { "name": "Membres d'equipe", "description": "Nombre maximum de membres" }
},
"created_at": "2026-01-15T10:00:00.000000Z",
"updated_at": "2026-01-15T10:00:00.000000Z"
}
],
"meta": {
"current_page": 1,
"last_page": 1,
"per_page": 25,
"total": 1
}
}
The is_system flag indicates a built-in feature that cannot be deleted. System features include team-members (used for seat-based billing) and other core features.
POST /api/v1/admin/features
Create a new feature.
Auth: Bearer token
Permission: features.create
Request Body:
{
"code": "api-calls",
"name": {
"en": "API Calls",
"fr": "Appels API"
},
"description": {
"en": "Monthly API call quota",
"fr": "Quota mensuel d'appels API"
},
"is_active": true,
"metadata": null
}
| Field | Type | Required | Validation |
|---|---|---|---|
code | string | Yes | Unique, alpha_dash, max 255 |
name | object | Yes | name.en required, string, max 255 |
description | object | No | description.en required if present, max 65535 |
is_active | boolean | No | Defaults to true |
metadata | object | No | Arbitrary key-value pairs |
Response (201): FeatureResource with loaded translations.
GET, PATCH, DELETE /api/v1/admin/features/{feature}
- GET (
features.view) — ReturnsFeatureResourcewith translations. - PATCH (
features.update) — All fields optional. Same shape as POST. Returns updatedFeatureResource. - DELETE (
features.delete) — Returns 204 No Content.
is_system: true) cannot be deleted. Attempting to do so returns a 403 error.Entitlement Endpoints
Entitlements link features to plans. Each entitlement is either a boolean toggle (feature is on/off) or a quota (feature has a numeric limit).
GET /api/v1/admin/plans/{plan}/entitlements
List entitlements for a specific plan.
Auth: Bearer token
Permission: entitlements.view
Response (200): Array of EntitlementResource objects with: id, plan_id, feature_id, type, value, feature (nested), created_at, updated_at.
PUT /api/v1/admin/plans/{plan}/entitlements
Replace all entitlements for a plan (sync operation). Permission: entitlements.sync
{
"entitlements": [
{ "feature_id": "990e8400-...", "type": "quota", "value": 50 },
{ "feature_id": "990e8400-...", "type": "boolean" }
]
}
Response (200): Updated PlanResource with loaded entitlements.
DELETE /api/v1/admin/entitlements/{entitlement}
Permission: entitlements.delete — Returns 204 No Content.
Plan Price Endpoints
Each plan can have prices in multiple currencies. Prices can be linked to Stripe Price objects for checkout.
PUT /api/v1/admin/plans/{plan}/prices
Sync all prices for a plan. Replaces the entire price set.
Auth: Bearer token
Request Body:
{
"prices": [
{
"currency": "EUR",
"price_cents": 2999,
"stripe_price_id": "price_1abc123"
},
{
"currency": "USD",
"price_cents": 3299
}
]
}
| Field | Type | Required | Validation |
|---|---|---|---|
prices[].currency | string | Yes | 3-char ISO code, must exist in currencies table, unique per plan |
prices[].price_cents | int | Yes | Min: 0 |
prices[].stripe_price_id | string | No | Must start with price_, unique per plan |
Response (200): PlanPriceResource[]
DELETE /api/v1/admin/plans/{plan}/prices/{price}
Response: 204 No Content
Stripe Price Management
These endpoints manage the link between local plan prices and Stripe Price objects. All are rate-limited to 30 requests per minute.
| Method | Path | Description |
|---|---|---|
| POST | .../prices/{price}/stripe-create | Create a new Stripe Price from a local price. Body: {product_id, nickname?} |
| POST | .../prices/{price}/stripe-link | Link an existing Stripe Price. Body: {stripe_price_id}. Validates currency, interval, amount. |
| POST | .../prices/{price}/stripe-unlink | Remove the Stripe Price link |
| POST | .../prices/stripe-import | Import a Stripe Price as a new local price. Body: {stripe_price_id} |
| GET | .../stripe-price-status | Check sync status for all plan prices |
All POST endpoints return the updated PlanPriceResource. The status endpoint returns an array with status per price: linked, missing, mismatch, stripe_missing, inactive, or error.
For more on Stripe integration, see Stripe Integration.
Currency Endpoints
Admin endpoints for managing supported currencies. All require platform.admin middleware.
| Method | Path | Description |
|---|---|---|
| GET | /api/v1/admin/currencies | List all currencies (active and inactive) |
| GET | /api/v1/admin/currencies/catalog | Browse ISO 4217 currencies not yet added |
| POST | /api/v1/admin/currencies | Create a currency |
| POST | /api/v1/admin/currencies/bulk | Bulk-create from an array of ISO codes |
| PATCH | /api/v1/admin/currencies/{currency} | Update name, symbol, minor_units, or is_active |
| DELETE | /api/v1/admin/currencies/{currency} | Delete a currency (204) |
Create request body:
{
"code": "GBP",
"name": "British Pound",
"symbol": "\u00a3",
"minor_units": 2,
"is_active": true
}
| Field | Type | Required | Validation |
|---|---|---|---|
code | string | Yes | 3 alpha chars, unique |
name | string | Yes | Max 100 |
symbol | string | Yes | Max 10 |
minor_units | int | Yes | Must be 0, 2, or 3 |
is_active | boolean | No | Defaults to true |
Bulk create accepts {"codes": ["GBP", "CHF", "CAD"]} and returns {created, skipped_existing, invalid}.
For more on multi-currency architecture, see Currency Rules.
What's Next
- Subscriptions API — Subscription lifecycle, checkout, and plan change endpoints
- Pricing Models — How flat, seat, and usage-based pricing works
- Stripe Integration — Stripe Checkout and price synchronization
API Conventions
API versioning, authentication, pagination, filtering, error format, data types, and backward-compatibility rules for the SaaS4Builders REST API.
Subscriptions API
Tenant subscription endpoints: checkout, cancellation, plan changes, proration preview, billing portal, seats, entitlements, and onboarding flow.