Search code examples
node.jspaypal-rest-sdkpaypal-subscriptionspaypal-webhooks

How to fix 'Webhook signature verification failed' in PayPal Node.js webhook?


While implementing PayPal webhooks in a Node.js application, I encountered an 401 Unauthorized error with the message "Webhook signature verification failed". This issue arises when I make a POST request to http://localhost:3001/paypal/subscriptions/webhook using Postman.

Background:

To simulate webhook events, I've been using Webhook.site to capture PayPal's webhook calls and extract necessary headers. These headers are then manually added to Postman requests to mimic the actual webhook call from PayPal. The headers include:

  • paypal-auth-algo

  • paypal-cert-url

  • paypal-transmission-id

  • paypal-transmission-sig

  • paypal-transmission-time

Despite ensuring the correctness of these headers, the verification of the webhook signature consistently fails.

Code:


paypalRouter.post("/subscriptions/webhook", async (req, res) => {

  console.log("Received webhook event", req.body); 

  try {
   
    console.log('Headers:', {
      'paypal-auth-algo': req.headers['paypal-auth-algo'],
      'paypal-cert-url': req.headers['paypal-cert-url'],
      'paypal-transmission-id': req.headers['paypal-transmission-id'],
      'paypal-transmission-sig': req.headers['paypal-transmission-sig'],
      'paypal-transmission-time': req.headers['paypal-transmission-time'],
    });
    const webhookEvent = req.body;

    console.log("Webhook event received:", webhookEvent.event_type);


    const verification = {
      auth_algo: req.headers['paypal-auth-algo'],
      cert_url: req.headers['paypal-cert-url'],
      transmission_id: req.headers['paypal-transmission-id'],
      transmission_sig: req.headers['paypal-transmission-sig'],
      transmission_time: req.headers['paypal-transmission-time'],
      webhook_id: process.env.WEBHOOK_ID,
      webhook_event: webhookEvent,
    };
    console.log('Final Verification Request Payload:', JSON.stringify(verification, null, 2));

    console.log('Verification Payload:', verification);

    const params = new URLSearchParams();
    params.append("grant_type", "client_credentials");

    const authResponse = await axios.post(
      "https://api-m.sandbox.paypal.com/v1/oauth2/token",
      params,
      {
        headers: {
          "Content-Type": "application/x-www-form-urlencoded",
        },
        auth: {
          username: process.env.PAYPAL_CLIENT_ID,
          password: process.env.PAYPAL_SECRET,
        },
      }
    );

    const accessToken = authResponse.data.access_token;

    

    const verifyResponse = await axios.post(
      "https://api-m.sandbox.paypal.com/v1/notifications/verify-webhook-signature",
      verification,
      {
        headers: {
          'Content-Type': 'application/json',
          Authorization: `Bearer ${accessToken}`
        },
      }
    );

 
    if (verifyResponse.data.verification_status === "SUCCESS") {
      // Handle different event types as needed
      if (webhookEvent.event_type === "BILLING.SUBSCRIPTION.ACTIVATED") {
        // Extracting the custom_id and subscription ID from the webhook event
        const userId = webhookEvent.resource.custom_id; // Adjusted based on actual data structure
        const subscriptionId = webhookEvent.resource.id; // Adjusted based on actual data structure

        console.log(`Attempting to confirm subscription for user ${userId} with subscription ID ${subscriptionId}`);

        try {
          // Updating the user's subscription status to 'confirmed'
          const updatedUser = await User.findOneAndUpdate(
              { _id: userId, subscriptionId: subscriptionId }, 
              { $set: { subscriptionStatus: 'confirmed' }},
              { new: true }
          );

          if (updatedUser) {
              console.log("Subscription confirmed for user:", userId);
          } else {
              console.log("No matching user document to update or subscription ID mismatch.");
          }

          return res.status(200).send('Subscription confirmed');
        } catch (error) {
          console.error("Error confirming subscription:", error);
          return res.status(500).send("Error updating subscription status.");
        }
      }
    } else {
      console.log("Failed to verify webhook signature:", verifyResponse.data);
      return res.status(401).send('Webhook signature verification failed');
    }
  }
  }
});

Console.logs

Received webhook event: {}

Headers: {
  'paypal-auth-algo': 'SHA...',
  'paypal-cert-url': 'https://api.sandbox.paypal.com/v1/notifications/certs/CERT...',
  'paypal-transmission-id': 'a5d...',
  'paypal-transmission-sig': 'Rrwi...',
  'paypal-transmission-time': '2024....'
}
Webhook event received: undefined
Final Verification Request Payload: {
  "auth_algo": "SHA-....",
  "cert_url": "https://api.sandbox.paypal.com/v1/notifications/certs/CERT-...",
  "transmission_id": "a5d....",
  "transmission_sig": "Rrw...",
  "transmission_time": "2024....",
  "webhook_id": "string",
  "webhook_event": {}
}
Failed to verify webhook signature: { verification_status: 'FAILURE' }

Research and Attempts:

  • I've verified that the headers mimic what PayPal sends for a real webhook event.
  • I've ensured that the environment variables for the PayPal client ID and secret are correctly set and my WEBHOOK_ID as well.
  • Searched Stack Overflow (SO) and found similar questions, but those solutions (e.g., checking the environment URL, ensuring headers are correct) did not resolve my issue.

Questions:

  1. Are there any common pitfalls or specific configurations for PayPal webhook verification that I might have missed?
  2. Could the issue stem from how I'm setting up the headers in Postman or a misunderstanding of the PayPal webhook verification process?

Any guidance or insights would be greatly appreciated.


Solution

  • Finally, I solved the issue, as Paypal is very concerned about its webhooks security, I can't run my webhook locally with Postman, the solution was to download and set up ngrok and change my webhook URL to the URL ngrok generated for me and add "/paypal/subscriptions/webhook" at the end of the URL something like this:

    https://ngrok-url/paypal/subscriptions/webhook

    on "Sandbox Webhooks"