Issues with Stripe (Webhook signature verification failed)

I have been having issues with linking my Stripe payment to my live app. when users make payment to upgrade to free plan, stripe payment will be successful; however, the payment plan wont upgrade. I have been using two separate webhook in stripe, one for my google cloud run and the other for my .env file, I have deployed as well and also changed my code, which firebase AI studio helped me fix yet the issue persists. Also, my upgrade will load for long without being succcesul yet stripe payment has been successful. I have done everything I know to do but it seems there is an issue. Stripe error message shows this; Response

HTTP status code

400 (Bad Request)

{

“error”: “Webhook signature verification failed: No signatures found matching the expected signature for payload. Are you passing the raw request body you received from Stripe? \n If a webhook request is being forwarded by a third-party tool, ensure that the exact request body, including JSON formatting and new line style, is preserved.\n\nLearn more about webhook signing and explore webhook integration examples for various frameworks at GitHub - stripe/stripe-node: Node.js library for the Stripe API.”

}

Got the same issue. I found out that the API used in the SDK is outdated. Set the API version to “2024-04-10” fixed the issue. IDK if it works in your case

1 Like

Thank you @Vu_Dung_Pham! I managed to changed my api version but the problem still persist.

I deployed to google cloud run using the following varibles and code:

STRIPE_SECRET_KEY (Live Stripe Secret Key)

STRIPE_WEBHOOK_SECRET (Live Stripe Webhook Signing Secret)

NEXT_PUBLIC_APP_URL (Your deployed app's URL: https://newsassist.journotech.org)

GEMINI_API_KEY

But it automatcially has the FIREBASE_CONFIG variables which is number 1

Here’s my code use:

/ src/app/api/stripe-webhooks/route.ts
import { NextResponse, type NextRequest } from 'next/server';
import { headers } from 'next/headers';
import Stripe from 'stripe';
import { handleStripeWebhook } from '@/app/actions/stripeWebhookHandler';

// This is the correct way to disable the default body parser in App Router.
// It allows us to get the raw body for Stripe's signature verification.
export const config = {
  api: {
    bodyParser: false,
  },
};

// Re-initialize Stripe here as this is a separate serverless function environment,
// ensuring the correct API version is used.
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2024-06-20',
});

const stripeWebhookSecret = process.env.STRIPE_WEBHOOK_SECRET;

export async function POST(request: NextRequest) {
  // We need the raw body as a string for constructEvent.
  const body = await request.text();
  const signature = headers().get('stripe-signature');

  if (!stripeWebhookSecret) {
    console.error('[Webhook] CRITICAL: Stripe webhook secret (STRIPE_WEBHOOK_SECRET) is not set.');
    return NextResponse.json({ error: 'Webhook secret not configured.' }, { status: 500 });
  }

  if (!body || !signature) {
    console.error("[Webhook] Missing request body or signature.");
    return NextResponse.json({ error: 'Missing request body or signature.' }, { status: 400 });
  }

  let event: Stripe.Event;

  try {
    // Use the raw text body to construct the event. This is the critical step.
    event = stripe.webhooks.constructEvent(body, signature, stripeWebhookSecret);
  } catch (err: any) {
    console.error(`[Webhook] SIGNATURE VERIFICATION FAILED. This is a critical error.`);
    console.error(`[Webhook] Error Message: ${err.message}`);
    return NextResponse.json({ error: `Webhook signature verification failed: ${err.message}` }, { status: 400 });
  }

  // Pass the verified event to the handler function that contains the business logic
  // (e.g., updating the Firestore database).
  const result = await handleStripeWebhook(event);

  if (result.error) {
    return NextResponse.json({ received: true, error: result.error }, { status: result.status });
  }

  return NextResponse.json({ received: true, message: result.message }, { status: result.status });
}

See my the other code handler

// src/app/actions/stripeWebhookHandler.ts
'use server';

import Stripe from 'stripe';
import { adminDb, adminAuth } from '@/firebase/firebaseAdmin';
import { PLANS, getPlanById, getResetQuota } from '@/config/plans';
import type { PlanId, UserProfile } from '@/lib/types';
import * as admin from 'firebase-admin';

/**
 * Main handler for processing verified Stripe webhook events.
 * Accepts a Stripe.Event object that has already been verified.
 */
export async function handleStripeWebhook(
  event: Stripe.Event
): Promise<{ error?: string; status: number; message?: string }> {
  try {
    switch (event.type) {
      case "checkout.session.completed":
        const session = event.data.object as Stripe.Checkout.Session;
        console.log(`[Webhook] Handling checkout.session.completed for session: ${session.id}`);

        if (session.payment_status !== "paid") {
            console.log(`[Webhook] Session ${session.id} not paid (Status: ${session.payment_status}). No action taken.`);
            return { status: 200, message: "Session not paid, no action." };
        }

        const planIdFromMeta = session.metadata?.planId as PlanId | undefined;
        const stripeCustomerId = typeof session.customer === "string" ? session.customer : session.customer?.id;
        const stripeSubscriptionId = typeof session.subscription === "string" ? session.subscription : session.subscription?.id;
        const stripePriceId = session.metadata?.priceId;
        let firebaseUIDFromMeta = session.metadata?.firebaseUID;
        const customerEmail = session.customer_details?.email;

        if (!stripeCustomerId) {
          console.error(`[Webhook] CRITICAL: Missing Stripe Customer ID from checkout session: ${session.id}.`);
          return { status: 200, message: 'Acknowledged: Missing Stripe Customer ID. Logged for review.' };
        }
        if (!planIdFromMeta) {
          console.error(`[Webhook] CRITICAL: Missing planId from checkout session metadata: ${session.id}.`);
          return { status: 200, message: 'Acknowledged: Missing planId in metadata. Logged for review.' };
        }
        if (planIdFromMeta === 'donation') {
            console.log(`[Webhook] Donation checkout session ${session.id} completed. No user plan update needed.`);
            return { status: 200, message: "Donation processed." };
        }

        let userRecord: admin.auth.UserRecord | null = null;
        if (firebaseUIDFromMeta) {
            try {
                userRecord = await adminAuth.getUser(firebaseUIDFromMeta);
                console.log(`[Webhook] Found user by UID from metadata: ${firebaseUIDFromMeta}`);
            } catch (e: any) {
                console.warn(`[Webhook] Firebase UID ${firebaseUIDFromMeta} from metadata not found. Will try finding user by email. Error: ${e.message}`);
                firebaseUIDFromMeta = undefined;
            }
        }

        if (!userRecord && customerEmail) {
            try {
                userRecord = await adminAuth.getUserByEmail(customerEmail);
                firebaseUIDFromMeta = userRecord.uid;
                console.log(`[Webhook] Found existing Firebase user by email: ${customerEmail}, UID: ${firebaseUIDFromMeta}`);
            } catch (e: any) {
                 if (e.code === 'auth/user-not-found') {
                    console.log(`[Webhook] No Firebase user found for email ${customerEmail}. This is expected for new signups.`);
                 } else {
                    throw e;
                 }
            }
        }

        if (!firebaseUIDFromMeta && customerEmail) {
            console.log(`[Webhook] Could not find existing Firebase user for ${customerEmail}. Creating a new user record.`);
             const newUserRecord = await adminAuth.createUser({
                email: customerEmail,
                emailVerified: true, // Assuming payment implies email is valid
            });
            firebaseUIDFromMeta = newUserRecord.uid;
            console.log(`[Webhook] Created new Firebase user with UID: ${firebaseUIDFromMeta}`);
        }

        if (!firebaseUIDFromMeta) {
            console.error("[Webhook] CRITICAL: Could not determine or create Firebase UID for customer:", stripeCustomerId);
            return { status: 200, message: "Acknowledged: Could not determine Firebase UID. Logged for manual review." };
        }

        const plan = getPlanById(planIdFromMeta);
        if (!plan || plan.id === 'enterprise') {
            console.error(`[Webhook] Invalid planId '${planIdFromMeta}' in metadata.`);
            return { status: 200, message: `Acknowledged: Invalid planId ${planIdFromMeta}.` };
        }
        if (!stripeSubscriptionId && plan.priceMonthly !== undefined && plan.priceMonthly > 0) {
             console.error(`[Webhook] Missing stripeSubscriptionId for paid plan '${planIdFromMeta}'.`);
             return { status: 200, message: `Acknowledged: Missing stripeSubscriptionId for plan ${planIdFromMeta}.` };
        }

        const userRef = adminDb.collection("users").doc(firebaseUIDFromMeta);
        
        console.log(`[Webhook] Preparing to write to Firestore for user ${firebaseUIDFromMeta} with plan ${plan.id}`);
        
        await userRef.set({
          stripeCustomerId,
          stripeSubscriptionId: stripeSubscriptionId || null,
          stripePriceId: stripePriceId || null,
          planId: plan.id,
          quota: getResetQuota(plan.id),
          quotaLastReset: admin.firestore.FieldValue.serverTimestamp(),
          currentPeriodStart: admin.firestore.FieldValue.serverTimestamp(),
          email: customerEmail,
          uid: firebaseUIDFromMeta,
        }, { merge: true });

        console.log(`[Webhook] SUCCESS: User ${firebaseUIDFromMeta} plan updated to ${plan.id} in Firestore.`);
        break;

      case "customer.subscription.updated":
      case "customer.subscription.deleted":
        const subscription = event.data.object as Stripe.Subscription;
        console.log(`[Webhook] Handling ${event.type} for subscription: ${subscription.id}`);
        
        const stripeCustomerIdForSub = typeof subscription.customer === "string" ? subscription.customer : subscription.customer.id;
        
        if (!stripeCustomerIdForSub) {
             console.error(`[Webhook] CRITICAL (${event.type}): Missing customer ID for subscription ${subscription.id}.`);
             return { status: 200, message: `Acknowledged: Missing customer ID for ${event.type}.` };
        }

        const usersToUpdateSnapshot = await adminDb.collection("users").where("stripeCustomerId", "==", stripeCustomerIdForSub).get();
        if (usersToUpdateSnapshot.empty) {
            console.warn(`[Webhook] No user found with Stripe Customer ID: ${stripeCustomerIdForSub} for ${event.type}.`);
            break;
        }

        for (const userDoc of usersToUpdateSnapshot.docs) {
            const firebaseUIDToUpdate = userDoc.id;
            console.log(`[Webhook] Found user ${firebaseUIDToUpdate} to update for subscription ${subscription.id}`);

            let newPlanKey: PlanId | undefined;
            let newStripePriceId: string | null = null;
            let newStripeSubscriptionId: string | null = subscription.id;

            if (event.type === "customer.subscription.deleted" || subscription.status === "canceled" || subscription.status === "unpaid" || subscription.status === "past_due") {
                newPlanKey = "free";
                newStripeSubscriptionId = null;
            } else if (subscription.items.data.length > 0) {
                newStripePriceId = subscription.items.data[0].price.id;
                newPlanKey = Object.values(PLANS).find(p => p.stripePriceId === newStripePriceId)?.id;

                if (!newPlanKey) {
                    console.warn(`[Webhook] Subscription ${subscription.id} updated with unknown price ID ${newStripePriceId}. Reverting user ${firebaseUIDToUpdate} to free plan.`);
                    newPlanKey = "free";
                    newStripeSubscriptionId = null;
                }
            } else {
                 console.warn(`[Webhook] Subscription ${subscription.id} has no items. Reverting user ${firebaseUIDToUpdate} to free plan.`);
                 newPlanKey = "free";
                 newStripeSubscriptionId = null;
            }

            const targetPlan = getPlanById(newPlanKey);
            const updatesForSubChange: Partial<UserProfile> = {
                planId: targetPlan.id,
                stripeSubscriptionId: newStripeSubscriptionId,
                stripePriceId: targetPlan.id === 'free' ? null : newStripePriceId,
                quota: getResetQuota(targetPlan.id),
                quotaLastReset: admin.firestore.FieldValue.serverTimestamp(),
            };

            await userDoc.ref.update(updatesForSubChange);
            console.log(`[Webhook] SUCCESS: User ${firebaseUIDToUpdate} plan updated to ${targetPlan.id} via ${event.type}.`);
        }
        break;

      default:
        console.log(`[Webhook] Unhandled event type received: ${event.type}`);
    }
    return { status: 200, message: "Webhook processed successfully." };
  } catch (error: any) {
    console.error(`[Webhook] CRITICAL ERROR while processing event ${event.id} (Type: ${event.type}):`, error.message, error.stack);
    // Return 200 to Stripe even if our internal logic fails, to prevent retries for a bug we need to fix.
    return { error: `Webhook processing error: ${error.message}`, status: 200 };
  }
}

Have you double checked your webhook url on stripe? Also why would you need 2 seperate webhook? I dont quite understand the one for your .env file

I have checked my webhook URL on Stripe, but perhaps that is why I am a bit confused. So, I am new to app development; everything was working fine until I wanted to initiate payment on my app using Stripe.

Firebase AI studio recommened that I use two different webhooks; it was a bit confusing. I got one webhook secret key from my project terminal, which is currently used in my .env and the second webhook secret code was gotten from my Stripe dashboard. Initially, I was using my local environment webhook secret key gotten from the Stripe dashboard on my .env file, and Firebase AI Studio recommended that I use a separate webhook key, and directed that I should get one from my termina, and i used the code to get it and added it to my .env and .env.local files.
The second webhook was the one from my live webhook endpoint on the Stripe dashboard, which I am using on Google Cloud run for deployment. So, that was excatly how I kept on having issues until now.

From your experience, please, can you tell me if you only use one webhook secret key and did you get it from your live webhook endpoint from the Stripe dashboard, or how did you get it. I believe your experience will help me sort my issue.

I’ll try my best to explain this
Stripe provides developers a feature called sandbox. Its a test environment and you can do anything with it, from mock payments to simulate monthly subsciption, and so on. I think what AI suggests here is use the secrets from sandbox environment for dev and test, then live secret for production.
Back to the webhook. When you create a webhook through stripe dashboard, it will ask to to enter the url of your webhook endpoint. Make sure that it is publicly accessible. Then you will get a webhook secret, which look like whsec_abcdef123423. This is the secret you will use in the line
event = stripe.webhooks.constructEvent(body, signature, stripeWebhookSecret);
to verify the webhook’s payload.
The error suggests that at least 1 of the 3 param is incorrect. Here is a article from stripe on how to debug Resolve webhook signature verification errors | Stripe Documentation.
If you have already use the webhook secret from stripe dashboard for production, double check on the dashboard again to make sure the url is correct. Also, in the dashboard you can test the webhook by sending some event too

@Elf

Though this isn’t a Firebase Studio issue, feel free to send me an email and I’ll do my best to guide you through how to setup Stripe for NextJS applications.

Thanks @Vu_Dung_Pham and @dms i noticed the webhook error payload message has stopped,when I decided to use only one webhook key, however, due to several clicks to restore last codes in a bid to stop the error, my web app currently having issues , delaying sign-in and signup users to access the app.

During the issue, i usually see an icon that says webhook. pending on each subscribers on stripe, now, that error has gone

Here’s the message current message from Stripe.

Delivery attempt
Jul 21, 2025, 7:13:57 PM
200
See delivery in event destination view
Response body
{
"received": 
true,
"error": 
"Webhook processing error: 5 NOT_FOUND: ",
}

@dms how can i send you email

Select —> @dms and click the “message” button.

@Vu_Dung_Pham Thanks alot for helping. The question you asked me, why am i using two webhook secret key, gave me a brain rang up to decide to use just one webhook key and now everything with stripe is working perfectly now!

2 Likes