Gareth Wilson Gareth Wilson

How to Secure and Verify PayPal Webhooks with Hookdeck

Published


PayPal processes billions of transactions across more than 200 markets worldwide, making webhook security essential for any integration handling payment data. When events occur — successful payments, subscription changes, refunds, disputes — PayPal sends webhook notifications to your application so you can respond in real-time.

However, accepting webhooks without proper verification leaves your application vulnerable to spoofed requests from malicious actors. In this guide, we'll show you how to secure and verify PayPal webhooks manually and with Hookdeck, ensuring that every webhook your application processes genuinely originated from PayPal. For a comprehensive overview of PayPal webhook capabilities, see our guide to PayPal webhooks.

Why webhook security matters

Webhooks are HTTP callbacks that notify your application when events occur. Without verification, an attacker could send fake webhook payloads to your endpoint, potentially triggering unauthorized actions like:

  • Granting access to products or services for payments that never occurred
  • Processing fraudulent refund notifications
  • Manipulating subscription states to extend free access
  • Corrupting your financial records with fabricated transaction data

PayPal addresses this by signing every webhook with RSA-SHA256 using a certificate-based system. By verifying this signature, you can be confident that webhooks are authentic and haven't been tampered with in transit.

How PayPal webhook signatures work

PayPal uses RSA-SHA256 asymmetric signature verification — a more complex system than the HMAC-based signing used by most webhook providers. Every webhook request includes several verification headers:

HeaderDescription
PAYPAL-TRANSMISSION-IDA unique UUID for the transmission
PAYPAL-TRANSMISSION-TIMEISO 8601 timestamp of when the request was sent
PAYPAL-TRANSMISSION-SIGBase64-encoded RSA-SHA256 signature
PAYPAL-CERT-URLURL of PayPal's signing certificate (public key)
PAYPAL-AUTH-ALGOAlgorithm identifier, typically SHA256withRSA

The signature generation process

PayPal generates the signature by:

  1. Computing an unsigned CRC32 checksum of the raw request body
  2. Constructing a pipe-delimited string: {transmission_id}|{transmission_time}|{webhook_id}|{crc32}
  3. Signing this string with PayPal's private key using RSA-SHA256
  4. Base64-encoding the result

To verify a webhook, you reconstruct this string, fetch PayPal's certificate, and use the public key to verify the signature.

Two verification methods

PayPal offers two approaches to verifying webhooks:

Self-cryptographic (offline) verification

Fetch the certificate from the PAYPAL-CERT-URL, extract the public key, reconstruct the signed string, and verify locally. This method is faster (no API round-trip) but more complex to implement.

Verify Webhook Signature API (postback)

POST the transmission details to PayPal's POST /v1/notifications/verify-webhook-signature endpoint and let PayPal verify on their end. This is simpler to implement but adds latency.

Important: The postback API does not work with PayPal's webhook simulator/mock events — it only validates real webhook deliveries.

Getting your PayPal webhook ID

PayPal assigns a unique webhook ID to each webhook endpoint you register. This ID is part of the signed string and is required for verification:

  1. Log in to the PayPal Developer Dashboard
  2. Select your REST API app
  3. Navigate to Webhooks
  4. Create or select a webhook endpoint
  5. Copy the Webhook ID displayed for your endpoint

Important: Store the webhook ID securely — you'll need it for every signature verification.

Verifying webhook signatures manually

Self-cryptographic verification

Node.js verification example

const express = require("express");
const crypto = require("crypto");
const https = require("https");
const { Buffer } = require("buffer");

const app = express();
const PAYPAL_WEBHOOK_ID = process.env.PAYPAL_WEBHOOK_ID;

// Cache for PayPal certificates
const certCache = new Map();

async function fetchCertificate(certUrl) {
  // Only accept certificates from PayPal's domain
  const url = new URL(certUrl);
  if (!url.hostname.endsWith(".paypal.com") &&
      !url.hostname.endsWith(".symantec.com") &&
      !url.hostname.endsWith(".verisign.com")) {
    throw new Error("Certificate URL is not from a trusted PayPal domain");
  }

  if (certCache.has(certUrl)) {
    return certCache.get(certUrl);
  }

  return new Promise((resolve, reject) => {
    https.get(certUrl, (res) => {
      let data = "";
      res.on("data", (chunk) => (data += chunk));
      res.on("end", () => {
        certCache.set(certUrl, data);
        resolve(data);
      });
      res.on("error", reject);
    });
  });
}

function computeCrc32(buffer) {
  // CRC32 lookup table
  const table = new Uint32Array(256);
  for (let i = 0; i < 256; i++) {
    let crc = i;
    for (let j = 0; j < 8; j++) {
      crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1;
    }
    table[i] = crc;
  }

  let crc = 0xffffffff;
  for (const byte of buffer) {
    crc = (crc >>> 8) ^ table[(crc ^ byte) & 0xff];
  }

  return (crc ^ 0xffffffff) >>> 0; // Return unsigned 32-bit integer
}

async function verifyPayPalWebhook(req) {
  const transmissionId = req.headers["paypal-transmission-id"];
  const transmissionTime = req.headers["paypal-transmission-time"];
  const transmissionSig = req.headers["paypal-transmission-sig"];
  const certUrl = req.headers["paypal-cert-url"];
  const rawBody = req.body;

  if (!transmissionId || !transmissionTime || !transmissionSig || !certUrl) {
    return false;
  }

  // Compute CRC32 of the raw body
  const crc32 = computeCrc32(Buffer.from(rawBody));

  // Construct the signed message
  const message = `${transmissionId}|${transmissionTime}|${PAYPAL_WEBHOOK_ID}|${crc32}`;

  // Fetch and cache the certificate
  const cert = await fetchCertificate(certUrl);

  // Verify the signature
  const verifier = crypto.createVerify("SHA256");
  verifier.update(message);

  return verifier.verify(cert, transmissionSig, "base64");
}

app.post(
  "/webhooks/paypal",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    try {
      const isValid = await verifyPayPalWebhook(req);

      if (!isValid) {
        return res.status(401).json({ error: "Invalid signature" });
      }

      const event = JSON.parse(req.body.toString());

      // Process the verified webhook
      switch (event.event_type) {
        case "PAYMENT.CAPTURE.COMPLETED":
          handlePaymentCompleted(event);
          break;
        case "BILLING.SUBSCRIPTION.ACTIVATED":
          handleSubscriptionActivated(event);
          break;
        default:
          console.log(`Received event: ${event.event_type}`);
      }

      res.status(200).json({ received: true });
    } catch (error) {
      console.error("Webhook verification error:", error);
      res.status(500).json({ error: "Verification failed" });
    }
  }
);

app.listen(3000, () => {
  console.log("Webhook server listening on port 3000");
});

Python verification example

import hashlib
import base64
import os
import struct
import requests as http_requests
from cryptography.x509 import load_pem_x509_certificate
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives import hashes
from flask import Flask, request, jsonify, abort

app = Flask(__name__)
PAYPAL_WEBHOOK_ID = os.environ.get("PAYPAL_WEBHOOK_ID")

# Certificate cache
cert_cache = {}

def fetch_certificate(cert_url):
    """Fetch and cache PayPal's signing certificate."""
    if cert_url in cert_cache:
        return cert_cache[cert_url]

    response = http_requests.get(cert_url)
    cert_pem = response.text
    cert_cache[cert_url] = cert_pem
    return cert_pem

def compute_crc32(data):
    """Compute unsigned CRC32 of the data."""
    import binascii
    return binascii.crc32(data) & 0xffffffff

def verify_paypal_webhook(raw_body, headers):
    """Verify a PayPal webhook signature."""
    transmission_id = headers.get("Paypal-Transmission-Id", "")
    transmission_time = headers.get("Paypal-Transmission-Time", "")
    transmission_sig = headers.get("Paypal-Transmission-Sig", "")
    cert_url = headers.get("Paypal-Cert-Url", "")

    if not all([transmission_id, transmission_time, transmission_sig, cert_url]):
        return False

    # Compute CRC32 of the raw body
    crc32 = compute_crc32(raw_body)

    # Construct the signed message
    message = f"{transmission_id}|{transmission_time}|{PAYPAL_WEBHOOK_ID}|{crc32}"

    # Fetch the certificate
    cert_pem = fetch_certificate(cert_url)
    cert = load_pem_x509_certificate(cert_pem.encode())
    public_key = cert.public_key()

    # Verify the signature
    try:
        public_key.verify(
            base64.b64decode(transmission_sig),
            message.encode(),
            padding.PKCS1v15(),
            hashes.SHA256()
        )
        return True
    except Exception:
        return False

@app.route("/webhooks/paypal", methods=["POST"])
def handle_webhook():
    raw_body = request.get_data()

    if not verify_paypal_webhook(raw_body, request.headers):
        abort(401, "Invalid signature")

    event = request.get_json()
    print(f"Received verified {event.get('event_type')} event")

    return jsonify({"received": True}), 200

if __name__ == "__main__":
    app.run(port=3000)

Postback verification (simpler alternative)

If you prefer not to handle certificate fetching and cryptographic verification yourself, you can use PayPal's Verify Webhook Signature API:

const axios = require("axios");

async function verifyViaPostback(req) {
  const accessToken = await getPayPalAccessToken(); // Your OAuth token

  const response = await axios.post(
    "https://api-m.paypal.com/v1/notifications/verify-webhook-signature",
    {
      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.PAYPAL_WEBHOOK_ID,
      webhook_event: JSON.parse(req.body.toString()),
    },
    {
      headers: {
        Authorization: `Bearer ${accessToken}`,
        "Content-Type": "application/json",
      },
    }
  );

  return response.data.verification_status === "SUCCESS";
}

The postback API adds latency (an extra API round-trip) and does not work with PayPal's webhook simulator. Use self-cryptographic verification in production for better performance.

Critical security best practices

Validate the certificate URL

Before fetching a certificate, verify that the PAYPAL-CERT-URL points to a trusted PayPal domain. Attackers could substitute a malicious certificate URL to make spoofed webhooks appear valid.

Cache certificates

PayPal's signing certificates change infrequently. Cache them to avoid an HTTPS fetch on every webhook delivery. Invalidate the cache when verification fails to handle certificate rotation.

Preserve the raw request body

The CRC32 checksum is computed on the exact bytes PayPal sends. Any parsing, reformatting, or re-serialization of the payload before verification will produce a different CRC32 and fail verification. This is the most common cause of PayPal signature verification failures.

Store the webhook ID securely

The webhook ID is part of the signed string. Store it in environment variables or a secrets manager — never hardcode it.

Process webhooks idempotently

PayPal may retry delivery up to 25 times over 3 days. Use the id field in the event payload to deduplicate and ensure idempotent processing.

Simplifying verification with Hookdeck

PayPal's RSA-SHA256 verification is significantly more complex than HMAC-based systems — it requires certificate fetching, CRC32 computation, and asymmetric cryptography. Hookdeck provides a webhook gateway that handles this complexity automatically.

What is Hookdeck?

Hookdeck provides an event gateway that sits between webhook providers (like PayPal) and your application. It provides:

  • Automatic signature verification
  • Event queuing and retry logic
  • Request logging and debugging tools
  • Local development tunneling

Setting up PayPal webhooks with Hookdeck

Step 1: Install the Hookdeck CLI

  npm install hookdeck-cli -g
  
  
  yarn global add hookdeck-cli
  
  
    brew install hookdeck/hookdeck/hookdeck
    
    
  1.     scoop bucket add hookdeck https://github.com/hookdeck/scoop-hookdeck-cli.git
        
        
  2.   scoop install hookdeck
      
      
  1. Download the latest release's tar.gz file.

  2.     tar -xvf hookdeck_X.X.X_linux_x86_64.tar.gz
        
        
  3.   ./hookdeck
      
      

Step 2: Authenticate

hookdeck login

This opens your browser for authentication. If you don't have a Hookdeck account, you can create one during this step.

Step 3: Create a connection

hookdeck listen 3000 paypal-source --path /webhooks/paypal

This command:

  • Creates a public URL for receiving webhooks
  • Forwards events to http://localhost:3000/webhooks/paypal
  • Displays the Source URL to configure in PayPal

Step 4: Configure PayPal

  1. Copy the Hookdeck Source URL from the CLI output
  2. In the PayPal Developer Dashboard, select your REST API app
  3. Navigate to Webhooks and click Add Webhook
  4. Set the Webhook URL to your Hookdeck Source URL
  5. Select the event types you want to receive
  6. Click Save
  7. Note the Webhook ID assigned by PayPal

Step 5: Configure source verification

  1. Open the Hookdeck Dashboard
  2. Navigate to Connections and select your source
  3. Under Advanced Source Configuration, enable Source Authentication
  4. Select PayPal from the list of platforms (if available), or configure using the Custom verification type with the PayPal-specific settings
  5. Click Save

How Hookdeck verification works

When verification is enabled:

  1. Hookdeck receives the webhook from PayPal
  2. Hookdeck handles the certificate fetching, CRC32 computation, and RSA-SHA256 verification automatically
  3. Valid requests are forwarded to your endpoint with x-hookdeck-verified: true
  4. Invalid requests are rejected and logged as "Verification Failed"

This means your application can trust any request from Hookdeck without implementing PayPal's complex cryptographic verification. You only need to implement Hookdeck's signature verification on your server.

Troubleshooting common issues

Signature mismatch

If signatures don't match, verify:

  1. Raw body usage: This is the most common issue. Ensure you're computing CRC32 on the exact bytes received, not a parsed/re-serialized version
  2. Webhook ID accuracy: Confirm the webhook ID matches the one PayPal assigned to your endpoint
  3. Certificate URL validation: Ensure you're fetching the certificate from the URL in the PAYPAL-CERT-URL header, not a hardcoded URL
  4. CRC32 implementation: PayPal uses unsigned 32-bit CRC32 — ensure your implementation returns the correct unsigned value

Postback verification returns FAILURE

  1. Simulator events: The postback API does not validate simulator-generated events — it only works with real webhook deliveries
  2. Webhook ID mismatch: The webhook ID in your request must match the endpoint that received the webhook
  3. Sandbox vs. production: Use the correct API base URL (api-m.sandbox.paypal.com for sandbox, api-m.paypal.com for production)

Webhooks not arriving

  1. HTTPS required: PayPal only delivers webhooks to HTTPS endpoints on port 443
  2. Check the dashboard: Review webhook delivery history in the PayPal Developer Dashboard for error codes
  3. Event subscription: Ensure your webhook endpoint is subscribed to the event types you expect

Conclusion

Securing PayPal webhooks requires verifying the RSA-SHA256 signature on every incoming request — a more complex process than typical HMAC-based verification. While you can implement this verification manually or use PayPal's postback API, Hookdeck simplifies the process by handling certificate management, CRC32 computation, and cryptographic verification automatically, allowing you to focus on building your integration rather than infrastructure.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.