Skip to main content

Stripe API

The Stripe API handles subscription management, token purchases, and production order payments.

Endpoints

MethodEndpointDescription
POST/api/stripe/checkoutCreate checkout session
POST/api/stripe/portalCreate customer portal session
POST/api/stripe/webhookHandle 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

POST /api/stripe/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

POST /api/stripe/webhook
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

  1. Go to Stripe Dashboard → Developers → Webhooks
  2. Add endpoint: https://yourapp.com/api/stripe/webhook
  3. Select events:
    • checkout.session.completed
    • invoice.paid
    • customer.subscription.updated
    • customer.subscription.deleted
  4. 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