Vibestacks LogoVibestacks
IntegrationsStripe & Payments

Webhooks

Understand how Stripe webhooks work in Vibestacks. Learn about automatic event handling, custom event processing, and webhook security.

Webhooks are how Stripe notifies your app about events like successful payments, subscription changes, and failed charges. Vibestacks handles the most common events automatically.

How Webhooks Work

┌─────────┐         ┌────────────────────┐         ┌──────────────┐
│  Stripe │ ──────> │ /api/auth/stripe/  │ ──────> │  Better Auth │
│         │  POST   │     webhook        │         │    Plugin    │
└─────────┘         └────────────────────┘         └──────────────┘


                                                   ┌──────────────┐
                                                   │   Database   │
                                                   │ (subscription│
                                                   │   updated)   │
                                                   └──────────────┘
  1. An event occurs in Stripe (payment, subscription change, etc.)
  2. Stripe sends a POST request to your webhook endpoint
  3. Better Auth verifies the signature and processes the event
  4. Your database is updated accordingly

Webhook Endpoint

The webhook endpoint is automatically created by Better Auth at:

POST /api/auth/stripe/webhook

Don't Create Your Own

You don't need to create a webhook route manually. Better Auth's Stripe plugin handles this automatically.

Automatically Handled Events

Better Auth processes these events out of the box:

EventWhat Happens
checkout.session.completedCreates subscription in database
customer.subscription.createdCreates subscription (if not via checkout)
customer.subscription.updatedUpdates subscription status, dates, plan
customer.subscription.deletedMarks subscription as canceled

These events keep your database in sync with Stripe automatically.

Required Events

When setting up your Stripe webhook endpoint, select at least these events:

checkout.session.completed
customer.subscription.created
customer.subscription.updated
customer.subscription.deleted

For better monitoring and features:

invoice.paid
invoice.payment_failed
customer.subscription.trial_will_end

Custom Event Handling

Handle additional events using the onEvent callback in lib/auth.ts:

lib/auth.ts
stripe({
  stripeClient,
  stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET!,
  
  // Handle any Stripe event
  onEvent: async (event) => {
    switch (event.type) {
      case "invoice.paid":
        // Payment successful
        console.log(`Invoice paid: ${event.data.object.id}`);
        break;
        
      case "invoice.payment_failed":
        // Payment failed - maybe send an email
        const invoice = event.data.object;
        await sendPaymentFailedEmail({
          customerId: invoice.customer as string,
          amount: invoice.amount_due,
        });
        break;
        
      case "customer.subscription.trial_will_end":
        // Trial ending in 3 days
        await sendTrialEndingReminder({
          subscriptionId: event.data.object.id,
        });
        break;
    }
  },
  
  subscription: {
    // ...
  },
})

Webhook Security

Stripe signs every webhook request. Better Auth automatically:

  1. Verifies the signature using your STRIPE_WEBHOOK_SECRET
  2. Rejects requests with invalid signatures
  3. Prevents replay attacks

Keep Your Secret Safe

Never expose your STRIPE_WEBHOOK_SECRET. It's used to verify that requests actually come from Stripe.

Signature Verification

If you see this error:

Webhook signature verification failed

Common causes:

  • Wrong STRIPE_WEBHOOK_SECRET (test vs live, or stale from CLI)
  • Request body was modified by middleware
  • Clock skew between servers

Testing Webhooks Locally

Use the Stripe CLI to forward webhooks to localhost:

# Start forwarding
stripe listen --forward-to localhost:3000/api/auth/stripe/webhook

# In another terminal, trigger a test event
stripe trigger checkout.session.completed

Useful Test Events

# Subscription lifecycle
stripe trigger customer.subscription.created
stripe trigger customer.subscription.updated
stripe trigger customer.subscription.deleted

# Payments
stripe trigger invoice.paid
stripe trigger invoice.payment_failed

# Trials
stripe trigger customer.subscription.trial_will_end

Viewing Webhook Logs

Stripe CLI (Local)

When running stripe listen, you'll see real-time logs:

2024-01-15 10:30:45  --> checkout.session.completed [evt_xxx]
2024-01-15 10:30:45  <--  [200] POST http://localhost:3000/api/auth/stripe/webhook

Stripe Dashboard (Production)

Go to dashboard.stripe.com/webhooks:

  1. Click on your endpoint
  2. View Webhook attempts tab
  3. See status, response, and payload for each event

Retry Behavior

If your endpoint returns an error (non-2xx status), Stripe will retry:

AttemptDelay
1Immediate
25 minutes
330 minutes
42 hours
5+8 hours (up to 3 days)

After 3 days of failures, Stripe stops retrying and marks the event as failed.

Idempotency

Always design your webhook handlers to be idempotent. The same event might be delivered multiple times due to retries.

Common Issues

400 Bad Request

  • Webhook secret doesn't match
  • Request body parsing issue (ensure raw body is preserved)

401 Unauthorized

  • Missing webhook secret
  • Secret doesn't match the endpoint

Timeout (504)

Your handler is taking too long. Webhooks should respond within 30 seconds.

Solution: Process heavy work in the background:

onEvent: async (event) => {
  // Don't await heavy operations
  // Use a queue or background job instead
  waitUntil(processHeavyTask(event));
}

Events Not Arriving

  1. Check Stripe Dashboard → Webhooks → Endpoint → Recent attempts
  2. Verify the endpoint URL is correct
  3. Ensure your server is accessible (not behind firewall)
  4. Check that you selected the correct events

Event Data Types

Each event has a typed data.object. Common shapes:

// checkout.session.completed
event.data.object: Stripe.Checkout.Session

// customer.subscription.*
event.data.object: Stripe.Subscription

// invoice.*
event.data.object: Stripe.Invoice

Access typed data:

onEvent: async (event) => {
  if (event.type === "invoice.paid") {
    const invoice = event.data.object as Stripe.Invoice;
    console.log(invoice.amount_paid);
    console.log(invoice.customer);
  }
}

Next Steps