We use tracking cookies to understand how you use the product and help us improve it. For more information on how we store cookies, read our  privacy policy.

Usage & Integration

Practical examples of using Stripe payments in Plainform with checkout, webhooks, and pricing display

Learn how to use Stripe payments in your Plainform application with practical examples.

Creating Checkout Sessions

Create a checkout session to accept payments. Plainform uses a form-based approach:

components/pricing/PricingCard.tsx (simplified)
export function PricingCard({ priceId, couponId }) {
  return (
    <form action="/api/stripe/checkout" method="POST">
      <input type="hidden" name="priceId" value={priceId} />
      {couponId && (
        <input type="hidden" name="couponId" value={couponId} />
      )}
      <button type="submit">Buy Now</button>
    </form>
  );
}

Checkout API Route

The checkout route creates a Stripe session with manual capture:

app/api/stripe/checkout/route.ts
import { stripe } from '@/lib/stripe/stripe';
import { NextRequest, NextResponse } from 'next/server';

export async function POST(req: NextRequest) {
  const formData = await req.formData();
  const priceId = formData.get('priceId')?.toString();
  const couponId = formData.get('couponId')?.toString();

  if (!priceId) {
    return NextResponse.json(
      { message: 'Invalid request' },
      { status: 400 }
    );
  }

  try {
    // Get price to determine payment mode
    const price = await stripe.prices.retrieve(priceId);
    const mode = price.type === 'recurring' ? 'subscription' : 'payment';

    const session = await stripe.checkout.sessions.create({
      line_items: [{ price: priceId, quantity: 1 }],
      discounts: couponId ? [{ coupon: couponId }] : undefined,
      mode,
      success_url: `${process.env.SITE_URL}/order?session_id={CHECKOUT_SESSION_ID}`,
      cancel_url: `${process.env.SITE_URL}`,
      automatic_tax: { enabled: true },
      
      // Manual capture for one-time payments
      ...(mode === 'payment' && {
        payment_intent_data: {
          capture_method: 'manual',
        },
      }),
    });

    return NextResponse.redirect(session.url!, { status: 303 });
  } catch (error) {
    console.error('Checkout error:', error);
    return NextResponse.json(
      { message: 'Checkout failed' },
      { status: 500 }
    );
  }
}

Key points:

  • Determines mode (payment vs subscription) from price type
  • Uses manual capture for one-time payments
  • Redirects to Stripe-hosted checkout page
  • Includes automatic tax calculation

Handling Webhooks

Webhooks notify your app of payment events. The webhook route verifies signatures and processes events:

app/api/stripe/webhook/route.ts (simplified)
import { stripe } from '@/lib/stripe/stripe';
import { NextRequest, NextResponse } from 'next/server';
import { revalidateTag } from 'next/cache';

export async function POST(req: NextRequest) {
  const rawBody = await req.text();
  const signature = req.headers.get('stripe-signature')!;

  let event;

  try {
    // Verify webhook signature
    event = stripe.webhooks.constructEvent(
      rawBody,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch (error) {
    console.error('Webhook signature verification failed:', error);
    return NextResponse.json(
      { message: 'Invalid signature' },
      { status: 400 }
    );
  }

  // Process events
  switch (event.type) {
    case 'checkout.session.completed':
      await handleCheckoutCompleted(event.data.object);
      revalidateTag('stripe/orders');
      break;
      
    case 'product.created':
    case 'product.updated':
    case 'product.deleted':
      revalidateTag('stripe/products');
      break;
      
    case 'coupon.created':
    case 'coupon.updated':
      revalidateTag('stripe/coupons');
      break;
      
    default:
      console.log(`Unhandled event type: ${event.type}`);
  }

  return NextResponse.json({ received: true });
}

Key points:

  • Always verify webhook signature for security
  • Use req.text() to get raw body (required for signature verification)
  • Revalidate Next.js cache tags after data changes
  • Handle events asynchronously

Processing Checkout Completion

When a checkout completes, capture the payment and send confirmation:

Checkout completion handler (simplified)
async function handleCheckoutCompleted(session) {
  const paymentIntentId = session.payment_intent;
  const customerEmail = session.customer_details?.email;

  if (!customerEmail) {
    // Cancel payment if email missing
    await cancelPaymentIntent(paymentIntentId);
    return;
  }

  try {
    // Capture payment (for manual capture mode)
    if (paymentIntentId) {
      await capturePaymentIntent(paymentIntentId);
    }

    // Update session metadata
    await stripe.checkout.sessions.update(session.id, {
      metadata: {
        orderStatus: 'Payment successful',
        paymentStatus: 'Accepted',
      },
    });

    // Send confirmation email
    await sendOrderConfirmation(customerEmail);

    return { success: true };
  } catch (error) {
    console.error('Payment capture failed:', error);
    await cancelPaymentIntent(paymentIntentId);
    throw error;
  }
}

Key points:

  • Validates customer email before processing
  • Captures payment for manual capture mode
  • Updates session metadata for order tracking
  • Sends confirmation email
  • Cancels payment on error

Manual Payment Capture

Plainform uses manual capture to give you control over when payments are charged. This allows you to implement custom business logic between payment authorization and capture.

Why manual capture?

  • Run custom validation logic before charging
  • Verify inventory availability
  • Grant user access or credits
  • Provision resources (API keys, licenses, etc.)
  • Send custom notifications
  • Update your database before finalizing payment

Capture or cancel payments using helper functions:

Capture Payment

lib/stripe/capturePaymentIntent.ts
import { stripe } from '@/lib/stripe/stripe';

export async function capturePaymentIntent(paymentIntentId: string) {
  try {
    const paymentIntent = await stripe.paymentIntents.capture(
      paymentIntentId
    );

    return {
      ok: true,
      paymentIntent,
      orderStatus: 'Payment captured successfully',
    };
  } catch (error) {
    console.error('Capture failed:', error);
    return {
      ok: false,
      orderStatus: 'Payment capture failed',
      message: 'Unable to process payment',
    };
  }
}

Cancel Payment

lib/stripe/cancelPaymentIntent.ts
import { stripe } from '@/lib/stripe/stripe';

export async function cancelPaymentIntent(paymentIntentId: string) {
  try {
    const paymentIntent = await stripe.paymentIntents.cancel(
      paymentIntentId
    );

    return {
      ok: true,
      paymentIntent,
      message: 'Payment cancelled successfully',
    };
  } catch (error) {
    console.error('Cancel failed:', error);
    return {
      ok: false,
      message: 'Unable to cancel payment',
    };
  }
}

Use cases:

  • Capture: After running your custom business logic (inventory check, user provisioning, etc.)
  • Cancel: If validation fails, order is invalid, or customer requests cancellation

Example workflow:

// 1. Payment authorized (not charged yet)
// 2. Run your custom logic
const inventoryAvailable = await checkInventory(productId);
const userAccount = await getUserAccount(customerEmail);

// 3. Capture or cancel based on your logic
if (inventoryAvailable && userAccount) {
  await capturePaymentIntent(paymentIntentId);
  await grantUserAccess(userAccount.id, productId);
  await sendWelcomeEmail(customerEmail);
} else {
  await cancelPaymentIntent(paymentIntentId);
  await notifyCustomer('Order could not be processed');
}

You can also capture or cancel payments manually in the Stripe Dashboard under Payments → Uncaptured.

Displaying Products & Pricing

Fetch products from Stripe and display them in your pricing section:

Fetch Products

lib/stripe/getProducts.ts
export async function getProducts() {
  try {
    const res = await fetch(`${process.env.SITE_URL}/api/stripe/products`, {
      cache: 'force-cache',
      next: { tags: ['stripe/products'] },
    });

    if (!res.ok) {
      throw new Error('Failed to fetch products');
    }

    return res.json();
  } catch (error) {
    console.error('Product fetch error:', error);
    return null;
  }
}

Products API Route

app/api/stripe/products/route.ts
import { stripe } from '@/lib/stripe/stripe';
import { NextResponse } from 'next/server';

export async function GET() {
  try {
    const products = await stripe.products.list({
      active: true,
      expand: ['data.default_price'],
    });

    const activeProducts = products.data.filter((p) => p.active);
    const sortedProducts = activeProducts.sort((a, b) => a.created - b.created);

    return NextResponse.json({ products: sortedProducts, ok: true });
  } catch (error) {
    console.error('Products fetch error:', error);
    return NextResponse.json(
      { message: 'Failed to fetch products' },
      { status: 500 }
    );
  }
}

Display Pricing

components/pricing/Pricing.tsx (simplified)
import { getProducts } from '@/lib/stripe/getProducts';
import { getCoupons } from '@/lib/stripe/getCoupons';
import { PricingCard } from './PricingCard';

export async function Pricing() {
  const products = await getProducts();
  const coupons = await getCoupons();

  if (!products) {
    return <div>Unable to load pricing</div>;
  }

  return (
    <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
      {products.products.map((product) => (
        <PricingCard
          key={product.id}
          name={product.name}
          description={product.description}
          price={product.default_price.unit_amount / 100}
          currency={product.default_price.currency}
          priceId={product.default_price.id}
          priceType={product.default_price.type}
          features={product.marketing_features}
          couponId={coupons?.coupon?.id}
          discount={coupons?.coupon?.percent_off}
        />
      ))}
    </div>
  );
}

Key points:

  • Products fetched server-side with caching
  • Prices divided by 100 (Stripe uses cents)
  • Coupons applied if available
  • Cached with revalidation tags

Fetch Coupons

Display active coupons in your pricing section:

lib/stripe/getCoupons.ts
export async function getCoupons() {
  try {
    const res = await fetch(`${process.env.SITE_URL}/api/stripe/coupons`, {
      cache: 'force-cache',
      next: { tags: ['stripe/coupons'] },
    });

    if (!res.ok) {
      throw new Error('Failed to fetch coupons');
    }

    return res.json();
  } catch (error) {
    console.error('Coupon fetch error:', error);
    return null;
  }
}

Cache Revalidation

Revalidate cached data when Stripe data changes:

Revalidation in webhook handler
switch (event.type) {
  case 'product.created':
  case 'product.updated':
  case 'product.deleted':
    revalidateTag('stripe/products');
    break;
    
  case 'coupon.created':
  case 'coupon.updated':
  case 'coupon.deleted':
    revalidateTag('stripe/coupons');
    break;
    
  case 'checkout.session.completed':
    revalidateTag('stripe/orders');
    break;
}

Why revalidation matters:

  • Products and coupons are cached for performance
  • Webhooks trigger cache updates when data changes
  • Ensures pricing section always shows current data

Testing Payments

Test the payment flow with Stripe test cards:

  1. Create Checkout: Click "Buy Now" on pricing page
  2. Use Test Card: 4242 4242 4242 4242
  3. Complete Checkout: Use any future date and CVC
  4. Check Webhook: Verify webhook received in Stripe Dashboard
  5. Verify Order: Check order saved to database (if applicable)
  6. Capture Payment: Capture in Stripe Dashboard → Payments

In test mode, use Stripe test cards to simulate different scenarios like declined cards, authentication required, etc.

Next Steps

How is this guide ?

Last updated on