PayPal's IPN flow has four steps. PayPal POSTs the message to your listener. Your listener returns an empty HTTP 200. Your listener POSTs the complete message back, prefixed with cmd=_notify-validate. PayPal replies with one word, VERIFIED or INVALID. Most "IPN not working" problems break one of these steps.
In your PayPal account, open IPN History. It shows each message, the URL it was sent to, its status and your server's HTTP response code. Sent means your listener answered with 200. Failed or Retrying means it didn't. You can also resend messages from this page. By default a resend goes to the message's original URL, so tick the option to send it to your current profile URL if you've changed it.
notify_url in your button or API call overrides the profile setting, so check both for typos.domain.com to www.domain.com in .htaccess, drops the POST data. Point the IPN at the final URL.https://ipnpb.sandbox.paypal.com/cgi-bin/webscr. Live IPNs go to https://ipnpb.paypal.com/cgi-bin/webscr.charset field. Rebuild the body from the raw POST, not from a re-ordered or decoded dictionary. In PHP, PayPal suggests rawurlencode and rawurldecode.User-Agent header that describes your listener.app.post('/ipn', express.raw({ type: '*/*' }), async (req, res) => {
res.status(200).end(); // empty 200 first
const raw = req.body.toString('utf8'); // original order and encoding
const sandbox = /(^|&)test_ipn=1(&|$)/.test(raw);
const url = sandbox ? 'https://ipnpb.sandbox.paypal.com/cgi-bin/webscr' : 'https://ipnpb.paypal.com/cgi-bin/webscr';
const r = await fetch(url, { method: 'POST', body: 'cmd=_notify-validate&' + raw,
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'User-Agent': 'MyShop-IPN-Listener' } });
if ((await r.text()) !== 'VERIFIED') return; // log INVALID and stop
// then check payment_status, receiver_email, amount/currency and that txn_id is new
});
The guide is Stripe-only. The kit is a self-hosted Cloudflare Worker relay that adds retries, replay and a dead-letter list in front of your endpoint. Its built-in schemes cover Stripe, Lemon Squeezy, Shopify, Paddle Billing, Square, Twilio (status callbacks) and GitHub. PayPal IPN isn't supported, because it uses postback verification, not a signature header.
Fix Your Stripe Webhooks guide · Self-Hosted Webhook Relay Kit. Code LAUNCH40 = 40% off until Sun Oct 11, 6:40 AM MT.
Once you get VERIFIED, PayPal's docs still tell you to check the payment status, the receiver, the amount and currency, and that you haven't already processed that txn_id.
Free download: Webhook debugging cheat sheet.
Fighting webhooks on more than one platform? The Fix Your Stripe Webhooks guide ($9) covers raw-body and signature problems in depth, and the Self-Hosted Webhook Relay Kit ($19) adds retries, replay and a dead-letter list in front of any endpoint.