Subscriptions & Billing
Soku uses Stripe for subscription management with Firebase as the source of truth for user subscription state. This page covers the full lifecycle: from checkout to credit allocation, plan changes, and API enforcement.
Subscription Tiers
Soku has four tiers. The free tier is the default for all users. Paid tiers are managed through Stripe subscriptions.
| Tier | Plans | Monthly Price | Yearly Price | Key Features |
|---|---|---|---|---|
| Free | -- | $0 | -- | Limited access, no paid features |
| Starter | starter, starter-yearly | $47/mo | $497/yr | 1 social account per platform, manual posting, basic analytics |
| Pro | pro, pro-yearly | $79/mo | $798/yr | All platforms, AI content repurposing, priority support |
| Agency (Enterprise tier) | agency, agency-yearly | $179/mo | $1,798/yr | Everything in Pro, unlimited connections, sub-accounts, premium support |
All paid plans include a 7-day free trial.
Tier definitions and their associated features and limits are centralized in the @soku/schema package (TIER_FEATURES, TIER_LIMITS). The AuthContext derives the user's available features from their current tier.
Checkout Flow
The checkout flow takes a user from plan selection to an active subscription in five steps.
Step 1: Create Checkout Session
The client sends a POST /api/create-checkout-session request with the Firebase ID token and desired plan:
const idToken = await user.getIdToken();
const response = await fetch('/api/create-checkout-session', {
method: 'POST',
headers: {
'Authorization': `Bearer ${idToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ planId: 'pro' }),
});
const { sessionId } = await response.json();
The API route:
- Verifies the ID token and checks that the email is verified.
- Finds or creates a Stripe customer using
ensureStripeCustomer()(reuses existing customers by stored ID or email). - Creates a Stripe Checkout Session with a 7-day trial, plan metadata, and promotion code support.
- Returns
{ sessionId }.
The client then redirects to Stripe Checkout.
Step 2: Stripe Checkout
The user enters their payment details on Stripe's hosted checkout page. On success, Stripe redirects to /success?session_id={CHECKOUT_SESSION_ID}. On cancellation, Stripe redirects to /signup?plan={planId}&canceled=true.
Step 3: Webhook -- checkout.session.completed
Stripe fires checkout.session.completed to the webhook endpoint (apps/functions/webhooks/stripe.js). The handler:
- Expands the session to retrieve the subscription and customer objects.
- Calls
ensureUidForCustomer()to find or create a Firebase Auth user by the customer's email. - Maps the Stripe customer ID to the Firebase UID in the
users/{uid}document.
Step 4: Verify Session
After the Stripe redirect, the client calls POST /api/verify-session with { sessionId } to finalize the subscription:
- Looks up the Stripe Checkout Session.
- Finds or creates the user and links the
stripeCustomerId. - Upserts
users/{uid}/subscription/currentwith the subscription details. - Returns
{ subscription, customToken }.
Step 5: Subscription Active
The AuthContext picks up the new subscription document immediately via its real-time Firestore onSnapshot listener. The UI updates without a page refresh.
Webhook Event Handling
Stripe webhooks are processed by a Firebase Function (stripeWebhook) at apps/functions/webhooks/stripe.js. Every incoming event is verified using the STRIPE_WEBHOOK_SECRET and deduplicated by writing to a stripeEvents/{eventId} collection.
Handled Events
| Event | Handler | What It Does |
|---|---|---|
checkout.session.completed | handleCheckoutCompleted | Maps Stripe customer to Firebase user, ensures user document exists |
customer.subscription.created | handleSubscriptionCreated | Writes subscription data, allocates initial credits |
customer.subscription.updated | handleSubscriptionUpdated | Updates subscription status, tier, period bounds, and plan details |
customer.subscription.deleted | handleSubscriptionDeleted | Sets status to canceled, resets tier to free |
customer.subscription.trial_will_end | handleTrialWillEnd | Logs trial ending (notification hook point) |
invoice.payment_succeeded | handlePaymentSucceeded | Updates subscription data, tops up credits for the new billing period |
invoice.payment_failed | handlePaymentFailed | Sets subscription status to past_due |
Customer-to-User Resolution
All webhook handlers need to map a Stripe customer.id to a Firebase UID. The resolution strategy is:
- Query
userscollection wherestripeCustomerId == customerId. - If not found, call
ensureUidForCustomer()which retrieves the customer's email from Stripe, finds or creates a Firebase Auth user, and upserts the user document with the mapping.
Idempotency
- Each Stripe event ID is recorded in
stripeEvents/{eventId}inside a Firestore transaction. - If the event has already been processed, the handler returns early with
{ received: true, duplicate: true }. - Credit allocations use idempotency keys like
sub.created:{subscriptionId}orinvoice:{invoiceId}to prevent double-crediting.
Credits System
Credits are the consumable resource tied to each subscription. They are allocated per billing period and debited when the user takes actions (generating content, making API calls, etc.).
Allocation
Credits are allocated based on the credits_per_period metadata field on each Stripe Price object.
- On
customer.subscription.created: Initial credits are granted for the subscription's first period. The amount comes from the Price'scredits_per_periodmetadata. - On
invoice.payment_succeeded: Credits are topped up for the new billing period, using the period bounds fromsubscription.current_period_startandcurrent_period_end.
Consumption
Credit debits happen within Firestore transactions in server routes and Functions. This ensures atomicity -- a debit either succeeds fully or not at all.
Credit Ledger
Every credit change (allocation or debit) is recorded as an immutable ledger entry:
users/{uid}/creditLedger/{entryId}:
{
"type": "allocation | debit",
"amount": 100,
"source": {
"event": "customer.subscription.created",
"id": "sub_abc123"
},
"idempotencyKey": "sub.created:sub_abc123",
"createdAt": "2026-01-15T00:00:00Z"
}
Credit Balance
The current balance is stored at:
users/{uid}/credits/current:
{
"balance": 85,
"periodStart": "2026-01-15T00:00:00Z",
"periodEnd": "2026-02-15T00:00:00Z",
"planId": "pro",
"priceId": "price_abc123",
"updatedAt": "2026-01-20T12:30:00Z"
}
The client reads GET /api/credits/balance to display remaining credits.
Pro-rated Upgrades (Optional)
If CREDITS_PRORATE_UPGRADES=true is set, an immediate pro-rated credit top-up is applied when a user upgrades mid-period. The calculation is:
remaining_fraction = (period_end - now) / (period_end - period_start)
credit_delta = new_plan_credits - old_plan_credits
prorated_credits = remaining_fraction * credit_delta
Plan Changes
Instant Upgrade
The client sends POST /api/billing/change-plan with the Firebase ID token and target plan:
const response = await fetch('/api/billing/change-plan', {
method: 'POST',
headers: {
'Authorization': `Bearer ${idToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ planId: 'pro' }),
});
const data = await response.json();
if (data.requiresAction && data.paymentIntentClientSecret) {
// Handle 3D Secure or payment confirmation via Stripe.js
await stripe.confirmCardPayment(data.paymentIntentClientSecret);
}
The API route (apps/web/src/app/api/billing/change-plan/route.ts):
- Verifies the ID token and retrieves the user's Stripe customer ID from Firestore.
- Retrieves the current subscription from Stripe (with fallback to searching by customer ID if the stored subscription ID is stale).
- Validates the subscription is in an active state (
trialing,active, orpast_due). - If already on the target plan, returns
{ alreadyOnPlan: true }. - Updates the Stripe subscription item with the new price, using
proration_behavior: 'create_prorations'. - Writes the updated subscription data to Firestore, clearing any
pendingPlan. - If the payment requires additional authentication (3D Secure), returns
{ requiresAction: true, paymentIntentClientSecret }.
Pending Plan Changes
For changes that take effect at the next billing period, the subscription document stores:
{
"pendingPlan": {
"priceId": "price_xyz",
"planId": "pro",
"creditsPerPeriod": 500,
"appliesAt": "2026-02-15T00:00:00Z"
}
}
When the next customer.subscription.updated webhook fires, the pending plan is applied and the pendingPlan field is cleared.
Cancellation
When a user cancels, Stripe sets cancel_at_period_end: true. The subscription remains active until the period ends. On customer.subscription.deleted, the webhook resets the tier to free and sets the status to canceled. No further credit top-ups occur, but the remaining balance lasts until periodEnd.
API Subscription Enforcement
Public API routes (such as /v1/posts and /v1/media) enforce that the caller has an active subscription. The middleware chain is:
Request → authenticateApiKey → requireActiveSubscription → route handler
authenticateApiKey: Validates the API key and resolves it to a user.requireActiveSubscription: Checks the user's subscription status. Returns403 Forbiddenif the subscription is inactive or expired.- Route handler: Processes the request.
This enforcement runs in the Firebase Functions api function, separate from the Next.js middleware.
Billing Portal
The Stripe Billing Portal lets users manage their payment method, view invoices, and cancel their subscription.
const response = await fetch('/api/billing/portal', {
method: 'POST',
// __session cookie is sent automatically
});
const { url } = await response.json();
window.location.href = url;
The POST /api/billing/portal route:
- Verifies the
__sessioncookie. - Retrieves the user's
stripeCustomerIdfrom Firestore. - Creates a Stripe Billing Portal session.
- Returns the portal URL.
Firestore Data Model
Subscription Document
users/{uid}/subscription/current:
| Field | Type | Description |
|---|---|---|
subscriptionId | string | Stripe subscription ID |
customerId | string | Stripe customer ID |
status | string | active, trialing, past_due, canceled, none |
planId | string | Plan identifier (e.g., pro, starter) |
priceId | string | Stripe Price ID |
productName | string | Human-readable plan name |
tier | string | free, starter, pro, enterprise |
currentPeriodStart | Timestamp | Start of current billing period |
currentPeriodEnd | Timestamp | End of current billing period |
trialEnd | Timestamp | null | Trial end date (null if not trialing) |
cancelAtPeriodEnd | boolean | Whether cancellation is pending |
pendingPlan | object | null | Pending plan change details |
updatedAt | Timestamp | Last update time |
User Document (Stripe Fields)
users/{uid}:
| Field | Type | Description |
|---|---|---|
stripeCustomerId | string | Stripe customer ID |
email | string | User's email |
createdAt | Timestamp | Account creation time |
updatedAt | Timestamp | Last update time |
Credits
users/{uid}/credits/current: Current credit balance and period bounds.
users/{uid}/creditLedger/{entryId}: Immutable ledger of all credit allocations and debits.
Webhook Deduplication
stripeEvents/{eventId}: Records processed Stripe events to prevent duplicate handling.
| Field | Type | Description |
|---|---|---|
id | string | Stripe event ID |
type | string | Event type (e.g., invoice.payment_succeeded) |
livemode | boolean | Whether the event is from live mode |
objectId | string | ID of the event's data object |
eventCreatedAt | Timestamp | When Stripe created the event |
receivedAt | Timestamp | When the webhook received it |
Testing with Stripe
Test Cards
Use these Stripe test cards in development:
| Card Number | Scenario |
|---|---|
4242 4242 4242 4242 | Successful payment |
4000 0025 0000 3155 | Requires 3D Secure authentication |
4000 0000 0000 9995 | Declined (insufficient funds) |
4000 0000 0000 0341 | Attaching card fails |
Use any future expiry date, any 3-digit CVC, and any billing postal code.
Local Webhook Testing
Forward Stripe test events to your local Functions emulator using the Stripe CLI:
stripe listen --forward-to http://localhost:5001/<project-id>/us-central1/stripeWebhook
The CLI outputs a webhook signing secret (whsec_...). Set this as STRIPE_WEBHOOK_SECRET in your Functions environment.
You can also trigger specific events manually:
stripe trigger checkout.session.completed
stripe trigger invoice.payment_succeeded
stripe trigger customer.subscription.updated
Checking Subscription State
After testing, verify the Firestore state:
- Open the Firebase Emulator UI at
http://localhost:4000. - Navigate to Firestore and inspect
users/{uid}/subscription/current. - Confirm the
status,tier, andcurrentPeriodEndfields are correct.
On the client side, the AuthContext updates in real time via its Firestore listener. Check the subscription state in React DevTools by inspecting the AuthContext provider value.
Helpers for Subscription Checks
The apps/web/src/lib/firestore.ts module provides utility functions:
hasActiveSubscription()-- checks if the user's subscription is active or trialing.isInTrialPeriod()-- checks if the user is currently in a trial period.