Stripe API
The Stripe API handles subscription management, token purchases, and production order payments.
Endpoints
| Method | Endpoint | Description |
|---|
| POST | /api/stripe/checkout | Create checkout session |
| POST | /api/stripe/portal | Create customer portal session |
| POST | /api/stripe/webhook | Handle Stripe webhooks |
Checkout Session
POST /api/stripe/checkout
Request
interface CheckoutRequest {
priceId: string; // Stripe Price ID
mode: "subscription" | "payment"; // Subscription or one-time
successUrl?: string; // Override success redirect
cancelUrl?: string; // Override cancel redirect
}
Subscription Checkout
const response = await fetch("/api/stripe/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
priceId: "price_pro_monthly",
mode: "subscription",
}),
});
const { url } = await response.json();
window.location.href = url; // Redirect to Stripe
Token Pack Purchase
const response = await fetch("/api/stripe/checkout", {
method: "POST",
body: JSON.stringify({
priceId: "price_tokens_50",
mode: "payment",
}),
});
Response
{
"url": "https://checkout.stripe.com/c/pay/cs_xxx",
"sessionId": "cs_xxx"
}
Customer Portal
Opens Stripe’s customer portal for subscription management.
Request
const response = await fetch("/api/stripe/portal", {
method: "POST",
});
const { url } = await response.json();
window.location.href = url;
Customers can:
- Update payment method
- View invoices
- Cancel subscription
- Change plan
Webhook Handler
Handles Stripe events for subscription lifecycle.
Verification
// app/api/stripe/webhook/route.ts
export async function POST(req: Request) {
const body = await req.text();
const signature = req.headers.get("stripe-signature")!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
return Response.json({ error: "Invalid signature" }, { status: 400 });
}
// Handle event...
}
Handled Events
checkout.session.completed
New subscription or token purchase completed
invoice.paid
Subscription renewal—grant monthly tokens
customer.subscription.updated
Plan change or status update
customer.subscription.deleted
Subscription canceled
Event Handling
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session;
if (session.mode === 'subscription') {
await createSubscription(session);
} else if (session.mode === 'payment') {
// Token pack purchase
await grantTokens(session);
}
break;
}
case 'invoice.paid': {
const invoice = event.data.object as Stripe.Invoice;
if (invoice.subscription) {
await grantMonthlyTokens(invoice);
}
break;
}
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription;
await cancelSubscription(subscription);
break;
}
}
Subscription Creation
On checkout completion:
async function createSubscription(session: Stripe.Checkout.Session) {
const userId = session.metadata?.userId;
const subscriptionId = session.subscription as string;
// Fetch Stripe subscription details
const stripeSub = await stripe.subscriptions.retrieve(subscriptionId);
const priceId = stripeSub.items.data[0].price.id;
// Find matching plan
const { data: plan } = await supabase
.from('billing_plans')
.select('*')
.eq('stripe_price_id', priceId)
.single();
// Create subscription record
await supabase
.from('subscriptions')
.insert({
user_id: userId,
plan_id: plan.id,
status: 'active',
external_reference: subscriptionId,
current_period_start: new Date(stripeSub.current_period_start * 1000).toISOString(),
current_period_end: new Date(stripeSub.current_period_end * 1000).toISOString(),
});
// Grant initial tokens
await grantProjectTokens({
projectId: session.metadata?.projectId,
userId,
tokens: plan.monthly_tokens,
metadata: { source: 'subscription_start' },
});
}
Monthly Token Grant
On invoice payment:
async function grantMonthlyTokens(invoice: Stripe.Invoice) {
const { data: subscription } = await supabase
.from('subscriptions')
.select('*, plan:billing_plans(*)')
.eq('external_reference', invoice.subscription)
.single();
if (!subscription) return;
// Grant tokens for new period
await supabase
.from('token_grants')
.insert({
user_id: subscription.user_id,
subscription_id: subscription.id,
source: 'plan',
tokens: subscription.plan.monthly_tokens,
expires_at: subscription.plan.rollover
? null
: subscription.current_period_end,
});
// Update period dates
await supabase
.from('subscriptions')
.update({
current_period_start: new Date(invoice.period_start * 1000).toISOString(),
current_period_end: new Date(invoice.period_end * 1000).toISOString(),
})
.eq('id', subscription.id);
}
Token Pack Grant
async function grantTokens(session: Stripe.Checkout.Session) {
const userId = session.metadata?.userId;
const tokens = parseInt(session.metadata?.tokens || '0');
await supabase
.from('token_grants')
.insert({
user_id: userId,
source: 'payg_purchase',
tokens,
metadata: {
stripe_session_id: session.id,
amount_paid: session.amount_total,
},
});
}
Environment Variables
STRIPE_SECRET_KEY=sk_live_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_xxx
# Price IDs
STRIPE_PRICE_ID_PRO=price_xxx
STRIPE_PRICE_ID_STUDIO=price_xxx
STRIPE_PRICE_ID_TOKENS_50=price_xxx
STRIPE_PRICE_ID_TOKENS_200=price_xxx
Webhook Setup
Local Development
# Install Stripe CLI
brew install stripe/stripe-cli/stripe
# Login
stripe login
# Forward webhooks
stripe listen --forward-to localhost:3000/api/stripe/webhook
Copy the webhook signing secret to .env.local.
Production
- Go to Stripe Dashboard → Developers → Webhooks
- Add endpoint:
https://yourapp.com/api/stripe/webhook
- Select events:
checkout.session.completed
invoice.paid
customer.subscription.updated
customer.subscription.deleted
- Copy signing secret to Vercel environment variables
Error Handling
// Webhook errors
if (!signature) {
return Response.json(
{ error: "Missing stripe-signature header" },
{ status: 400 }
);
}
try {
event = stripe.webhooks.constructEvent(body, signature, secret);
} catch (err) {
console.error("Webhook signature verification failed:", err);
return Response.json({ error: "Invalid signature" }, { status: 400 });
}
// Always return 200 to acknowledge receipt
return Response.json({ received: true });
Always return 200 from webhooks, even on internal errors. Otherwise Stripe will retry indefinitely.
Testing
Use Stripe test mode for development:
STRIPE_SECRET_KEY=sk_test_xxx
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_xxx
Test card numbers:
- Success:
4242 4242 4242 4242
- Decline:
4000 0000 0000 0002
- 3D Secure:
4000 0025 0000 3155