PayPal webhooks fail in ways that produce no error anywhere you'd think to look: the delivery silently never happens because your URL isn't on port 443, or it happens and your signature check fails because you verified against re-serialized JSON, or you tested with the simulator and verification can't pass the way you wrote it. Here's each failure mode, fastest check first.
Point the webhook at a neutral capture URL so your code is out of the loop. Create a
bin (button below, or curl https://hookden.pages.dev/new), subscribe it as a listener
URL in Application
management under your sandbox app, and fire a test event (or use the simulator —
but read step 4 first).
You'll see the full delivery: paypal-transmission-id,
paypal-transmission-time, paypal-transmission-sig,
paypal-cert-url, paypal-auth-algo headers plus the exact raw
JSON body — which matters more than usual with PayPal, because verification hashes the
raw bytes (step 5).
PayPal delivers webhooks to HTTPS on port 443 only. A listener on
https://example.com:8443/hook or plain HTTP will simply never receive
anything — there's no error event for "we didn't try."
urlfiltering.paloaltonetworks.com to check your domain's
categorization and request recategorization.Your listener must answer every delivery with an HTTP 2xx. Any other outcome — no response, connection refused, 404, 500 — makes PayPal retry that delivery up to 25 times over 3 days. After 3 days it's marked Failed, but you can resend it manually from the Webhook Events view in the dashboard.
id in the body.The Webhooks simulator generates mock events that don't belong to any app, and two things about them break naive verification:
/v1/notifications/verify-webhook-signature will not
succeed the way a real event does.WEBHOOK_ID — not your real
webhook's ID.So: a verification failure on simulator traffic is not evidence your code is wrong,
and a pass on simulator traffic with WEBHOOK_ID hardcoded will fail in
production. Test the crypto with real sandbox events (subscribe the listener to a
sandbox app and trigger a real payment event).
PayPal doesn't use a shared-secret HMAC like Stripe or Shopify. The signed message is a pipe-joined string:
transmissionId | timeStamp | webhookId | crc32
transmissionId — from the paypal-transmission-id header.timeStamp — from paypal-transmission-time.webhookId — not in the message at all; it's the ID
assigned when you subscribed the listener URL. Using the event's id from
the body here is the classic mistake.crc32 — CRC32 checksum of the raw request body, in decimal.
Parse-then-re-stringify the JSON and the checksum changes → verification fails. Same
raw-body discipline that bites
Stripe integrations.Then verify paypal-transmission-sig (base64) against that string using
the public key from the cert at paypal-cert-url (algorithm per
paypal-auth-algo, typically SHA256withRSA). Alternatively, post the whole
thing back to /v1/notifications/verify-webhook-signature — but the
webhook_event you send must be byte-for-byte what you received;
re-serialized JSON can fail there too.
A captured delivery in a bin preserves the raw bytes exactly, so you can compute the CRC32 against reality, copy the request as curl, and replay it at your real handler while you debug. (PayPal's scheme is RSA-cert-based, so the bin's shared-secret HMAC badge doesn't apply here — use the raw body + headers it preserves.)
A public HTTPS URL on port 443 means localhost:3000 is out. Instead of
running a tunnel, give PayPal a stable capture URL and relay deliveries to your
machine:
$ curl -s https://hookden.pages.dev/cli -o hookden && chmod +x hookden $ curl https://hookden.pages.dev/new $ ./hookden relay YOUR_BIN http://localhost:3000
Bodies are re-delivered byte-identical, so the CRC32 in your verification code still matches what PayPal signed. Details: webhooks to localhost without a tunnel.
No signup needed. Or from your terminal: curl https://hookden.pages.dev/new