Menu

Payment Flow

This document details the complete flow for users purchasing a pricing plan, including frontend interactions, API calls, payment processing, and result verification.

Flow Overview

User selects plan

Clicks purchase button

Create checkout session (POST /api/payment/checkout-session)

Redirect to payment provider page

User completes payment

Redirect back to app (GET /payment/success)

Verify payment status (GET /api/payment/verify-success)

Webhook processing (POST /api/stripe/webhook or /api/creem/webhook)

Order creation and credit allocation

Step 1: User Selects Plan

Users see all available pricing plans on the pricing page. Each plan card displays:

  • Plan title and description
  • Pricing information
  • Feature list
  • Purchase button

Frontend Components

The PricingCardDisplay component handles displaying plan cards:

<PricingCardDisplay
  plan={plan}
  localizedPlan={localizedPlan}
/>

The PricingCTA component handles purchase button click events:

<PricingCTA plan={plan} localizedPlan={localizedPlan} />

Step 2: Create Checkout Session

When the user clicks the purchase button, the frontend calls the /api/payment/checkout-session API to create a checkout session.

API Request

Endpoint: POST /api/payment/checkout-session

Request Body (Stripe):

{
  "provider": "stripe",
  "stripePriceId": "price_xxx",
  "couponCode": "DISCOUNT10"    // Optional
}

Request Body (Creem):

{
  "provider": "creem",
  "creemProductId": "prod_xxx",
  "couponCode": "DISCOUNT10"    // Optional
}

API Processing Logic

Stripe Checkout Session Creation

app/api/payment/checkout-session/route.ts
// 1. Verify user identity
const session = await getSession();
const user = session?.user;
if (!user) return apiResponse.unauthorized();
 
// 2. Get plan information
const plan = await db
  .select()
  .from(pricingPlansSchema)
  .where(eq(pricingPlansSchema.stripePriceId, stripePriceId))
  .limit(1);
 
// 3. Create or get Stripe Customer
const customerId = await getOrCreateStripeCustomer(user.id);
 
// 4. Create Stripe Checkout Session
const checkoutSession = await stripe.checkout.sessions.create({
  customer: customerId,
  line_items: [{ price: stripePriceId, quantity: 1 }],
  mode: isSubscription ? 'subscription' : 'payment',
  success_url: getURL('payment/success?session_id={CHECKOUT_SESSION_ID}&provider=stripe'),
  cancel_url: getURL(process.env.NEXT_PUBLIC_PRICING_PATH!),
  metadata: {
    userId: user.id,
    planId: plan.id,
    planName: plan.cardTitle,
    priceId: stripePriceId
  }
});
 
// 5. Return session URL
return apiResponse.success({
  sessionId: checkoutSession.id,
  url: checkoutSession.url
});

Creem Checkout Session Creation

app/api/payment/checkout-session/route.ts
// 1. Verify user identity
const session = await getSession();
const user = session?.user;
if (!user) return apiResponse.unauthorized();
 
// 2. Get plan information
const plan = await db
  .select()
  .from(pricingPlansSchema)
  .where(eq(pricingPlansSchema.creemProductId, creemProductId))
  .limit(1);
 
// 3. Create Creem Checkout Session
const checkoutSession = await createCreemCheckoutSession({
  product_id: creemProductId,
  units: 1,
  discount_code: couponCode,
  customer: {
    email: user.email
  },
  success_url: getURL('payment/success?provider=creem'),
  metadata: {
    userId: user.id,
    planId: plan.id,
    planName: plan.cardTitle,
    productId: creemProductId
  }
});
 
// 4. Return session URL
return apiResponse.success({
  sessionId: checkoutSession.id,
  url: checkoutSession.checkout_url
});

Frontend Processing

components/home/PricingCTA.tsx
const handleCheckout = async () => {
  setIsLoading(true);
  
  try {
    const response = await fetch('/api/payment/checkout-session', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Accept-Language': locale
      },
      body: JSON.stringify({
        provider: plan.provider,
        stripePriceId: plan.stripePriceId,  // Stripe
        creemProductId: plan.creemProductId, // Creem
        couponCode: plan.stripeCouponId || plan.creemDiscountCode // At most one exists
      })
    });
 
    const result = await response.json();
 
    if (!response.ok) {
      throw new Error(result.error || 'Failed to create checkout session');
    }
 
    if (result.data.url) {
      // Redirect to payment page
      router.push(result.data.url);
    }
  } catch (error) {
    toast.error(error.message);
  } finally {
    setIsLoading(false);
  }
};

Step 3: User Completes Payment

The user is redirected to the payment provider's payment page (Stripe Checkout or Creem Checkout) to complete the payment flow.

Stripe Checkout

  • User enters payment information
  • Stripe processes payment
  • After successful payment, redirects to success_url

Creem Checkout

  • User enters payment information
  • Creem processes payment
  • After successful payment, redirects to success_url

Step 4: Payment Success Callback

After successful payment, the user is redirected back to the application's payment/success page.

Page Route

app/[locale]/payment/success/page.tsx
import { redirect } from 'next/navigation';
import { Suspense } from 'react';
 
export default function PaymentSuccessPage({
  searchParams
}: {
  searchParams: { session_id?: string; checkout_id?: string; provider?: string }
}) {
  const provider = searchParams.provider;
  
  if (!provider) {
    redirect('/pricing');
  }
 
  return (
    <div className="container mx-auto py-12">
      <Suspense fallback={<div>Verifying payment...</div>}>
        <PaymentVerification
          provider={provider}
          sessionId={searchParams.session_id}
          checkoutId={searchParams.checkout_id}
        />
      </Suspense>
    </div>
  );
}

Payment Verification Component

async function PaymentVerification({
  provider,
  sessionId,
  checkoutId
}: {
  provider: string;
  sessionId?: string;
  checkoutId?: string;
}) {
  const session = await getSession();
  if (!session?.user) {
    redirect('/login');
  }
 
  // Call verification API
  const verifyUrl = new URL('/api/payment/verify-success', process.env.NEXT_PUBLIC_SITE_URL);
  verifyUrl.searchParams.set('provider', provider);
  if (provider === 'stripe' && sessionId) {
    verifyUrl.searchParams.set('session_id', sessionId);
  }
  if (provider === 'creem' && checkoutId) {
    verifyUrl.searchParams.set('checkout_id', checkoutId);
  }
 
  const response = await fetch(verifyUrl.toString(), {
    headers: {
      Cookie: cookies().toString()
    }
  });
 
  const result = await response.json();
 
  if (!result.success) {
    return (
      <div className="text-center">
        <h1 className="text-2xl font-bold text-red-600">Payment Verification Failed</h1>
        <p>{result.error}</p>
      </div>
    );
  }
 
  // Display success message
  return (
    <div className="text-center">
      <h1 className="text-2xl font-bold text-green-600">Payment Successful!</h1>
      <p>Your order has been processed successfully.</p>
      {result.data.order && (
        <div className="mt-4">
          <p>Order ID: {result.data.order.id}</p>
        </div>
      )}
    </div>
  );
}

Step 5: Verify Payment Status

The /api/payment/verify-success API is responsible for verifying payment status and returning order information.

API Processing Flow

Stripe Verification

app/api/payment/verify-success/stripe-handler.ts
export async function verifyStripePayment(req: NextRequest, userId: string) {
  const sessionId = req.nextUrl.searchParams.get('session_id');
  
  // 1. Retrieve Checkout Session
  const session = await stripe.checkout.sessions.retrieve(sessionId, {
    expand: ['line_items', 'payment_intent', 'subscription']
  });
 
  // 2. Verify user ID
  if (session.metadata?.userId !== userId) {
    return apiResponse.unauthorized('User ID mismatch');
  }
 
  // 3. Verify session status
  if (session.status !== 'complete') {
    return apiResponse.badRequest(`Session status is not complete: ${session.status}`);
  }
 
  // 4. Handle subscription or one-time payment
  if (session.mode === 'subscription') {
    return handleStripeSubscription(session, userId, sessionId);
  } else if (session.mode === 'payment') {
    return handleStripePayment(session, userId);
  }
}

Creem Verification

app/api/payment/verify-success/creem-handler.ts
export async function verifyCreemPayment(req: NextRequest, userId: string) {
  const checkoutId = req.nextUrl.searchParams.get('checkout_id');
  
  // 1. Retrieve Checkout Session
  const session = await retrieveCreemCheckoutSession(checkoutId);
 
  // 2. Verify user ID
  if (session.metadata?.userId !== userId) {
    return apiResponse.unauthorized('User ID mismatch');
  }
 
  // 3. Verify payment status
  if (session.status !== 'completed') {
    return apiResponse.badRequest(`Checkout status is not completed: ${session.status}`);
  }
 
  // 4. Handle subscription or one-time payment
  if (session.order.type === 'recurring') {
    return handleCreemSubscription(session, userId, checkoutId);
  } else {
    return handleCreemPayment(session, userId);
  }
}

Response Data Structure

One-time Payment:

{
  "success": true,
  "data": {
    "order": {
      "id": "order-id",
      "status": "succeeded",
      "amountTotal": "29.00",
      "currency": "USD"
    }
  }
}

Subscription Payment:

{
  "success": true,
  "data": {
    "subscription": {
      "id": "subscription-id",
      "status": "active",
      "currentPeriodEnd": "2024-02-01T00:00:00Z"
    }
  }
}

Step 6: Webhook Processing

Payment providers send webhook events after payment completion. Webhook processing is a critical part of the payment flow, responsible for:

  1. Creating order records
  2. Granting user credits
  3. Synchronizing subscription status

Webhook Events

Stripe Webhook Events

  • checkout.session.completed - Checkout completed (one-time payment)
  • invoice.paid - Invoice payment successful (subscription renewal)
  • customer.subscription.created - Subscription created
  • customer.subscription.updated - Subscription updated
  • customer.subscription.deleted - Subscription canceled
  • charge.refunded - Refund

Creem Webhook Events

  • checkout.completed - Checkout completed (one-time payment)
  • subscription.paid - Subscription payment successful
  • subscription.active - Subscription activated
  • subscription.update - Subscription updated
  • subscription.canceled - Subscription canceled
  • subscription.expired - Subscription expired
  • refund.created - Refund

For detailed webhook processing logic, please refer to Webhook Handling Mechanism.

Payment Flow Sequence Diagram

User Browser        Frontend App      Backend API       Payment Provider
    |                |                |                  |
    |--Click Buy---->|                |                  |
    |                |--Create Sess-->|                  |
    |                |                |--Create Sess---->|
    |                |                |<--Return URL-----|
    |                |<--Return URL---|                  |
    |<--Redirect-----|                |                  |
    |                |                |                  |
    |--Go to Payment->|               |                  |
    |                |                |                  |
    |--Complete Pay-->|               |                  |
    |                |                |                  |
    |<--Redirect Back-|               |                  |
    |                |                |                  |
    |--Verify Pay--->|--Verify Pay--->|                  |
    |                |                |--Query Order---->|
    |                |                |<--Return Order---|
    |                |<--Return Result-|                 |
    |<--Show Result--|                |                  |
    |                |                |                  |
    |                |                |<--Webhook Event--|
    |                |                |--Process Event-->|
    |                |                |--Create Order----|
    |                |                |--Grant Credits---|

Security Considerations

1. User Authentication

All payment-related APIs require user login:

const session = await getSession();
if (!session?.user) {
  return apiResponse.unauthorized();
}

2. Webhook Signature Verification

All webhook requests must verify signatures:

Stripe:

const sig = headers.get('stripe-signature');
const event = stripe.webhooks.constructEvent(body, sig, webhookSecret);

Creem:

const signature = headers.get('creem-signature');
if (!verifySignature(body, signature, webhookSecret)) {
  return apiResponse.badRequest('Signature verification failed');
}

3. Idempotency Handling

Order creation uses idempotency checks to avoid duplicate creation:

const { order, existed } = await createOrderWithIdempotency(
  provider,
  orderData,
  providerOrderId
);
 
if (existed) {
  return; // Order already exists, skip processing
}

4. User ID Verification

Verify that the user ID in the payment session matches the currently logged-in user:

if (session.metadata?.userId !== userId) {
  return apiResponse.unauthorized('User ID mismatch');
}

Testing Payment Flow

Using Stripe Test Mode

  1. Get test API Key from Stripe Dashboard
  2. Create test products and prices
  3. Use test card number: 4242 4242 4242 4242
  4. Configure Webhook URL to point to your application or use Stripe CLI to forward webhooks: stripe listen --forward-to localhost:3000/api/stripe/webhook

Using Creem Test Mode

  1. Get test API Key from Creem Dashboard
  2. Create test products
  3. Use test card number: 4242 4242 4242 4242
  4. Configure Webhook URL to point to your application