PayPal Webhooks: Verify Signatures, Test Events and Handle Retries

Updated September 26, 202611 min read

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-url and paypal-auth-algo.
  • The simplest verification is a POST to /v1/notifications/verify-webhook-signature with those headers, your webhook_id and the untouched event; accept only verification_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.

  1. In Developer Dashboard → Apps & Credentials, open your REST app (sandbox or live).
  2. Under Webhooks, add your listener URL (HTTPS only).
  3. Select the event types you need, for example PAYMENT.CAPTURE.COMPLETED, CHECKOUT.ORDER.APPROVED or BILLING.SUBSCRIPTION.CANCELLED.
  4. 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:

HeaderContains
paypal-transmission-idUnique ID of this transmission
paypal-transmission-timeTransmission timestamp
paypal-transmission-sigBase64-encoded signature
paypal-cert-urlURL of the X.509 certificate that verifies the signature
paypal-auth-algoSigning 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.

verify-paypal.ts
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.

verify-paypal-offline.ts
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 OK once 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

  • FAILURE on real events: wrong-environment Webhook ID, or the body was re-serialized.
  • FAILURE on 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

StepActionNotes
RegisterAdd HTTPS listener via dashboard or /v1/notifications/webhooksPort 443; save the Webhook ID per environment
TestSimulator or /v1/notifications/simulate-eventMock events fail postback verification
Verifyverify-webhook-signature or CRC32 + certificateRaw body, matching Webhook ID, accept only SUCCESS
ProcessStore event, dedupe on event.idRespond 200 OK fast
MonitorWatch delivery status and failuresUp 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.

  1. 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.
  2. Forward to localhost: hooklistener login, then hooklistener tunnel --port 3000, and register the tunnel URL so live sandbox events reach your dev server.
  3. Replay: resend a captured PAYMENT.CAPTURE.COMPLETED to your handler after each change instead of making another sandbox payment. Save it as a replay case to rerun later.
  4. From your coding agent: with the Hooklistener MCP server connected, Claude Code or Codex can create_endpoint, wait for PayPal's delivery with wait_for_request, and replay_request it 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.

Further Reading