If you take money through PayPal's legacy Instant Payment Notification (IPN) — a Website Payments Standard button, a WooCommerce/Gravity Forms PayPal Standard gateway, or a hand-rolled listener.php that's been quietly confirming orders since 2014 — the ground is already moving under you. PayPal stopped letting merchants generate new IPN/WPS credentials at the end of 2025, Website Payments Standard is deprecated as of January 2026, and full end-of-life lands in January 2027. The replacement is REST Webhooks.
The deadline isn't the dangerous part. Jan 2027 is far enough that everyone plans for it. The dangerous part is the migration window we're in right now, because IPN→Webhooks is not a rename. It changes four things at once, and each one has a failure mode that returns HTTP 200 to PayPal — which makes PayPal stop retrying — while your system silently never records the payment.
IPN and Webhooks disagree on almost everything
| Surface | IPN (legacy) | Webhooks (REST) |
|---|---|---|
| Content type | application/x-www-form-urlencoded |
application/json |
| Payload shape | flat key/value pairs | nested resource object |
| Amount field |
mc_gross = "19.95"
|
resource.amount.value + resource.amount.currency_code
|
| Status field |
payment_status = "Completed"
|
event_type and resource.status = "COMPLETED"
|
| Buyer email |
payer_email (top level) |
no equivalent in the capture resource |
| Custom ref | custom |
custom_id |
| Transaction id | txn_id |
resource.id (capture id) / id (event id WH-…) |
| Verification | POST back cmd=_notify-validate, expect VERIFIED
|
RSA-SHA256 over raw body + PAYPAL-TRANSMISSION-* headers + cert chain |
A migration that treats this as "point the same handler at the new payload" is broken on every row. Here are the four that fail quietly.
1. The body parser still expects form-encoding — so every field is undefined
IPN listeners are wired to read a form POST:
app.post('/paypal/ipn', express.urlencoded({ extended: false }), (req, res) => {
const status = req.body.payment_status; // "Completed"
const amount = req.body.mc_gross; // "19.95"
if (status === 'Completed') fulfillOrder(req.body.custom, amount);
res.sendStatus(200);
});
Point PayPal Webhooks at that endpoint without touching the parser and the JSON body lands on express.urlencoded, which parses it into a single garbage key or an empty object. req.body.payment_status is undefined, the === 'Completed' check is false, fulfillOrder never runs — and the handler still falls through to res.sendStatus(200).
That 200 is the trap. PayPal treats 2xx as "delivered" and stops retrying. The payment is captured, the buyer is charged, your webhook said OK, and the order is never fulfilled. Nothing in your logs is red. The only signal is a customer emailing "I paid and got nothing" days later — and by then the webhook is long past its retry window, so you can't even replay it without going to the PayPal dashboard.
2. Field paths moved, and the ones that didn't move changed meaning
Even after you switch to a JSON parser, a 1:1 field map fails because the data is nested and renamed:
// IPN brain, JSON body
const amount = req.body.mc_gross; // undefined → NaN downstream
const ref = req.body.custom; // undefined (it's custom_id now)
const email = req.body.payer_email; // undefined — and unrecoverable here
The correct paths are req.body.resource.amount.value (a string, not a number — "19.95"), req.body.resource.amount.currency_code (now a separate field; IPN folded currency into mc_currency alongside mc_gross), and req.body.resource.custom_id.
payer_email is the sharp one. IPN handed you the buyer's email on every notification. The PAYMENT.CAPTURE.COMPLETED resource does not contain the payer email at all — payer identity lives on the order, one API call up, not on the capture. Any code that keys a customer record, sends a receipt, or matches an account off payer_email gets undefined and either crashes (loud, lucky) or writes a blank/null email into your users table (silent, unlucky). The fix is a follow-up GET /v2/checkout/orders/{id} — an architectural change, not a field rename.
And amount being a string means mc_gross * quantity or any arithmetic that used to coerce cleanly now produces NaN or string concatenation. "19.95" < 20.00 is true by coercion but "100.00" < 20.00 is false — comparisons mostly work until the one order where they don't.
3. Status vocabulary: "Completed" → "COMPLETED", in two places
IPN had one status field with title-case values: payment_status = "Completed" | "Pending" | "Refunded" | "Denied". Webhooks split that into two layers, both upper-case:
-
event_type—"PAYMENT.CAPTURE.COMPLETED","PAYMENT.CAPTURE.REFUNDED","PAYMENT.CAPTURE.DENIED" -
resource.status—"COMPLETED","PENDING","DECLINED"
Code carried over verbatim — if (status === 'Completed') — silently never matches "COMPLETED". No error; the branch just never fires, so paid orders sit forever in "awaiting payment." The case flip is the kind of thing that passes a quick eyeball review (Completed and COMPLETED read the same when you're skimming) and fails 100% of the time in production.
The deeper trap is the vocabulary split. A refund used to arrive as payment_status = "Refunded" on the same field you were already reading. Now a refund is a different event_type (PAYMENT.CAPTURE.REFUNDED) that your handler may not even be subscribed to. If you only registered PAYMENT.CAPTURE.COMPLETED, refunds and chargebacks simply never reach you — your books say paid, PayPal says refunded, and the drift is invisible until reconciliation.
4. Verification flips from a postback handshake to RSA-SHA256 — and skip-verify becomes an open wallet
IPN verification was a callback: take the exact payload, POST it back to PayPal with cmd=_notify-validate, and trust it only if PayPal replies VERIFIED. Webhooks replace that entirely with offline RSA-SHA256: you reconstruct a signing string from the raw request body, the PAYPAL-TRANSMISSION-ID, PAYPAL-TRANSMISSION-TIME, your webhook id, and a CRC32 of the body, then verify the PAYPAL-TRANSMISSION-SIG against the public cert at PAYPAL-CERT-URL. (Or you call PayPal's /v1/notifications/verify-webhook-signature and check verification_status === "SUCCESS".)
Two silent failure modes hide here:
-
The old postback still "works" enough to fool you. During deprecation the
_notify-validateendpoint hasn't vanished, but posting a JSON webhook body back to it doesn't returnVERIFIED. Teams that kept the postback path see every webhook fail verification → return 4xx → PayPal retries then gives up → payments lost, exactly as in #1, but now it looks like a security check doing its job. - The skip-verify fallback becomes an open endpoint. The most dangerous pattern is the dev-convenience branch:
const sig = req.headers['paypal-transmission-sig'];
if (!sig) return process(req.body); // "must be a local test"
if (!verify(req)) return res.sendStatus(401);
process(req.body);
Anyone who learns your webhook URL can now POST a hand-written PAYMENT.CAPTURE.COMPLETED with any custom_id and amount they like, omit the signature header, and walk straight through to fulfillOrder. IPN's postback design made this attack awkward (PayPal had to confirm). Webhook verification done wrong makes it a curl one-liner. This won't appear in any log as an error — the forged request returns 200 just like a real one.
What to grep for in every PayPal integration you own
grep -rn "payment_status\|mc_gross\|payer_email\|_notify-validate" .
Each hit is IPN-era code that breaks on the JSON payload. Then specifically:
-
Parser: anything reading
req.body.*on a PayPal route behindurlencodedmiddleware — JSON webhooks need a JSON parser and the raw body preserved for signature verification (express.json({ verify: (req,_,buf) => req.rawBody = buf })). -
Status checks: every
=== 'Completed'/'Refunded'/'Pending'— flip to upper-caseresource.statusand branch onevent_type. Confirm you're subscribed to refund/dispute events, not justCOMPLETED. -
Amounts: every
mc_gross→resource.amount.value, and treat it as a string — parse explicitly before arithmetic or comparison. -
Buyer identity: every
payer_email→ there is no drop-in; add theGET /v2/checkout/orders/{id}lookup or capture payer data at checkout instead of expecting it on the webhook. -
Verification: kill any
if (!sig) skipbranch, and make sure you're doing RSA-SHA256 over the raw body — not posting JSON back to_notify-validate. -
Idempotency: dedup keyed on
txn_idsilently treats every webhook as new (the field is gone). Re-key on the eventid(WH-…) orresource.id.
The reason this one is worth auditing now rather than in late 2026 is that the failure isn't gated on the January 2027 cliff. The moment you flip a button or gateway from IPN to Webhooks — which merchants are doing all through 2026 — these modes are live, and the worst of them (silent 200, lost payment, open endpoint) produce no error anywhere in your stack. The cutoff is loud. The migration is not.
FlareCanary monitors API responses for schema drift, silent removals, and behavior changes across upstream providers. If you run integrations against PayPal, Stripe, Shopify, GitHub, or anywhere else that ships breaking changes through changelog posts and deprecation notices, flarecanary.com catches the drift before your customers do.
Top comments (0)