Skip to main content
BunShip integrates Stripe to handle subscriptions, checkout, usage tracking, and customer self-service. Every organization starts on the Free plan and can upgrade through Stripe Checkout.

Plans and Pricing

Three plans are defined in packages/config/src/billing.ts:
FeatureFreePro ($29/mo)Enterprise ($99/mo)
Team members210Unlimited
Projects325Unlimited
API requests/month1,000100,000Unlimited
Webhook endpoints110Unlimited
API keys11050
Storage0.5 GB10 GB100 GB
All paid plans offer monthly and yearly billing. Yearly billing saves roughly 17%.

Configuring Plans

Plan configuration lives in a single file. Each plan defines its Stripe price IDs, usage limits, and feature list:
// packages/config/src/billing.ts
export const billingConfig = {
  currency: "usd",
  plans: [
    {
      id: "free",
      name: "Free",
      description: "For side projects and experimentation",
      price: { monthly: 0, yearly: 0 },
      stripePriceIds: { monthly: null, yearly: null },
      limits: {
        members: 2,
        projects: 3,
        apiRequests: 1000,
        webhookEndpoints: 1,
        apiKeys: 1,
        storageGB: 0.5,
      },
      features: [
        "Up to 2 team members",
        "3 projects",
        "1,000 API requests/month",
        "Community support",
      ],
    },
    {
      id: "pro",
      name: "Pro",
      description: "For growing teams and businesses",
      price: { monthly: 29, yearly: 290 },
      stripePriceIds: {
        monthly: process.env.STRIPE_PRO_MONTHLY_PRICE_ID ?? "price_pro_monthly",
        yearly: process.env.STRIPE_PRO_YEARLY_PRICE_ID ?? "price_pro_yearly",
      },
      limits: {
        members: 10,
        projects: 25,
        apiRequests: 100000,
        webhookEndpoints: 10,
        apiKeys: 10,
        storageGB: 10,
      },
      features: [
        "Up to 10 team members",
        "25 projects",
        "100K API requests/month",
        "Webhooks",
        "API access",
        "Priority support",
        "Audit logs (30 days)",
      ],
      popular: true,
    },
    // Enterprise plan follows the same pattern
  ],
} as const;
Set the STRIPE_PRO_MONTHLY_PRICE_ID, STRIPE_PRO_YEARLY_PRICE_ID, STRIPE_ENTERPRISE_MONTHLY_PRICE_ID, and STRIPE_ENTERPRISE_YEARLY_PRICE_ID environment variables to match the price IDs in your Stripe dashboard.

Checkout Flow

When a user upgrades, BunShip creates a Stripe Checkout Session linked to the organization:
// apps/api/src/services/billing.service.ts
const session = await stripe.checkout.sessions.create({
  customer: customerId,
  mode: "subscription",
  payment_method_types: ["card"],
  line_items: [
    {
      price: priceId,
      quantity: 1,
    },
  ],
  success_url: `${appConfig.url}/dashboard/billing?success=true`,
  cancel_url: `${appConfig.url}/dashboard/billing?canceled=true`,
  metadata: {
    organizationId: orgId,
    planId: plan.id,
  },
  subscription_data: {
    metadata: {
      organizationId: orgId,
      planId: plan.id,
    },
  },
});
1

User selects a plan

Your frontend calls POST /api/v1/billing/checkout with the desired priceId.
2

BunShip creates a Stripe customer

If the organization does not yet have a Stripe customer, one is created automatically and stored in the subscriptions table.
3

Redirect to Stripe Checkout

The API returns a Checkout Session URL. Redirect the user to complete payment.
4

Stripe webhook confirms subscription

After payment, Stripe sends a checkout.session.completed webhook. BunShip updates the local subscription record with the plan ID, status, and billing period.

Customer Portal

Stripe’s Customer Portal lets users manage their payment methods, view invoices, and cancel subscriptions without any custom UI:
const session = await stripe.billingPortal.sessions.create({
  customer: customerId,
  return_url: `${appConfig.url}/dashboard/billing`,
});
Call POST /api/v1/billing/portal to generate a portal session URL, then redirect the user.

Usage Tracking and Limits

BunShip tracks resource usage per organization and compares it against the current plan’s limits. The getUsage function queries counts in parallel:
const [
  [{ apiRequestCount }],
  [{ memberCount }],
  [{ projectCount }],
  [{ webhookCount }],
  [{ apiKeyCount }],
] = await Promise.all([
  db
    .select({ apiRequestCount: sql<number>`count(*)` })
    .from(auditLogs)
    .where(eq(auditLogs.organizationId, orgId)),
  db
    .select({ memberCount: sql<number>`count(*)` })
    .from(memberships)
    .where(eq(memberships.organizationId, orgId)),
  db
    .select({ projectCount: sql<number>`count(*)` })
    .from(projects)
    .where(eq(projects.organizationId, orgId)),
  db
    .select({ webhookCount: sql<number>`count(*)` })
    .from(webhooks)
    .where(eq(webhooks.organizationId, orgId)),
  db
    .select({ apiKeyCount: sql<number>`count(*)` })
    .from(apiKeys)
    .where(and(eq(apiKeys.organizationId, orgId), eq(apiKeys.isActive, true))),
]);
The response includes current usage, plan limits, and a percentage for each resource:
{
  "plan": {
    "id": "pro",
    "name": "Pro",
    "limits": {
      "members": 10,
      "projects": 25,
      "apiRequests": 100000,
      "webhookEndpoints": 10,
      "apiKeys": 10,
      "storageGB": 10
    }
  },
  "usage": {
    "members": { "current": 4, "limit": 10, "percentage": 40 },
    "projects": { "current": 8, "limit": 25, "percentage": 32 },
    "apiRequests": { "current": 12450, "limit": 100000, "percentage": 12.45 }
  }
}
A limit of -1 means unlimited (Enterprise plan). Use the helper functions from @bunship/config:
import { isUnlimited, isWithinLimit } from "@bunship/config";

if (!isWithinLimit(currentMembers, plan.limits.members)) {
  throw new ValidationError("Member limit reached. Upgrade your plan.");
}

Webhook Handling

BunShip automatically processes Stripe webhook events to keep subscription state in sync. The following events are handled:
EventAction
checkout.session.completedCreates or updates the subscription record with plan ID and Stripe subscription ID
customer.subscription.updatedUpdates status, billing period, and cancellation state
customer.subscription.deletedMarks subscription as canceled, resets to Free plan
invoice.payment_succeededUpdates payment status
invoice.payment_failedFlags subscription for follow-up
Set STRIPE_WEBHOOK_SECRET in your environment to verify webhook signatures. Without it, BunShip cannot validate that events come from Stripe.

Cancellation

Subscriptions cancel at the end of the current billing period rather than immediately. This gives users access to paid features until their prepaid time expires:
const updatedSubscription = await stripe.subscriptions.update(subscription.stripeSubscriptionId, {
  cancel_at_period_end: true,
});

await db
  .update(subscriptions)
  .set({
    cancelAtPeriodEnd: true,
    updatedAt: new Date(),
  })
  .where(eq(subscriptions.id, subscription.id));

Invoices

Retrieve an organization’s invoice history from Stripe:
const invoices = await stripe.invoices.list({
  customer: customerId,
  limit: 10,
});
Each invoice includes the amount, status, PDF download link, and hosted payment page URL.

Testing with Stripe Test Mode

During development, BunShip uses Stripe’s test mode. No real charges are made.
1

Set test keys

Use your Stripe test secret key and webhook secret in .env: bash STRIPE_SECRET_KEY=sk_test_... STRIPE_WEBHOOK_SECRET=whsec_...
2

Use test card numbers

Stripe provides test card numbers for different scenarios: - 4242 4242 4242 4242 — Successful payment - 4000 0000 0000 3220 — 3D Secure required - 4000 0000 0000 0002 — Declined
3

Forward webhooks locally

Use the Stripe CLI to forward webhook events to your local server: bash stripe listen --forward-to localhost:3000/api/v1/webhooks/stripe
4

Trigger test events

stripe trigger checkout.session.completed stripe trigger customer.subscription.updated

Environment Variables

VariableDescriptionRequired
STRIPE_SECRET_KEYStripe API secret keyYes
STRIPE_WEBHOOK_SECRETStripe webhook signing secretYes
STRIPE_PRO_MONTHLY_PRICE_IDStripe price ID for Pro monthlyYes (for paid plans)
STRIPE_PRO_YEARLY_PRICE_IDStripe price ID for Pro yearlyYes (for paid plans)
STRIPE_ENTERPRISE_MONTHLY_PRICE_IDStripe price ID for Enterprise monthlyYes (for paid plans)
STRIPE_ENTERPRISE_YEARLY_PRICE_IDStripe price ID for Enterprise yearlyYes (for paid plans)