Subscriptions & Billing ~15 min
Overview
Shell integrates with Stripe for subscription management. Plans are defined in the Shell DB and optionally synced to Stripe. Each organization is on exactly one plan at a time.
Organization ──planId──► subscription_plans ──stripePriceId──► Stripe
│
plan_limits (resource caps)
plan_usage (current consumption)Subscription statuses
| Status | Meaning |
|---|---|
TRIALING | In free trial period |
ACTIVE | Paid and current |
PAST_DUE | Payment failed, grace period |
CANCELED | Subscription ended |
UNPAID | Multiple failures, access restricted |
INCOMPLETE | Stripe payment pending confirmation |
Check current subscription
GET /api/subscriptions/current
Authorization: Bearer <shell-jwt>{
"data": {
"status": "ACTIVE",
"plan": { "id": "...", "name": "Pro", "slug": "pro" },
"interval": "month",
"currentPeriodEnd": "2026-04-01T00:00:00Z",
"cancelAtPeriodEnd": false,
"trialEnd": null
}
}Upgrade via Stripe Checkout
POST /api/subscriptions/upgrade
Authorization: Bearer <shell-jwt>
Content-Type: application/json
{
"planCode": "pro",
"interval": "month"
}Response — for paid plans:
{ "data": { "checkoutUrl": "https://checkout.stripe.com/..." } }Redirect the user to checkoutUrl. Stripe calls the webhook on completion.
Response — for free plans:
{ "data": { "success": true, "plan": "community" } }Free plans are assigned immediately without Stripe.
Upgrade with saved payment method
POST /api/subscriptions/upgrade-direct
Authorization: Bearer <shell-jwt>
Content-Type: application/json
{
"planCode": "pro",
"interval": "month",
"paymentMethodId": "pm_..."
}Uses a previously saved payment method — no redirect required.
Payment methods
List saved methods
GET /api/subscriptions/payment-methods
Authorization: Bearer <shell-jwt>{
"data": [
{
"id": "pm_...",
"brand": "visa",
"last4": "4242",
"expMonth": 12,
"expYear": 2027,
"isDefault": true
}
]
}Save a new method (SetupIntent flow)
Step 1 — create a SetupIntent:
POST /api/subscriptions/payment-methods/setup
Authorization: Bearer <shell-jwt>{ "data": { "clientSecret": "seti_..._secret_..." } }Step 2 — confirm on the frontend using Stripe.js:
const { error } = await stripe.confirmCardSetup(clientSecret, {
payment_method: { card: cardElement },
});Once confirmed, the method appears in GET /payment-methods.
Set default method
PUT /api/subscriptions/payment-methods/:id/default
Authorization: Bearer <shell-jwt>Delete a method
DELETE /api/subscriptions/payment-methods/:id
Authorization: Bearer <shell-jwt>Billing portal
Let users manage their own subscription, invoices, and payment methods via Stripe's hosted portal:
GET /api/subscriptions/portal
Authorization: Bearer <shell-jwt>{ "data": { "portalUrl": "https://billing.stripe.com/session/..." } }Stripe webhook
Shell processes these Stripe events at POST /api/subscriptions/webhook (no auth, verified by Stripe signature):
| Event | Action |
|---|---|
checkout.session.completed | Activate subscription, update org plan |
invoice.paid | Renew subscription, update period end |
customer.subscription.updated | Sync status and plan changes |
customer.subscription.deleted | Mark subscription canceled |
Webhook secret required
Set APP_ENV_STRIPE_WEBHOOK_SECRET to your Stripe webhook signing secret. Without it, webhook signature verification will fail and events will be rejected.
Configure in Stripe Dashboard: Developers → Webhooks → Add endpoint
- URL:
https://shell-api.your-domain.com/api/subscriptions/webhook - Events: the 4 listed above
Usage limits
Plans can define per-resource caps. Shell enforces them via two endpoints.
Check a limit
POST /api/subscriptions/check-limit
Authorization: Bearer <shell-jwt>
Content-Type: application/json
{ "limitKey": "employees" }{
"data": {
"allowed": true,
"current": 45,
"limit": 50,
"remaining": 5
}
}"allowed": false means the org is at or over the limit.
Consume usage
Call this after successfully creating a resource:
POST /api/subscriptions/consume
Authorization: Bearer <shell-jwt>
Content-Type: application/json
{ "limitKey": "employees" }Get usage summary
GET /api/subscriptions/usage
Authorization: Bearer <shell-jwt>{
"data": {
"employees": { "current": 45, "limit": 50 },
"documents": { "current": 120, "limit": -1 }
}
}limit: -1 = unlimited.
Admin: manage plans
List plans
GET /api/plans
Authorization: Bearer <shell-jwt> (requires plans:list)Create a plan
POST /api/plans
Authorization: Bearer <shell-jwt> (admin only)
Content-Type: application/json
{
"name": "Pro",
"slug": "pro",
"price": 49.00,
"interval": "month",
"isActive": true,
"serviceIds": ["svc-uuid-1", "svc-uuid-2"]
}Set plan limits
PUT /api/plans/:id/limits
Authorization: Bearer <shell-jwt> (admin only)
Content-Type: application/json
{
"limits": [
{ "resource": "employees", "limitValue": 50 },
{ "resource": "documents", "limitValue": -1 }
]
}limitValue: -1 = unlimited.
Sync plan to Stripe
Creates or updates a Stripe Product and Price for this plan:
POST /api/plans/:id/sync-stripe
Authorization: Bearer <shell-jwt> (admin only)Run this after creating or updating a paid plan
Without syncing, stripePriceId will be empty and Stripe Checkout will fail.