Payments
NextShip supports multiple payment providers for subscriptions, one-time purchases, and credit-based billing.
Supported Providers
| Provider | Features |
|---|---|
| Stripe | Global payments, subscriptions, customer portal |
| Creem | Alternative 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_xxxCreem
CREEM_API_KEY=ck_test_xxx
CREEM_WEBHOOK_SECRET=xxxPayment 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:listenCredit 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