PayPal IPN vs Webhooks: Which One Should You Verify
PayPal IPN is a legacy notification format verified by posting the payload back to PayPal for an echo check; PayPal Webhooks are the modern replacement verified with a certificate-based signature via PayPal's API. New integrations should use Webhooks. Existing IPN listeners can keep running but should migrate over time.
Your PayPal integration is returning INVALID on every IPN message, or your webhook signature check keeps failing even though PayPal is clearly delivering the event. The usual cause is simple: IPN and Webhooks are two separate PayPal notification systems with completely different verification steps, and code written for one silently fails against the other. CanHook is a webhook inspector that captures the raw request PayPal sends, headers and body intact, so you can see which format you are actually receiving before you write a line of verification code.
PayPal IPN is a legacy, form-encoded notification that you verify by posting the exact message back to PayPal and checking for the word VERIFIED. PayPal Webhooks are the modern JSON-based replacement that you verify with a certificate-based signature check, either yourself or by calling PayPal's verification API. New integrations should use Webhooks. If you inherited an IPN listener, it still works and PayPal still supports it, but treat it as maintenance-only rather than a base for anything new.
What's the Difference Between PayPal IPN and Webhooks?
IPN is the older of the two and arrives as a plain form-encoded POST. Webhooks are the current system and arrive as JSON, scoped to specific event types you subscribe to in your PayPal account settings. The table below covers the differences that actually change your code.
| Aspect | IPN | Webhooks |
|---|---|---|
| Payload format | URL-encoded form fields | JSON |
| Verification method | Postback the raw message to PayPal, check for VERIFIED | Signature headers checked via PayPal's verify-webhook-signature API |
| Retry behavior | Up to 15 resends over 4 days if you never return 200 | Up to 25 attempts over 3 days until you return any 2xx |
| PayPal's current guidance | Legacy, still accepted and supported | Recommended for new integrations |
How Do You Verify a PayPal IPN Message?
PayPal's own IPN documentation describes a four-step protocol, and skipping or reordering a step is the most common source of a false INVALID on a genuine transaction.
- Return an empty HTTP 200 to PayPal immediately, before you process anything.
- Capture the raw body exactly as received: same fields, same order, same encoding.
- POST it back unaltered to
https://ipnpb.paypal.com/cgi-bin/webscrwithcmd=_notify-validateprepended. - Read the single-word response:
VERIFIEDmeans process the event,INVALIDmeans log it and stop. - Only act on transaction fields such as
payment_statusandtxn_idafter step 4 confirms VERIFIED.
<?php
// Step 1: acknowledge immediately, before doing any work
http_response_code(200);
// Step 2: capture the raw, unmodified POST body
$raw = file_get_contents('php://input');
// Step 3: post it back to PayPal unchanged, cmd=_notify-validate prepended
$ch = curl_init('https://ipnpb.paypal.com/cgi-bin/webscr');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => 'cmd=_notify-validate&' . $raw,
CURLOPT_RETURNTRANSFER => true,
]);
$result = trim(curl_exec($ch));
// Step 4: only trust the fields when PayPal echoes VERIFIED
if ($result === 'VERIFIED') {
parse_str($raw, $fields);
// process $fields['txn_id'], $fields['payment_status'], ...
}The bug that produces INVALID on a real payment is almost always step 2 or 3: something in the request stack has re-encoded, reordered, or trimmed the body before it reaches your postback, so what you send back no longer matches what PayPal sent.
How Do You Verify a PayPal Webhook Signature?
Every webhook delivery carries five headers PayPal uses for signing: paypal-transmission-id, paypal-transmission-time, paypal-transmission-sig, paypal-cert-url, and paypal-auth-algo. You pass all five, along with your webhook ID and the untouched event body, to PayPal's verify-webhook-signature endpoint rather than checking the signature yourself.
<?php
$headers = getallheaders();
$rawBody = file_get_contents('php://input');
$payload = json_encode([
'transmission_id' => $headers['paypal-transmission-id'],
'transmission_time' => $headers['paypal-transmission-time'],
'cert_url' => $headers['paypal-cert-url'],
'auth_algo' => $headers['paypal-auth-algo'],
'transmission_sig' => $headers['paypal-transmission-sig'],
'webhook_id' => $webhookId,
'webhook_event' => json_decode($rawBody),
]);
$ch = curl_init('https://api-m.paypal.com/v1/notifications/verify-webhook-signature');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Authorization: Bearer ' . $accessToken],
CURLOPT_POSTFIELDS => $payload,
CURLOPT_RETURNTRANSFER => true,
]);
$response = json_decode(curl_exec($ch), true);
if (($response['verification_status'] ?? null) === 'SUCCESS') {
// trust $rawBody
}PayPal's webhooks documentation is explicit that the event body must be posted back exactly as received, with no reformatting. Re-serializing the JSON, even with identical values, changes the bytes the signature was computed over and turns a real event into a FAILURE.
Which Should You Use for a New Integration?
Use Webhooks. PayPal's own IPN documentation calls it a legacy integration method that is still accepted for existing listeners but is no longer the recommended starting point. Webhooks give you scoped event types, a server-side verification call instead of a fragile postback, and JSON instead of an ad hoc form encoding.
Getting either verification path right depends on seeing the exact bytes PayPal sent: field order for IPN, header casing for webhooks. A print statement or a generic request log usually normalizes exactly the detail you need, because it re-serializes the payload the same way your buggy code does. CanHook's catch URL captures the request as it arrived, so you can confirm which format you are getting and what is actually in it before you write the verification code. See what a captured request looks like on the features page.
Why Does Verification Keep Failing in Production?
Almost every production failure traces back to something between PayPal and your verification code touching the payload. A reverse proxy that pretty-prints JSON, a framework that parses the body into an object and hands you a re-encoded version, or a load balancer that strips a header casing all break verification silently, because your code never sees the bytes PayPal actually signed. The fix is to read the raw body and raw headers before any middleware normalizes them, and to verify the signature or postback against that raw form, not the parsed one.
If you cannot tell whether the problem is your code or something upstream of it, capture the request at the transport layer first. Point the PayPal-facing URL at a CanHook catch URL temporarily, trigger one event, and compare the headers and body you actually received against what your handler receives after your own stack has touched it. A mismatch there tells you the bug is not in your verification logic at all.
How Do You Test PayPal Notifications Locally?
Point your PayPal sandbox webhook or IPN listener URL at a CanHook catch URL first. The request lands in your dashboard immediately, with the sandbox's real headers and body, whether or not your local dev server is reachable from the internet. From there, use replay to resend the exact captured request as many times as you need while you build the handler.
Replay and relay both go through the same outbound safety check as everything else CanHook sends, which resolves the destination and rejects private and loopback addresses, so neither one will deliver directly to localhost. Run a tunnel (ngrok, Cloudflare Tunnel) that exposes your dev server on a public HTTPS URL, replay the captured PayPal event against that tunnel URL, and you get the exact sandbox payload hitting your real code without triggering a new sandbox transaction each time you want to retest.
curl -X POST https://canhook.com/h/abc123def456 \
-H "Content-Type: application/json" \
-d '{"event_type":"PAYMENT.CAPTURE.COMPLETED"}'The most likely reason to skip a dedicated catch URL is that you already have a working listener with logging in place. That is fine until the failure is in the encoding or header casing itself, which a debug log will not preserve, because it normalizes the payload on the way into the log the same way your handler does on the way into your verification code. A capture point ahead of your own code is the only way to see the request PayPal actually sent.
Create a free CanHook endpoint, point your PayPal sandbox webhook or IPN URL at its catch URL, and trigger one sandbox event. The raw request, headers included, will be in your dashboard in about the time it takes to refresh the page: sign up free and try it against your own sandbox account.
Frequently asked questions
Is PayPal IPN deprecated?
PayPal's own IPN documentation calls it a legacy integration method: PayPal still accepts and supports existing IPN listeners, but recommends Webhooks for anything new. Treat IPN as maintenance-only rather than a starting point for a fresh integration.
What headers does PayPal send with a webhook event?
PayPal signs each webhook delivery with five headers: paypal-transmission-id, paypal-transmission-time, paypal-transmission-sig, paypal-cert-url, and paypal-auth-algo. You pass all five, along with the webhook ID and the untouched event body, to PayPal's verify-webhook-signature endpoint.
How many times does PayPal retry a failed notification?
IPN messages are resent up to 15 times over four days if your listener never returns a 200 response. Webhooks retry up to 25 times over three days until your endpoint returns any 2xx status code.
Can you run PayPal IPN and Webhooks on the same account at the same time?
Yes. They are independent notification channels configured separately in your PayPal account settings, so you can leave an existing IPN listener running while you build and test a Webhooks listener alongside it before cutting over.
Why does PayPal IPN verification return INVALID for a real payment?
The most common cause is a changed postback: PayPal requires the exact same fields, in the exact same order and encoding, prefixed with cmd=_notify-validate. Reformatting the body, trimming whitespace, or reordering parameters before the postback produces INVALID even for a genuine transaction.
Do you need a public server to receive PayPal notifications during development?
You need a public HTTPS URL that PayPal can reach, but it does not have to be your application server. Pointing PayPal at a hosted catch URL during development lets you inspect the raw payload, then replay it against a tunnel URL that forwards into your local dev server.