DEV Community

FlareCanary
FlareCanary

Posted on

PayPal IPN is deprecated — the IPN Webhooks migration drops payments in four silent ways

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);
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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-validate endpoint hasn't vanished, but posting a JSON webhook body back to it doesn't return VERIFIED. 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);
Enter fullscreen mode Exit fullscreen mode

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" .
Enter fullscreen mode Exit fullscreen mode

Each hit is IPN-era code that breaks on the JSON payload. Then specifically:

  1. Parser: anything reading req.body.* on a PayPal route behind urlencoded middleware — JSON webhooks need a JSON parser and the raw body preserved for signature verification (express.json({ verify: (req,_,buf) => req.rawBody = buf })).
  2. Status checks: every === 'Completed' / 'Refunded' / 'Pending' — flip to upper-case resource.status and branch on event_type. Confirm you're subscribed to refund/dispute events, not just COMPLETED.
  3. Amounts: every mc_grossresource.amount.value, and treat it as a string — parse explicitly before arithmetic or comparison.
  4. Buyer identity: every payer_email → there is no drop-in; add the GET /v2/checkout/orders/{id} lookup or capture payer data at checkout instead of expecting it on the webhook.
  5. Verification: kill any if (!sig) skip branch, and make sure you're doing RSA-SHA256 over the raw body — not posting JSON back to _notify-validate.
  6. Idempotency: dedup keyed on txn_id silently treats every webhook as new (the field is gone). Re-key on the event id (WH-…) or resource.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)