ShipAddons

Stripe Subscriptions

Plans, upgrades, downgrades, and webhook handling

ShipAddons implements a complete subscription system with Stripe, supporting multiple plans, upgrades, downgrades, and webhook-driven state management.

Subscription Architecture

┌─────────────┐    checkout    ┌─────────────┐
│   Add-on    │───────────────>│   Backend   │
│   Client    │                │   (/api)    │
└─────────────┘                └──────┬──────┘

                               ┌──────▼──────┐
                               │   Stripe    │
                               │  Checkout   │
                               └──────┬──────┘

                               ┌──────▼──────┐
                               │   Webhook   │───> Supabase
                               │   Handler   │
                               └─────────────┘

State changes flow through Stripe webhooks—the add-on never directly updates subscription state.

Plans Configuration

Plans are defined in constants.ts:

export interface Plan {
  id: "BASIC" | "ESSENTIAL" | "PRO";
  name: string;
  description: string;
  imageUrls: string[];
  priceInCents: number;
  mode: "payment" | "subscription";
  features: string[];
  stripePriceId?: string; // Populated by web/scripts/stripe-sync.ts
}

export const plans: Record<Plan["id"], Plan> = {
  BASIC: {
    id: "BASIC",
    name: "Basic",
    description: "Basic plan",
    imageUrls: ["https://dummyimage.com/96x96/9/fff.png&text=Logo"],
    priceInCents: 0,
    mode: "subscription",
    features: ["basic_feature"],
  },
  ESSENTIAL: {
    id: "ESSENTIAL",
    name: "Essential",
    description: "Essential plan",
    imageUrls: ["https://dummyimage.com/96x96/9/fff.png&text=Logo"],
    priceInCents: 1000,
    mode: "subscription",
    features: ["essential_feature"],
    stripePriceId: "price_1SedRfRgqD2Y1UgSc0mp1Vcb",
  },
  PRO: {
    id: "PRO",
    name: "Pro",
    description: "Pro plan",
    imageUrls: ["https://dummyimage.com/96x96/9/fff.png&text=Logo"],
    priceInCents: 2000,
    mode: "subscription",
    features: ["pro_feature"],
    stripePriceId: "price_1SedRgRgqD2Y1UgSfiv60YIY",
  },
};

Checkout Flow

1. Client Initiates Checkout

// In add-on client
const { purchase } = usePurchase();

const handleUpgrade = async (packageId: string) => {
  const checkoutUrl = await purchase(packageId);
  window.open(checkoutUrl, "_blank");
};

2. Backend Creates Checkout Session

// POST /api/stripe/checkout
export async function POST(request: NextRequest) {
  // Verify JWT and get user
  const user = await validateRequest(request);

  // Get package details
  const { packageId } = await request.json();
  const pkg = packages[packageId];

  // Check for existing customer
  const { data: subscription } = await supabaseAdmin
    .from("subscriptions")
    .select("stripe_customer_id")
    .eq("user_id", user.id)
    .single();

  // Create checkout session
  const session = await stripe.checkout.sessions.create({
    mode: "subscription",
    line_items: [
      {
        price: pkg.stripePriceId,
        quantity: 1,
      },
    ],
    customer: subscription?.stripe_customer_id,
    customer_email: !subscription ? user.email : undefined,
    client_reference_id: user.id,
    success_url: `${process.env.NEXT_PUBLIC_URL}/pricing?success=true`,
    cancel_url: `${process.env.NEXT_PUBLIC_URL}/pricing?canceled=true`,
    metadata: {
      user_id: user.id,
      package_id: packageId,
    },
  });

  return Response.json({ data: session.url });
}

3. User Completes Payment

Stripe handles the payment flow on their hosted checkout page. After completion, the user is redirected to success_url.

4. Webhook Updates Database

Stripe sends a checkout.session.completed event to your webhook endpoint.

Webhook Events

checkout.session.completed

Fired when a customer completes checkout.

case "checkout.session.completed": {
  const session = event.data.object as Stripe.Checkout.Session;

  const userId = session.client_reference_id;
  const customerId = session.customer as string;
  const subscriptionId = session.subscription as string;
  const packageId = session.metadata?.package_id;

  // Get subscription details
  const subscription = await stripe.subscriptions.retrieve(subscriptionId);

  // Upsert subscription record
  await supabaseAdmin.from("subscriptions").upsert({
    user_id: userId,
    stripe_customer_id: customerId,
    stripe_subscription_id: subscriptionId,
    status: subscription.status,
    plan_id: packageId,
    current_period_start: new Date(subscription.current_period_start * 1000),
    current_period_end: new Date(subscription.current_period_end * 1000),
  }, {
    onConflict: "user_id",
  });

  // Log the event
  await supabaseAdmin.from("audit_logs").insert({
    user_id: userId,
    action: "SUBSCRIPTION_CREATED",
    context: JSON.stringify({ plan_id: packageId }),
  });

  break;
}

customer.subscription.updated

Fired when a subscription changes (plan change, renewal, status change).

case "customer.subscription.updated": {
  const subscription = event.data.object as Stripe.Subscription;

  // Determine new plan from price ID
  const priceId = subscription.items.data[0]?.price.id;
  const newPlanId = Object.entries(packages)
    .find(([, pkg]) => pkg.stripePriceId === priceId)?.[0];

  // Update subscription record
  await supabaseAdmin
    .from("subscriptions")
    .update({
      status: subscription.status,
      plan_id: newPlanId,
      current_period_start: new Date(subscription.current_period_start * 1000),
      current_period_end: new Date(subscription.current_period_end * 1000),
    })
    .eq("stripe_subscription_id", subscription.id);

  // Log status changes
  await supabaseAdmin.from("audit_logs").insert({
    user_id: userId,
    action: "SUBSCRIPTION_UPDATED",
    context: JSON.stringify({
      status: subscription.status,
      plan_id: newPlanId,
    }),
  });

  break;
}

customer.subscription.deleted

Fired when a subscription is canceled (immediately or at period end).

case "customer.subscription.deleted": {
  const subscription = event.data.object as Stripe.Subscription;

  await supabaseAdmin
    .from("subscriptions")
    .update({ status: "canceled" })
    .eq("stripe_subscription_id", subscription.id);

  break;
}

Billing Portal

Allow customers to manage their subscription through Stripe's hosted billing portal.

Backend Endpoint

// POST /api/stripe/billing_portal
export async function POST(request: NextRequest) {
  const user = await validateRequest(request);

  // Get customer ID
  const { data: subscription } = await supabaseAdmin
    .from("subscriptions")
    .select("stripe_customer_id")
    .eq("user_id", user.id)
    .single();

  if (!subscription?.stripe_customer_id) {
    return Response.json({ error: "No subscription found" }, { status: 404 });
  }

  // Create portal session
  const session = await stripe.billingPortal.sessions.create({
    customer: subscription.stripe_customer_id,
    return_url: `${process.env.NEXT_PUBLIC_URL}/pricing`,
    configuration: process.env.STRIPE_PORTAL_CONFIG_ID,
  });

  return Response.json({ data: session.url });
}

Client Usage

const { openBillingPortal } = usePurchase();

const handleManageSubscription = async () => {
  await openBillingPortal();
  // Opens Stripe billing portal in new tab
};

Upgrades and Downgrades

Stripe handles proration automatically when customers switch plans via the billing portal.

  • Upgrades: Customer is charged the prorated difference immediately
  • Downgrades: Credit is applied to the next billing cycle

No additional code is needed—the customer.subscription.updated webhook handles plan changes.

Checking Subscription Status

In the Add-on Client

const { user, subscription } = useAuth();

// Check if user has active subscription
const hasActiveSubscription = subscription?.status === "active";

// Check specific plan
const isPro = subscription?.plan_id === "PRO";

// Check if subscription exists
const hasSubscription = !!subscription;

In the Backend

// Get subscription for user
const { data: subscription } = await supabaseAdmin
  .from("subscriptions")
  .select("*")
  .eq("user_id", userId)
  .single();

// Check access
if (subscription?.status !== "active") {
  return Response.json({ error: "Subscription required" }, { status: 403 });
}

// Check plan level
const allowedPlans = ["ESSENTIAL", "PRO"];
if (!allowedPlans.includes(subscription.plan_id)) {
  return Response.json({ error: "Upgrade required" }, { status: 403 });
}

Testing Subscriptions

Use Stripe test mode and test cards:

Card NumberScenario
4242 4242 4242 4242Successful payment
4000 0000 0000 0002Card declined
4000 0000 0000 32203D Secure required

Trigger test webhooks via CLI:

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

On this page