PayPal Webhooks: Verify Signatures, Test Events and Handle Retries
PayPal webhooks are signed POSTs that PayPal sends to your HTTPS listener when payments are captured, subscriptions change or disputes open. To trust one, verify it: either ask PayPal with POST /v1/notifications/verify-webhook-signature and accept only SUCCESS, or check the RSA signature yourself against PayPal's certificate. This guide covers registration, the simulator, both verification methods in Node.js and PayPal's retry rules, checked against PayPal's webhook docs in September 2026.
Key takeaways
- PayPal delivers webhooks only to HTTPS listeners on port 443 and retries up to 25 times over 3 days until it gets a 2xx.
- Every delivery carries five verification headers:
paypal-transmission-id,paypal-transmission-time,paypal-transmission-sig,paypal-cert-urlandpaypal-auth-algo. - The simplest verification is a POST to
/v1/notifications/verify-webhook-signaturewith those headers, yourwebhook_idand the untouched event; accept onlyverification_status: "SUCCESS". - Offline verification signs
transmissionId|timeStamp|webhookId|crc32, where the CRC32 is computed over the raw body in decimal; re-serialized JSON breaks it. - Simulator (mock) events can't pass postback verification, so test verification with real sandbox events.
- To see exactly what PayPal sends, subscribe a Hooklistener endpoint to your sandbox events: it records every
paypal-*header and body byte, forwards deliveries to localhost through the CLI tunnel, and lets you replay them while you fix your handler.
Step 1: How do I register a PayPal webhook listener?
Add an HTTPS URL on port 443 to your REST app in the Developer Dashboard, or create it through the Webhooks API, and keep the Webhook ID it returns. You need that ID for every signature check.
- In Developer Dashboard → Apps & Credentials, open your REST app (sandbox or live).
- Under Webhooks, add your listener URL (HTTPS only).
- Select the event types you need, for example
PAYMENT.CAPTURE.COMPLETED,CHECKOUT.ORDER.APPROVEDorBILLING.SUBSCRIPTION.CANCELLED. - Copy the Webhook ID and store it per environment.
The same thing through the API:
# 1. Get an OAuth access token
curl -s -u "$PAYPAL_CLIENT_ID:$PAYPAL_CLIENT_SECRET" \
-d grant_type=client_credentials \
https://api-m.sandbox.paypal.com/v1/oauth2/token
# 2. Create the webhook subscription (the response includes "id": your Webhook ID)
curl -s -X POST https://api-m.sandbox.paypal.com/v1/notifications/webhooks \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/paypal/webhooks",
"event_types": [{ "name": "PAYMENT.CAPTURE.COMPLETED" }]
}'Use api-m.paypal.com with live credentials for production. Sandbox and live are separate subscriptions with separate Webhook IDs.
Step 2: How do I send a test PayPal webhook?
Use the Webhooks simulator in the Developer Dashboard, or call POST /v1/notifications/simulate-event, to send a mock event to your URL. Mock events are good for testing parsing and routing, but PayPal's postback verification doesn't work for them.
curl -s -X POST https://api-m.sandbox.paypal.com/v1/notifications/simulate-event \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/paypal/webhooks",
"event_type": "PAYMENT.CAPTURE.COMPLETED",
"resource_version": "2.0"
}'
# Pass "webhook_id" instead of "url" to target an existing subscription.To test signature verification end to end, trigger a real sandbox event instead, for example by capturing a sandbox order. Pointing the subscription at a webhook tester first shows you the exact paypal-transmission-sig and paypal-cert-url headers your code will receive.
Step 3: How do I verify a PayPal webhook signature?
Read the five paypal-* headers and the raw body, then either post them to PayPal's verify-webhook-signature API or check the RSA signature locally. The headers:
| Header | Contains |
|---|---|
paypal-transmission-id | Unique ID of this transmission |
paypal-transmission-time | Transmission timestamp |
paypal-transmission-sig | Base64-encoded signature |
paypal-cert-url | URL of the X.509 certificate that verifies the signature |
paypal-auth-algo | Signing algorithm, SHA256withRSA |
Option A: the verify-webhook-signature API
POST the headers, your Webhook ID and the event to /v1/notifications/verify-webhook-signature. PayPal answers with verification_status set to SUCCESS or FAILURE. Pass the event through as the raw body text rather than JSON.stringify(JSON.parse(body)): re-serializing can change number formatting or escaping and turn a genuine event into a FAILURE.
const PAYPAL_API = process.env.PAYPAL_API ?? "https://api-m.sandbox.paypal.com"; // live: https://api-m.paypal.com
export async function verifyWithPayPal(
rawBody: string,
headers: Record<string, string | string[] | undefined>,
webhookId: string,
accessToken: string, // from /v1/oauth2/token, cached until it expires
): Promise<boolean> {
const h = (name: string) => String(headers[name] ?? "");
// Splice the raw event in as-is instead of re-serializing it
const body =
`{"auth_algo":${JSON.stringify(h("paypal-auth-algo"))},` +
`"cert_url":${JSON.stringify(h("paypal-cert-url"))},` +
`"transmission_id":${JSON.stringify(h("paypal-transmission-id"))},` +
`"transmission_sig":${JSON.stringify(h("paypal-transmission-sig"))},` +
`"transmission_time":${JSON.stringify(h("paypal-transmission-time"))},` +
`"webhook_id":${JSON.stringify(webhookId)},` +
`"webhook_event":${rawBody}}`;
const res = await fetch(`${PAYPAL_API}/v1/notifications/verify-webhook-signature`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${accessToken}` },
body,
});
if (!res.ok) return false;
const { verification_status } = await res.json();
return verification_status === "SUCCESS";
}- Use the Webhook ID and API host of the environment the event came from.
- Cache the OAuth token; don't fetch a new one per webhook.
- It adds one network round trip per event, which is why high-volume listeners verify offline.
Option B: verify offline with CRC32 and the certificate
Build the string transmissionId|timeStamp|webhookId|crc32, where crc32 is the CRC32 of the raw body in decimal, then verify paypal-transmission-sig with SHA-256 RSA against the certificate at paypal-cert-url. Only download certificates from PayPal hosts; otherwise an attacker can point you at their own certificate.
import crypto from "node:crypto";
import crc32 from "buffer-crc32";
const certCache = new Map<string, string>();
async function getCertificate(certUrl: string): Promise<string> {
const url = new URL(certUrl);
if (url.protocol !== "https:" || !url.hostname.endsWith(".paypal.com")) {
throw new Error(`Refusing certificate from ${url.hostname}`);
}
if (!certCache.has(certUrl)) {
const res = await fetch(certUrl);
if (!res.ok) throw new Error(`Certificate download failed: ${res.status}`);
certCache.set(certUrl, await res.text());
}
return certCache.get(certUrl)!;
}
export async function verifyPayPalOffline(
rawBody: Buffer, // exact bytes PayPal sent
headers: Record<string, string | string[] | undefined>,
webhookId: string,
): Promise<boolean> {
const h = (name: string) => String(headers[name] ?? "");
const checksum = crc32.unsigned(rawBody).toString(); // decimal, from raw bytes
const message = [h("paypal-transmission-id"), h("paypal-transmission-time"), webhookId, checksum].join("|");
const certificate = await getCertificate(h("paypal-cert-url"));
return crypto.verify(
"sha256",
Buffer.from(message),
certificate,
Buffer.from(h("paypal-transmission-sig"), "base64"),
);
}- Compute the CRC32 from the raw request body. Parsing and re-stringifying JSON changes the bytes.
- Use the Webhook ID of the current environment (sandbox vs live).
- Cache certificates by URL, and fetch again if verification fails after PayPal rotates them.
Step 4: How should I process PayPal events safely?
Store the event, answer 200, and do the work asynchronously and idempotently. PayPal can deliver the same event more than once and out of order, so re-fetch the order, capture or subscription from the REST API when accuracy matters.
- Persist processed event IDs (from
event.id) so retries don't double-fulfil an order. - Respond with
200 OKonce the payload is stored or queued. - Log verification failures with the headers and a hash of the body for triage.
Step 5: How does PayPal retry, and how do I monitor it?
PayPal retries a delivery up to 25 times over 3 days if your listener doesn't return a 2xx or can't be reached. When you go live, create the live subscription and watch delivery status in the dashboard's webhook events view, where you can also resend events after a fix.
Launch checklist
- Separate sandbox and live listeners, credentials and Webhook IDs.
- Make sure WAF or bot rules don't block PayPal's POSTs.
- Alert on repeated 4xx/5xx responses from your listener.
Troubleshooting tips
FAILUREon real events: wrong-environment Webhook ID, or the body was re-serialized.FAILUREon simulator events: expected; use a real sandbox event.- No deliveries at all: check TLS on port 443 and that the URL doesn't redirect.
Summary Table
| Step | Action | Notes |
|---|---|---|
| Register | Add HTTPS listener via dashboard or /v1/notifications/webhooks | Port 443; save the Webhook ID per environment |
| Test | Simulator or /v1/notifications/simulate-event | Mock events fail postback verification |
| Verify | verify-webhook-signature or CRC32 + certificate | Raw body, matching Webhook ID, accept only SUCCESS |
| Process | Store event, dedupe on event.id | Respond 200 OK fast |
| Monitor | Watch delivery status and failures | Up to 25 retries over 3 days; resend after fixes |
Test PayPal webhooks with Hooklistener
Signature bugs come from bytes you can't see. Hooklistener gives you a public HTTPS URL that records every PayPal delivery exactly as sent.
- Capture: create an endpoint in the Hooklistener dashboard, subscribe its URL to your sandbox events, and complete a sandbox payment. You get the five
paypal-*headers and the raw body. - Forward to localhost:
hooklistener login, thenhooklistener tunnel --port 3000, and register the tunnel URL so live sandbox events reach your dev server. - Replay: resend a captured
PAYMENT.CAPTURE.COMPLETEDto your handler after each change instead of making another sandbox payment. Save it as a replay case to rerun later. - From your coding agent: with the Hooklistener MCP server connected, Claude Code or Codex can
create_endpoint, wait for PayPal's delivery withwait_for_request, andreplay_requestit against the handler it is writing.
FAQ
How do I verify a PayPal webhook signature?
Either POST the five paypal-* headers, your webhook_id and the unmodified event to /v1/notifications/verify-webhook-signature and accept only verification_status SUCCESS, or verify it yourself: build transmissionId|timeStamp|webhookId|crc32(rawBody), then check paypal-transmission-sig (base64, SHA256withRSA) against the certificate from paypal-cert-url.
What does verify-webhook-signature return?
A JSON object with verification_status set to "SUCCESS" or "FAILURE". It does not return VALID or INVALID. The call needs an OAuth access token and goes to https://api-m.sandbox.paypal.com in sandbox or https://api-m.paypal.com in live.
Why does PayPal webhook verification fail for simulator events?
PayPal's postback verification does not work for mock events sent by the Webhooks simulator. Use simulator events to test parsing and routing, and test signature verification with real sandbox events, for example by completing a sandbox payment.
How many times does PayPal retry a webhook?
Up to 25 times over 3 days, whenever your listener does not answer with a 2xx status or cannot be reached. Store event.id and skip duplicates, and return 200 as soon as the event is safely stored.
How do I see the raw PayPal webhook headers and body?
Subscribe a Hooklistener endpoint URL to the events in the PayPal Developer Dashboard, trigger a sandbox payment, and open the captured request. It shows every paypal-* header and the exact body bytes, which is what signature verification depends on.