Skip to main content
โšก Calmops

Add Stripe Payments in One Day: A Practical Guide for Indie Hackers

How to integrate Stripe checkout, subscriptions, and webhooks quickly and securely

Introduction

Stripe is the de-facto payment provider for indie hackers and early-stage startups. This guide helps you integrate Stripe checkout or subscriptions in one day, with secure webhooks and a developer-friendly workflow.

Why Stripe? Unlike traditional payment processors, Stripe offers:

  • Developer-friendly APIs with excellent documentation
  • Competitive pricing (2.9% + $0.30 per transaction for Checkout)
  • Built-in fraud detection (Stripe Radar)
  • Support for 135+ currencies and payment methods
  • Webhook reliability for event-driven architectures

Choose Your Stripe Flow

Understanding which integration pattern to use is crucial for your timeline and feature needs.

The fastest path to payments. Stripe hosts the entire checkout experience, handling card validation, 3D Secure (3DS) authentication, and compliance.

  • Best for: One-time purchases, subscription sign-ups, simple product sales
  • Setup time: 2-3 hours including webhooks
  • Pricing control: Limited to predefined prices you create in Stripe Dashboard
  • URL: https://stripe.com/docs/payments/checkout

Billing API (For Complex Scenarios)

More control over invoice generation, metering, and usage-based billing. Requires more implementation effort.

  • Best for: SaaS with tiered pricing, usage-based billing (e.g., API calls), complex invoicing
  • Setup time: 1-2 days
  • Use case example: Charge customers $0.10 per API request beyond their plan’s limit
  • URL: https://stripe.com/docs/billing/quickstart

Payment Elements/API (Maximum Flexibility)

Build a completely custom checkout experience. You maintain full UI control but handle more compliance yourself.


Prepare Your Stripe Account

Getting your Stripe account production-ready takes 15-30 minutes.

Step-by-Step Account Setup

  1. Create a Stripe Account

  2. Complete Account Activation (Necessary for Live Mode)

    • Go to Settings โ†’ Account Settings
    • Add your business name, address, and contact info
    • Verify your business type (sole proprietor, LLC, corporation, etc.)
    • Add your bank account details for payouts
    • Provide tax ID / SSN (required for US accounts)
    • Timeline: Usually approved within 24-48 hours
  3. Create Your First Product & Price

    • In Dashboard, go to Products โ†’ Add Product
    • Example product: “Pro Plan” with $29/month recurring
    • Prices are SKUs (Stock Keeping Units) tied to products
    • Recurring prices are subscription prices; one-time prices are for single purchases
    • Example pricing setup:
      • Basic Plan: $9/month (price_xxx_basic)
      • Pro Plan: $29/month (price_xxx_pro)
      • Enterprise: Custom pricing (contact sales)
  4. Get Your API Keys

    • Navigate to Developers โ†’ API Keys
    • You have two sets:
      • Test Mode (starts with pk_test_ and sk_test_): For development and testing
      • Live Mode (starts with pk_live_ and sk_live_): For production payments
    • Keep your Secret Key (sk_) completely privateโ€”treat it like a password
    • Your Publishable Key (pk_) can be public (used in frontend code)
  5. Test Your Account

    • Stay in Test Mode until you’re ready to go live
    • Use Stripe’s test card numbers: 4242 4242 4242 4242 (success), 4000 0000 0000 0002 (decline)
    • Any future date for expiry, any 3-digit CVC

Core Concepts You Need to Know

Webhook (Web Hook)

An automated callbackโ€”Stripe sends HTTP POST requests to your server when payment events occur. Instead of polling (constantly asking “is payment done?”), Stripe pushes events to you.

  • Example event: checkout.session.completed fires when a customer completes checkout
  • Why it matters: You need webhooks to fulfill orders, activate subscriptions, and send confirmation emails
  • Signature verification: Stripe signs each webhook with a secret key so you can verify it’s really from Stripe

Session ID (Checkout Session)

A unique identifier for one checkout attempt. Contains line items, pricing, customer email, and redirect URLs.

Price ID

A Stripe identifier for a specific product variant (e.g., price_1Qw7m9D0XxF0Gd1I8jKlMnOp for the “$29/month Pro Plan”).

Customer ID

A unique Stripe customer record. You don’t have to create customers manuallyโ€”Stripe creates them automatically during checkout.

Invoice

A billing document. For subscriptions, Stripe auto-generates invoices on billing day. You can retrieve and email them to customers.


Quick Implementation Steps (Next.js Example)

This example uses Next.js with API Routes, a popular stack for indie hackers.

1. Install Dependencies

npm i stripe @stripe/stripe-js
  • stripe: Server-side Stripe SDK for creating sessions and handling webhooks
  • @stripe/stripe-js: Client-side Stripe library for redirecting to checkout

2. Set Up Environment Variables

Create a .env.local file in your project root:

# Public key (safe to expose in frontend)
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...your_test_key...

# Secret key (KEEP PRIVATE - server-side only)
STRIPE_SECRET_KEY=sk_test_...your_test_secret_key...

# Webhook signing secret (from Developers โ†’ Webhooks)
STRIPE_WEBHOOK_SECRET=whsec_...webhook_secret...

# Your app's base URL (for redirect URLs)
NEXT_PUBLIC_URL=http://localhost:3000

Security reminder: Never commit .env.local to git. Add it to .gitignore.

3. Create a Checkout Session Endpoint

This serverless function creates a Stripe Checkout session and returns the session ID.

// filepath: /home/quyq/Documents/code/calmops-hugo/pages/api/create-checkout-session.js
import Stripe from 'stripe'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY)

export default async function handler(req, res) {
  // Only accept POST requests
  if (req.method !== 'POST') {
    return res.status(405).json({ error: 'Method not allowed' })
  }

  const { priceId, customerEmail } = req.body

  // Validate inputs
  if (!priceId) {
    return res.status(400).json({ error: 'Missing priceId' })
  }

  try {
    // Create the checkout session
    const session = await stripe.checkout.sessions.create({
      mode: 'subscription', // 'subscription', 'payment', or 'setup'
      payment_method_types: ['card'],
      line_items: [
        {
          price: priceId,
          quantity: 1,
        },
      ],
      customer_email: customerEmail, // Pre-fill email to save steps
      success_url: `${process.env.NEXT_PUBLIC_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
      cancel_url: `${process.env.NEXT_PUBLIC_URL}/cancel`,
      subscription_data: {
        trial_period_days: 7, // Optional: 7-day free trial
      },
    })

    return res.json({ sessionId: session.id })
  } catch (error) {
    console.error('Stripe error:', error.message)
    return res.status(500).json({ error: error.message })
  }
}

Key parameters explained:

  • mode: 'subscription': For recurring charges. Use 'payment' for one-time charges.
  • line_items: Products and quantities. Each item needs a price ID and quantity.
  • success_url / cancel_url: Where to redirect after checkout. {CHECKOUT_SESSION_ID} is a Stripe placeholder.
  • trial_period_days: Offer a free trial (optional but increases conversion).

4. Client: Redirect to Checkout

Create a checkout button that calls your API and redirects to Stripe-hosted checkout.

// filepath: pages/pricing.js (or your pricing page)
import { loadStripe } from '@stripe/stripe-js'

let stripePromise

const getStripe = () => {
  if (!stripePromise) {
    stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY)
  }
  return stripePromise
}

export default function PricingPage() {
  const handleCheckout = async (priceId) => {
    const stripe = await getStripe()
    const email = prompt('Enter your email:') // Or get from login

    // Call your API endpoint
    const response = await fetch('/api/create-checkout-session', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ priceId, customerEmail: email }),
    })

    const { sessionId } = await response.json()

    // Redirect to Stripe Checkout
    const result = await stripe.redirectToCheckout({ sessionId })
    if (result.error) alert(result.error.message)
  }

  return (
    <div>
      <h1>Pricing</h1>
      <button onClick={() => handleCheckout('price_1Qw7m9D0XxF0Gd1I8jKlMnOp')}>
        Subscribe to Pro ($29/month)
      </button>
    </div>
  )
}

5. Handle Webhooks Securely

Webhooks are how Stripe notifies you of payment events. Signature verification is critical to ensure requests are really from Stripe.

// filepath: /home/quyq/Documents/code/calmops-hugo/pages/api/stripe-webhook.js
import Stripe from 'stripe'
import { buffer } from 'micro'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY)

// Disable Next.js's default body parser for this route
export const config = { api: { bodyParser: false } }

export default async function handler(req, res) {
  if (req.method !== 'POST') {
    return res.status(405).json({ error: 'Method not allowed' })
  }

  // Get raw request body as buffer
  const buf = await buffer(req)
  const sig = req.headers['stripe-signature']

  let event

  // Verify webhook signature
  try {
    event = stripe.webhooks.constructEvent(
      buf.toString(),
      sig,
      process.env.STRIPE_WEBHOOK_SECRET
    )
  } catch (error) {
    console.error('Webhook signature verification failed:', error.message)
    return res.status(400).json({ error: `Webhook Error: ${error.message}` })
  }

  // Handle specific events
  try {
    switch (event.type) {
      case 'checkout.session.completed': {
        const session = event.data.object
        console.log(`โœ“ Checkout completed for customer: ${session.customer}`)
        
        // TODO: Update your database
        // - Create user account if new
        // - Activate subscription
        // - Send welcome email
        // Example:
        // await db.users.update({ stripeCustomerId: session.customer }, { status: 'active' })
        break
      }

      case 'customer.subscription.updated': {
        const subscription = event.data.object
        console.log(`โœ“ Subscription updated: ${subscription.id}`)
        // Handle plan upgrades/downgrades
        break
      }

      case 'invoice.payment_failed': {
        const invoice = event.data.object
        console.log(`โš  Payment failed for invoice: ${invoice.id}`)
        // Send retry email, disable access if needed
        break
      }

      default:
        console.log(`Unhandled event type: ${event.type}`)
    }

    res.json({ received: true })
  } catch (error) {
    console.error('Error handling webhook:', error.message)
    return res.status(500).json({ error: 'Internal server error' })
  }
}

Critical points:

  • Signature verification: Always verify stripe-signature header. This proves the webhook is from Stripe, not a hacker.
  • Idempotency: Webhooks can be retried. Handle duplicate events gracefully (check if subscription is already active).
  • Event types: Listen only to events you need. Common ones: checkout.session.completed, customer.subscription.created, invoice.payment_failed.

6. Create a Success Page

After successful checkout, redirect users to a success page.

// filepath: /home/quyq/Documents/code/calmops-hugo/pages/success.js
import { useRouter } from 'next/router'
import Stripe from 'stripe'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY)

export default function SuccessPage({ subscription }) {
  const router = useRouter()
  const { session_id } = router.query

  return (
    <div style={{ textAlign: 'center', padding: '50px' }}>
      <h1>โœ“ Payment Successful!</h1>
      <p>Thank you for subscribing. Your account is now active.</p>
      <p>Session ID: {session_id}</p>
      {subscription && (
        <p>
          Your plan renews on{' '}
          {new Date(subscription.current_period_end * 1000).toLocaleDateString()}
        </p>
      )}
      <a href="/dashboard">Go to Dashboard</a>
    </div>
  )
}

export async function getServerSideProps(context) {
  const { session_id } = context.query

  if (!session_id) {
    return { notFound: true }
  }

  try {
    // Retrieve session details from Stripe
    const session = await stripe.checkout.sessions.retrieve(session_id, {
      expand: ['subscription'], // Include subscription details
    })

    return {
      props: {
        subscription: session.subscription,
      },
    }
  } catch (error) {
    console.error('Error retrieving session:', error.message)
    return { notFound: true }
  }
}

Subscriptions & Billing Tips

Trial Periods

Increase conversions by offering a free trial (7-14 days is common).

  • Set trial_period_days: 7 in checkout session
  • Users won’t be charged until trial ends
  • Send reminder email 2 days before trial ends

Annual Billing Discounts

Encourage annual plans with a discount to increase customer lifetime value (LTV).

  • Create two prices: price_monthly ($29/mo) and `price_annual` ($290/year = $24.17/mo)
  • Display “Save 17%” on the annual plan
  • Higher upfront commitment = better retention

Tiered Pricing with subscription_items

For multiple pricing tiers (Starter, Pro, Enterprise), create separate price IDs and let users upgrade/downgrade.

// Example: Pro plan with add-on usage
const session = await stripe.checkout.sessions.create({
  mode: 'subscription',
  line_items: [
    { price: 'price_pro_plan', quantity: 1 }, // $29/month base
    { price: 'price_extra_users', quantity: 2 }, // 2 extra users @ $10 each
  ],
  // ...
})

Post-Purchase Workflow

  1. Immediate: Send transactional email with receipt and login credentials
  2. Day 1: Send onboarding checklist (connect integrations, upload data, etc.)
  3. Trial end - 2 days: Reminder email that billing starts
  4. After first charge: Confirmation email

Testing Payments Locally

Using ngrok or localtunnel

To test webhooks, Stripe needs to reach your local server via HTTPS. Use a tunneling service:

# Install ngrok (macOS/Linux/Windows)
# Download from https://ngrok.com

# Start ngrok tunnel
ngrok http 3000

# You'll get a URL like: https://abcd1234.ngrok.io

Then in Stripe Dashboard (Developers โ†’ Webhooks):

  1. Add endpoint: https://abcd1234.ngrok.io/api/stripe-webhook
  2. Select events: checkout.session.completed, invoice.payment_failed
  3. Get the webhook signing secret and add to .env.local

Test Scenarios

Scenario Test Card Result
Successful payment 4242 4242 4242 4242 โœ“ Completes
Card decline 4000 0000 0000 0002 โœ— Declined
Requires authentication (3DS) 4000 0025 0000 3155 Requires 2FA
Expired card 4000 0000 0000 0069 โœ— Declined

Use any future expiry date (e.g., 12/25) and any 3-digit CVC.


Security & Compliance

API Key Management

  • Never commit secret keys to version control
  • Use .env.local (add to .gitignore)
  • For production, use environment variables in your hosting platform (Vercel, Heroku, AWS Lambda, etc.)
  • Rotate keys periodically

Webhook Signature Verification

Always verify the stripe-signature header. This prevents spoofed webhook requests.

const event = stripe.webhooks.constructEvent(body, sig, webhookSecret)

If verification fails, reject the webhook.

PCI Compliance (Payment Card Industry)

PCI-DSS is a security standard for handling card data. Good news: Using Stripe Checkout or Payment Elements means Stripe handles PCI compliance. You don’t store card data.

3D Secure (3DS) Authentication

Some cards require 2FA (two-factor authentication) for higher-value purchases. Stripe handles this automatically in Checkout.

Fraud Prevention

  • Enable Stripe Radar (free tier: basic fraud detection)
  • Radar analyzes transaction patterns and flags suspicious activity
  • Settings โ†’ Radar Rules to customize thresholds

Tax Compliance

If you sell digital products:

  • US: Collect sales tax based on customer state
  • EU: Collect VAT (varies by country)
  • Stripe Tax: Integrated tax calculation (separate pricing)
  • Alternative: TaxJar, Avalara integrations

Post-Launch Checklist

  • Webhook reliability: Test that webhooks reliably update your DB
  • Idempotency: Handle duplicate webhook events gracefully
  • Failed payment recovery: Implement retry emails and paused access notifications
  • Billing portal: Enable Stripe Customer Portal for self-service (update card, view invoices, cancel)
  • Account dashboard: Let users see their plan, billing date, and usage
  • Cancellation flow: Allow users to self-serve cancel (reduce churn, improve NPS)
  • Receipts: Email invoice PDFs after each charge
  • Testing: Test upgrades, downgrades, and cancellations end-to-end

Enable Stripe Customer Portal

Let customers manage their subscriptions without contacting you.

// pages/api/create-portal-session.js
import Stripe from 'stripe'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY)

export default async function handler(req, res) {
  const { customerId } = req.body

  const session = await stripe.billingPortal.sessions.create({
    customer: customerId,
    return_url: `${process.env.NEXT_PUBLIC_URL}/dashboard`,
  })

  res.json({ url: session.url })
}

Alternatives to Stripe

If Stripe doesn’t fit your use case:

Paddle

  • Ideal for: Digital products, SaaS with non-US customers
  • Why: Handles VAT automatically (huge for EU), simpler licensing, takes more of the revenue share
  • Pricing: 5% + transaction fees (vs Stripe’s 2.9%)
  • URL: https://paddle.com

Gumroad

  • Ideal for: Digital downloads, courses, indie creators
  • Why: Simplest checkout, built-in audience features, handles tax
  • Pricing: 10% + payment processor fees
  • URL: https://gumroad.com

Lemon Squeezy

  • Ideal for: Digital products with license management
  • Why: Checkout, licensing, affiliate programs all built-in
  • Pricing: 8% + payment fees
  • URL: https://lemonsqueezy.com

Razorpay / Instamojo (Asia-focused)

  • Better for Indian, Southeast Asian markets
  • Lower fees, local payment methods

Final Thoughts

Stripe is the standard for indie hackers because it balances simplicity (Checkout) with power (APIs, Webhooks, Subscriptions). You can go live in one day with a solid foundation.

Next steps:

  1. Create your Stripe account (15 minutes)
  2. Copy the code examples above (30 minutes)
  3. Test with test cards (15 minutes)
  4. Set up webhooks and handle checkout.session.completed (30 minutes)
  5. Deploy to production and monitor logs

Pro tips:

  • Start with Checkout (pre-built, secure, compliant)
  • Listen only to essential webhook events initially
  • Set up Slack notifications for payment failures
  • Monitor Stripe logs (Developers โ†’ Logs) to debug issues
  • Join the Stripe Developer community for updates

Action: Integrate Stripe Checkout for your first plan today. Accept your first test payment within 2 hours. Go live when you’re confident.


Helpful Resources

Comments