How to Secure and Verify PayPal Webhooks with Hookdeck
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:
| Header | Description |
|---|---|
PAYPAL-TRANSMISSION-ID | A unique UUID for the transmission |
PAYPAL-TRANSMISSION-TIME | ISO 8601 timestamp of when the request was sent |
PAYPAL-TRANSMISSION-SIG | Base64-encoded RSA-SHA256 signature |
PAYPAL-CERT-URL | URL of PayPal's signing certificate (public key) |
PAYPAL-AUTH-ALGO | Algorithm identifier, typically SHA256withRSA |
The signature generation process
PayPal generates the signature by:
- Computing an unsigned CRC32 checksum of the raw request body
- Constructing a pipe-delimited string:
{transmission_id}|{transmission_time}|{webhook_id}|{crc32} - Signing this string with PayPal's private key using RSA-SHA256
- 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:
- Log in to the PayPal Developer Dashboard
- Select your REST API app
- Navigate to Webhooks
- Create or select a webhook endpoint
- 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
-
scoop bucket add hookdeck https://github.com/hookdeck/scoop-hookdeck-cli.git -
scoop install hookdeck
-
Download the latest release's tar.gz file.
-
tar -xvf hookdeck_X.X.X_linux_x86_64.tar.gz -
./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
- Copy the Hookdeck Source URL from the CLI output
- In the PayPal Developer Dashboard, select your REST API app
- Navigate to Webhooks and click Add Webhook
- Set the Webhook URL to your Hookdeck Source URL
- Select the event types you want to receive
- Click Save
- Note the Webhook ID assigned by PayPal
Step 5: Configure source verification
- Open the Hookdeck Dashboard
- Navigate to Connections and select your source
- Under Advanced Source Configuration, enable Source Authentication
- Select PayPal from the list of platforms (if available), or configure using the Custom verification type with the PayPal-specific settings
- Click Save
How Hookdeck verification works
When verification is enabled:
- Hookdeck receives the webhook from PayPal
- Hookdeck handles the certificate fetching, CRC32 computation, and RSA-SHA256 verification automatically
- Valid requests are forwarded to your endpoint with
x-hookdeck-verified: true - 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:
- 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
- Webhook ID accuracy: Confirm the webhook ID matches the one PayPal assigned to your endpoint
- Certificate URL validation: Ensure you're fetching the certificate from the URL in the
PAYPAL-CERT-URLheader, not a hardcoded URL - CRC32 implementation: PayPal uses unsigned 32-bit CRC32 — ensure your implementation returns the correct unsigned value
Postback verification returns FAILURE
- Simulator events: The postback API does not validate simulator-generated events — it only works with real webhook deliveries
- Webhook ID mismatch: The webhook ID in your request must match the endpoint that received the webhook
- Sandbox vs. production: Use the correct API base URL (
api-m.sandbox.paypal.comfor sandbox,api-m.paypal.comfor production)
Webhooks not arriving
- HTTPS required: PayPal only delivers webhooks to HTTPS endpoints on port 443
- Check the dashboard: Review webhook delivery history in the PayPal Developer Dashboard for error codes
- 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.