Chapter 3: Watertight Webhook Handling and Database Synchronization

In the previous chapter, we learned how to redirect users to Stripe's checkout page.
But you must remember the most important rule in payment processing:
"The 'Payment Successful' message on the frontend can never be treated as proof of actual payment."

Users might experience network disconnection or immediately close their browser tab at the moment of successful payment, preventing them from reaching your success_url page. If you place database write logic on the frontend page, users could face the nightmare scenario of "card charged but no product received" complaints.

The only reliable method is using Webhooks. It's like Stripe's server secretly calling your server in the background: "Hey! I just received payment, here are the order details...". This call isn't affected by users closing their browsers, making it the most stable and secure reconciliation mechanism.


๐Ÿ”’ 1. Webhook Signature: The Core of Hacker Prevention

Since Webhooks are HTTP POST requests sent from Stripe to our API, what if hackers forge a request that looks like it's from Stripe, pretending they paid $1 million?

Stripe's solution: Webhook Signatures.
When you set up a Webhook URL in the Stripe Dashboard, Stripe provides you with a unique Webhook Secret (starting with whsec_).
Every time Stripe sends a request, it includes a "signature" in the HTTP headers calculated using this secret. When we receive the request, we must verify this signature using the same secret. If it doesn't match, it means the request is forged by hackersโ€”reject it immediately!


๐Ÿ’ป 2. Implementing the Stripe Webhook API

In Next.js App Router, we create this API: src/app/api/stripe/webhook/route.ts.

import { NextResponse } from 'next/server';
import Stripe from 'stripe';
import { createClient } from '@supabase/supabase-js'; // Note: Use Admin privileges here

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2023-10-16',
});

// Payment Webhooks are specialโ€”we must receive the "raw bytes (Raw Body)" to correctly verify signatures
// In Next.js, we need to disable default JSON parsing (though in App Router, we can directly read request.text())

export async function POST(request: Request) {
  const payload = await request.text();
  const sig = request.headers.get('stripe-signature');

  let event: Stripe.Event;

  try {
    // 1. Core defense: Verify the signature! If this passes, it's guaranteed to be a genuine Stripe request
    event = stripe.webhooks.constructEvent(
      payload,
      sig!,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch (err: any) {
    console.error('โš ๏ธ Webhook signature verification failed.', err.message);
    return NextResponse.json({ error: 'Webhook signature verification failed' }, { status: 400 });
  }

  // 2. Initialize a high-privilege database connection (since Webhooks run in the background without user cookies)
  const supabaseAdmin = createClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.SUPABASE_SERVICE_ROLE_KEY!
  );

  // 3. Handle events based on type
  try {
    switch (event.type) {
      // Event A: Checkout completed (for one-time payments or first subscription payment)
      case 'checkout.session.completed': {
        const session = event.data.object as Stripe.Checkout.Session;
        
        // Extract the userId we sneakily stored in the previous chapter
        const userId = session.metadata?.userId;
        
        if (userId) {
          console.log(`๐Ÿ’ฐ Payment succeeded! User: ${userId}, Amount: ${session.amount_total}`);
          
          // Write to database and grant access!
          await supabaseAdmin.from('vt_purchases').insert({
            user_id: userId,
            item_id: 'vip-all-access', // Assume this is a site-wide pass
            order_no: session.id,
            amount: session.amount_total ? session.amount_total / 100 : 0 // Stripe amounts are in cents, so divide by 100
          });
        }
        break;
      }

      // Event B: Subscription recurring payment succeeded
      case 'invoice.paid': {
        const invoice = event.data.object as Stripe.Invoice;
        // If you need monthly auto-renewal, update the user's subscription expiry here!
        break;
      }

      // Event C: Subscription payment failed (expired card or insufficient funds)
      case 'invoice.payment_failed': {
        const invoice = event.data.object as Stripe.Invoice;
        // Suspend the user's VIP access and notify them to update their card
        break;
      }

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

    // 4. [CRITICAL] Always return 200 OK to Stripe
    // If you don't, Stripe will think your server is down and retry your API incessantly for three days
    return NextResponse.json({ received: true });

  } catch (err) {
    console.error('Webhook handler error:', err);
    return NextResponse.json({ error: 'Webhook handler failed' }, { status: 500 });
  }
}

๐Ÿšง 3. How to Test Webhooks on Localhost?

In the previous chapter about ECPay, we mentioned the "major pitfall": ECPay's servers can't reach your localhost.
Now, during Stripe development, surely we don't need to deploy to Vercel after every single code change just to test, right?

Stripe demonstrates why it's the global leader in developer experience by providing an official tool: Stripe CLI.

  1. Install Stripe CLI on your computer.
  2. Log in to your Stripe account in the terminal: stripe login
  3. Run this command to forward Stripe's Webhooks through your local network directly to localhost:
    stripe listen --forward-to localhost:3000/api/stripe/webhook
    
  4. After running this, the terminal will display:
    Your webhook signing secret is whsec_xxxxxxxx...
    This is your local development STRIPE_WEBHOOK_SECRETโ€”paste it into your .env.local!

Now, when you test checkout payments locally, Stripe's cloud will use the CLI to tunnel Webhook signals through firewalls straight to your local API! This premium development experience is why developers worldwide love Stripe.

๐Ÿ† Conclusion

Congratulations on completing Stripe's international payment integration!
You now have an international-grade SaaS infrastructure with:

  • Apple Pay quick checkout
  • Multi-currency payment acceptance
  • Secure Webhook reconciliation
  • Subscription support

Software knows no borders, and neither should your code. Get ready to accept orders and sell your software to the world! ๐Ÿš€

Webhook Security

Webhooks are public endpoints โ€” anyone can send requests to them. Stripe signs every webhook payload so you can verify it came from Stripe.

import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

// Webhook handler with signature verification
export default async function handler(req, res) {
  if (req.method !== 'POST') {
    return res.status(405).end();
  }

  const sig = req.headers['stripe-signature'];
  const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;

  let event;

  try {
    event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret);
  } catch (err) {
    console.error('Signature verification failed:', err.message);
    return res.status(400).send(`Webhook Error: ${err.message}`);
  }

  // Handle the event
  switch (event.type) {
    case 'checkout.session.completed':
      const session = event.data.object;
      await handleCheckoutCompleted(session);
      break;
    case 'payment_intent.succeeded':
      const paymentIntent = event.data.object;
      await handlePaymentSucceeded(paymentIntent);
      break;
    case 'payment_intent.payment_failed':
      const failedPayment = event.data.object;
      await handlePaymentFailed(failedPayment);
      break;
    case 'customer.subscription.updated':
      const subscription = event.data.object;
      await handleSubscriptionUpdated(subscription);
      break;
    default:
      console.log(`Unhandled event type: ${event.type}`);
  }

  res.status(200).json({ received: true });
}

Important Webhook Events

| Event | When It Fires | Action | |-------|-------------|--------| | checkout.session.completed | User completes Checkout | Grant access to product | | checkout.session.expired | Checkout session times out | Notify user | | payment_intent.succeeded | Payment succeeds | Update order status | | payment_intent.payment_failed | Payment fails | Notify user, retry | | customer.subscription.created | Subscription starts | Setup recurring billing | | customer.subscription.updated | Subscription changes | Update plan access | | customer.subscription.deleted | Subscription ends | Revoke access | | invoice.payment_succeeded | Recurring invoice paid | Confirm subscription active | | invoice.payment_failed | Recurring invoice fails | Dunning, notify user | | charge.dispute.created | Customer disputes charge | Start dispute process | | charge.refunded | Refund issued | Update records |

Handling Checkout Session Completed

async function handleCheckoutCompleted(session) {
  const customerId = session.customer;
  const customerEmail = session.customer_details.email;
  const metadata = session.metadata || {};
  const orderId = metadata.order_id;

  // Grant access to the purchased product
  await db.orders.update({
    where: { id: orderId },
    data: {
      status: 'paid',
      stripeSessionId: session.id,
      paidAt: new Date(),
    },
  });

  // Send confirmation email
  await sendEmail({
    to: customerEmail,
    subject: 'Payment Confirmed',
    body: `Your order ${orderId} has been paid successfully.`,
  });

  console.log(`Order ${orderId} completed for ${customerEmail}`);
}

Idempotency

Stripe webhooks can deliver the same event multiple times (at-least-once delivery). Always make your handlers idempotent:

async function handleCheckoutCompleted(session) {
  // Check if already processed
  const existing = await db.orders.findUnique({
    where: { stripeSessionId: session.id },
  });

  if (existing && existing.status === 'paid') {
    console.log('Session already processed โ€” skipping');
    return;
  }

  // Process normally
  // ...
}

Local Webhook Testing

# Install Stripe CLI
brew install stripe/stripe-cli/stripe

# Login to your Stripe account
stripe login

# Forward webhooks to your local server
stripe listen --forward-to localhost:3000/api/webhooks

# You'll get a webhook signing secret (whsec_...)
# Set this as STRIPE_WEBHOOK_SECRET in your .env.local

# Trigger a test event
stripe trigger checkout.session.completed

# View forwarded events
stripe listen --forward-to localhost:3000/api/webhooks --events checkout.session.completed,payment_intent.succeeded

Common Webhook Issues

| Issue | Cause | Solution | |-------|-------|----------| | 400 signature error | Wrong webhook secret or raw body parsing | Ensure raw body is used, not parsed JSON | | Duplicate processing | Stripe delivers at-least-once | Make handlers idempotent | | Timeout | Handler takes too long | Return 200 quickly, process async | | Missing events | Not subscribed to correct event types | Check Stripe Dashboard webhook settings | | Endpoint not reachable | Local dev server not exposed | Use Stripe CLI listen or ngrok |

Summary

Webhooks are the backbone of async payment processing. Stripe signs every webhook for security, and you must verify the signature before trusting the payload. Handle key events like checkout.session.completed and payment_intent.succeeded. Always make handlers idempotent for at-least-once delivery.

Key takeaways:

  • Always verify the Stripe signature before processing
  • Use constructEvent(req.body, sig, endpointSecret) for verification
  • Handle checkout.session.completed to grant access
  • Handle payment_intent.succeeded and payment_failed for payment status
  • Make all handlers idempotent (check if already processed)
  • Use Stripe CLI for local webhook testing
  • Return 200 quickly, do heavy processing asynchronously
  • Subscribe to only the event types you need

What's Next: Algorithm โ€” Greedy Algorithms

The next course covers greedy algorithms โ€” Kruskal, Prim, Huffman coding, and set cover.

Unlock Full Tutorial

This chapter is paid content. Join the project to unlock over 5000 words of deep analysis, including 10+ god-tier Prompts and real Source Code examples!