Skip to main content

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.

TierPlansMonthly PriceYearly PriceKey Features
Free--$0--Limited access, no paid features
Starterstarter, starter-yearly$47/mo$497/yr1 social account per platform, manual posting, basic analytics
Propro, pro-yearly$79/mo$798/yrAll platforms, AI content repurposing, priority support
Agency (Enterprise tier)agency, agency-yearly$179/mo$1,798/yrEverything 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:

  1. Verifies the ID token and checks that the email is verified.
  2. Finds or creates a Stripe customer using ensureStripeCustomer() (reuses existing customers by stored ID or email).
  3. Creates a Stripe Checkout Session with a 7-day trial, plan metadata, and promotion code support.
  4. 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:

  1. Expands the session to retrieve the subscription and customer objects.
  2. Calls ensureUidForCustomer() to find or create a Firebase Auth user by the customer's email.
  3. 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:

  1. Looks up the Stripe Checkout Session.
  2. Finds or creates the user and links the stripeCustomerId.
  3. Upserts users/{uid}/subscription/current with the subscription details.
  4. 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

EventHandlerWhat It Does
checkout.session.completedhandleCheckoutCompletedMaps Stripe customer to Firebase user, ensures user document exists
customer.subscription.createdhandleSubscriptionCreatedWrites subscription data, allocates initial credits
customer.subscription.updatedhandleSubscriptionUpdatedUpdates subscription status, tier, period bounds, and plan details
customer.subscription.deletedhandleSubscriptionDeletedSets status to canceled, resets tier to free
customer.subscription.trial_will_endhandleTrialWillEndLogs trial ending (notification hook point)
invoice.payment_succeededhandlePaymentSucceededUpdates subscription data, tops up credits for the new billing period
invoice.payment_failedhandlePaymentFailedSets 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:

  1. Query users collection where stripeCustomerId == customerId.
  2. 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} or invoice:{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's credits_per_period metadata.
  • On invoice.payment_succeeded: Credits are topped up for the new billing period, using the period bounds from subscription.current_period_start and current_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):

  1. Verifies the ID token and retrieves the user's Stripe customer ID from Firestore.
  2. Retrieves the current subscription from Stripe (with fallback to searching by customer ID if the stored subscription ID is stale).
  3. Validates the subscription is in an active state (trialing, active, or past_due).
  4. If already on the target plan, returns { alreadyOnPlan: true }.
  5. Updates the Stripe subscription item with the new price, using proration_behavior: 'create_prorations'.
  6. Writes the updated subscription data to Firestore, clearing any pendingPlan.
  7. 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
  1. authenticateApiKey: Validates the API key and resolves it to a user.
  2. requireActiveSubscription: Checks the user's subscription status. Returns 403 Forbidden if the subscription is inactive or expired.
  3. 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:

  1. Verifies the __session cookie.
  2. Retrieves the user's stripeCustomerId from Firestore.
  3. Creates a Stripe Billing Portal session.
  4. Returns the portal URL.

Firestore Data Model

Subscription Document

users/{uid}/subscription/current:

FieldTypeDescription
subscriptionIdstringStripe subscription ID
customerIdstringStripe customer ID
statusstringactive, trialing, past_due, canceled, none
planIdstringPlan identifier (e.g., pro, starter)
priceIdstringStripe Price ID
productNamestringHuman-readable plan name
tierstringfree, starter, pro, enterprise
currentPeriodStartTimestampStart of current billing period
currentPeriodEndTimestampEnd of current billing period
trialEndTimestamp | nullTrial end date (null if not trialing)
cancelAtPeriodEndbooleanWhether cancellation is pending
pendingPlanobject | nullPending plan change details
updatedAtTimestampLast update time

User Document (Stripe Fields)

users/{uid}:

FieldTypeDescription
stripeCustomerIdstringStripe customer ID
emailstringUser's email
createdAtTimestampAccount creation time
updatedAtTimestampLast 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.

FieldTypeDescription
idstringStripe event ID
typestringEvent type (e.g., invoice.payment_succeeded)
livemodebooleanWhether the event is from live mode
objectIdstringID of the event's data object
eventCreatedAtTimestampWhen Stripe created the event
receivedAtTimestampWhen the webhook received it

Testing with Stripe

Test Cards

Use these Stripe test cards in development:

Card NumberScenario
4242 4242 4242 4242Successful payment
4000 0025 0000 3155Requires 3D Secure authentication
4000 0000 0000 9995Declined (insufficient funds)
4000 0000 0000 0341Attaching 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:

  1. Open the Firebase Emulator UI at http://localhost:4000.
  2. Navigate to Firestore and inspect users/{uid}/subscription/current.
  3. Confirm the status, tier, and currentPeriodEnd fields 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.