Docs/modules/Payments

Payments

NextShip supports multiple payment providers for subscriptions, one-time purchases, and credit-based billing.

Supported Providers

ProviderFeatures
StripeGlobal payments, subscriptions, customer portal
CreemAlternative provider, same feature set

Switch providers via environment variable:

PAYMENT_PROVIDER=stripe  # or "creem"

Configuration

Stripe

STRIPE_SECRET_KEY=sk_test_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_xxx

Creem

CREEM_API_KEY=ck_test_xxx
CREEM_WEBHOOK_SECRET=xxx

Payment Types

1. Subscriptions

Recurring payments for plans (Free, Pro, Enterprise).

// src/server/actions/subscription.ts
export async function createCheckoutSession(plan: string, interval: "monthly" | "yearly") {
  const session = await requireAuth();
 
  // Creates Stripe/Creem checkout session
  const checkoutSession = await stripe.checkout.sessions.create({
    mode: "subscription",
    line_items: [{ price: priceId, quantity: 1 }],
    success_url: `${baseUrl}/billing?success=true`,
    cancel_url: `${baseUrl}/pricing`,
    metadata: { userId: session.user.id },
  });
 
  return { url: checkoutSession.url };
}

2. Credit Purchases (One-time)

Buy credit packages for API usage.

// src/server/actions/credit-purchase.ts
export async function createCreditCheckoutSession(params: {
  skuId: string;
  successUrl: string;
  cancelUrl: string;
}) {
  const session = await requireAuth();
  const sku = await getSkuById(params.skuId);
 
  // Creates checkout for credit package
  const checkoutSession = await stripe.checkout.sessions.create({
    mode: "payment",
    line_items: [{ price: sku.stripePriceId, quantity: 1 }],
    metadata: {
      userId: session.user.id,
      skuId: sku.id,
      credits: sku.credits.toString(),
      type: "credit_purchase",
    },
  });
 
  return { url: checkoutSession.url };
}

Webhook Handling

Webhooks sync payment events with your database.

Stripe Webhooks

// src/app/api/stripe/webhook/route.ts
export async function POST(req: Request) {
  const body = await req.text();
  const signature = req.headers.get("stripe-signature")!;
 
  const event = stripe.webhooks.constructEvent(
    body,
    signature,
    process.env.STRIPE_WEBHOOK_SECRET!
  );
 
  switch (event.type) {
    case "checkout.session.completed":
      const session = event.data.object;
      if (session.metadata?.type === "credit_purchase") {
        // Add credits to user
        await addCredits(session.metadata.userId, Number(session.metadata.credits));
      } else {
        // Handle subscription
        await handleSubscriptionCreated(session);
      }
      break;
    case "customer.subscription.updated":
      await handleSubscriptionUpdate(event.data.object);
      break;
    case "customer.subscription.deleted":
      await handleSubscriptionDelete(event.data.object);
      break;
  }
 
  return new Response("OK");
}

Local Testing

# Install Stripe CLI
brew install stripe/stripe-cli/stripe
 
# Login to Stripe
stripe login
 
# Forward webhooks to local server
pnpm stripe:listen

Credit System (SKUs)

Credit packages are managed as SKUs in the admin dashboard.

SKU Schema

// src/lib/db/schema.ts
export const skus = pgTable("skus", {
  id: text("id").primaryKey(),
  name: text("name").notNull(),
  credits: integer("credits").notNull(),
  price: integer("price").notNull(),        // in cents
  currency: text("currency").default("usd"),
  stripePriceId: text("stripe_price_id"),
  creemProductId: text("creem_product_id"),
  isActive: boolean("is_active").default(true),
});

Admin Management

SKUs can be created/edited in /skus admin page:

  • Name and description
  • Credit amount
  • Price (in cents)
  • Stripe Price ID / Creem Product ID
  • Active/Inactive status

Customer Portal

Allow users to manage their subscription:

// src/server/actions/subscription.ts
export async function createPortalSession() {
  const session = await requireAuth();
 
  const portalSession = await stripe.billingPortal.sessions.create({
    customer: session.user.stripeCustomerId,
    return_url: `${baseUrl}/billing`,
  });
 
  return { url: portalSession.url };
}

Checking Subscription Status

// src/lib/permissions.ts
export async function requireSubscription(plan: string) {
  const session = await requireAuth();
 
  const subscription = await db.query.subscriptions.findFirst({
    where: eq(subscriptions.userId, session.user.id),
  });
 
  if (subscription?.plan !== plan || subscription?.status !== "active") {
    throw new Error("Subscription required");
  }
 
  return session;
}

Best Practices

  • Always verify webhook signatures
  • Store customer IDs in your database
  • Handle failed payments gracefully
  • Test with test mode before going live
  • Use idempotency keys for retries