Previous Article
How to Select and Preview an Image in JavaScript (FileReader vs Object URLs)
25 April 2018
If you are setting up PayPal payment notifications today, do not use Instant Payment Notification. IPN is deprecated. PayPal's documentation directs all new integrations to Webhooks, and IPN receives no new features — it exists only to avoid breaking integrations built a decade ago.
This guide covers the current approach: receiving PayPal Webhooks in PHP, verifying they genuinely came from PayPal, and handling them safely.
The widely copied IPN handler — including the one this article previously showed — opens a raw socket and writes an HTTP request by hand:
// This has not worked for years
$header = "POST /cgi-bin/webscr HTTP/1.0\r\n";
$header .= "Content-Type: application/x-www-form-urlencoded\r\n";
$fp = fsockopen('ssl://www.paypal.com', 443, $errno, $errstr, 30);
Three reasons it fails on a modern system:
Host header. PayPal required HTTP/1.1 with a Host header from 2016. An HTTP/1.0 request without one is rejected outright.fsockopen('ssl://…') gives you no control over the TLS version, and PayPal requires TLS 1.2 or higher.mysql_* functions were removed in PHP 7. The database half of that handler cannot execute at all.Even repaired, IPN has a design weakness: verification requires posting the entire payload back to PayPal and trusting a plain-text VERIFIED response. Webhooks use cryptographic signatures instead.
Create an app at developer.paypal.com/dashboard. You get a Client ID and Secret for Sandbox and separate ones for Live.
Under your app, add a webhook: give it your HTTPS endpoint URL and subscribe to the events you need. For a typical checkout that is:
CHECKOUT.ORDER.APPROVED — buyer approved, not yet capturedPAYMENT.CAPTURE.COMPLETED — money actually captured. This is the one that means "paid".PAYMENT.CAPTURE.DENIED and PAYMENT.CAPTURE.REFUNDEDSave the Webhook ID PayPal generates — signature verification requires it.
<?php
declare(strict_types=1);
// Read the raw body BEFORE any framework parses it — signature verification
// needs the exact bytes PayPal sent, not a re-encoded version.
$rawBody = file_get_contents('php://input');
$headers = array_change_key_case(getallheaders(), CASE_UPPER);
$required = ['PAYPAL-TRANSMISSION-ID', 'PAYPAL-TRANSMISSION-TIME',
'PAYPAL-TRANSMISSION-SIG', 'PAYPAL-CERT-URL', 'PAYPAL-AUTH-ALGO'];
foreach ($required as $header) {
if (empty($headers[$header])) {
http_response_code(400);
exit;
}
}
$verified = verifyWebhookSignature($rawBody, $headers, PAYPAL_WEBHOOK_ID);
if (!$verified) {
error_log('PayPal webhook signature verification failed');
http_response_code(401);
exit;
}
$event = json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR);
handleEvent($event);
// Acknowledge fast. PayPal retries on any non-2xx or a slow response.
http_response_code(200);
Verification is an API call to PayPal, using an OAuth token obtained with your client credentials:
function verifyWebhookSignature(string $rawBody, array $headers, string $webhookId): bool
{
$payload = [
'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,
// Must be the decoded object, not the raw string.
'webhook_event' => json_decode($rawBody, true),
];
$ch = curl_init(PAYPAL_API_BASE . '/v1/notifications/verify-webhook-signature');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: Bearer ' . getAccessToken(),
],
CURLOPT_POSTFIELDS => json_encode($payload, JSON_THROW_ON_ERROR),
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status !== 200 || $response === false) {
return false;
}
return (json_decode($response, true)['verification_status'] ?? '') === 'SUCCESS';
}
The API base differs by environment — https://api-m.sandbox.paypal.com for testing, https://api-m.paypal.com for live. Keep it in configuration, never hard-coded, or you will eventually verify live payments against sandbox.
PayPal will send the same event more than once. Retries happen on timeout, on a non-2xx response, and occasionally for no visible reason. If your handler fulfils an order every time it receives PAYMENT.CAPTURE.COMPLETED, a retry ships the goods twice.
Record every event ID and reject repeats at the database level:
CREATE TABLE paypal_webhook_events ( event_id VARCHAR(64) NOT NULL PRIMARY KEY, event_type VARCHAR(64) NOT NULL, resource_id VARCHAR(64) NULL, received_at DATETIME NOT NULL, payload JSON NOT NULL );
function handleEvent(array $event): void
{
global $pdo;
try {
$pdo->prepare(
'INSERT INTO paypal_webhook_events (event_id, event_type, resource_id, received_at, payload)
VALUES (?, ?, ?, NOW(), ?)'
)->execute([
$event['id'],
$event['event_type'],
$event['resource']['id'] ?? null,
json_encode($event),
]);
} catch (PDOException $e) {
// Duplicate primary key — we have already processed this event.
if ($e->getCode() === '23000') {
return;
}
throw $e;
}
match ($event['event_type']) {
'PAYMENT.CAPTURE.COMPLETED' => fulfilOrder($event['resource']),
'PAYMENT.CAPTURE.DENIED' => flagFailedPayment($event['resource']),
'PAYMENT.CAPTURE.REFUNDED' => processRefund($event['resource']),
default => null,
};
}
Relying on the unique constraint rather than a "does it exist?" check is deliberate — two concurrent deliveries of the same event would both pass a SELECT before either inserted.
Confirm that what PayPal captured matches what you charged. Never take the amount from the request that created the order:
function fulfilOrder(array $resource): void
{
global $pdo;
$orderId = $resource['custom_id'] ?? null;
$paid = (float) ($resource['amount']['value'] ?? 0);
$currency = $resource['amount']['currency_code'] ?? '';
$order = fetchOrder($pdo, $orderId);
if ($order === null) {
error_log("Webhook for unknown order: {$orderId}");
return;
}
// Compare in minor units — floating point equality on money is unreliable.
if ((int) round($paid * 100) !== (int) round((float) $order['total'] * 100)
|| $currency !== $order['currency']) {
error_log("Amount mismatch on order {$orderId}: paid {$paid} {$currency}");
flagForReview($orderId);
return;
}
markOrderPaid($pdo, $orderId);
sendConfirmationEmail($order);
}
After paying, the buyer is returned to your success URL. That redirect is not proof of payment — anyone can visit /success?order=123 directly. Use it only to show a friendly message; let the webhook (or a server-side capture call) be the sole thing that marks an order paid.
Your endpoint must be publicly reachable over HTTPS, which localhost is not. Use a tunnel:
ngrok http 8000 # then register https://<random>.ngrok-free.app/webhooks/paypal in the dashboard
The developer dashboard has a webhook simulator that sends sample events of any type — the fastest way to exercise your handler without completing real sandbox checkouts. Note that simulated events will not pass signature verification, so log the failure clearly enough to tell it apart from a genuine problem.
Create sandbox buyer and seller accounts under Testing Tools → Sandbox Accounts, and remember that sandbox and live have completely separate credentials, webhook IDs and endpoints.
PAYMENT.CAPTURE.COMPLETED, not order approval, as "paid".Advertisement
Written by
Amit VermaFounder / Senior Software Engineer
Amit leads engineering at Being Idea. With 15+ years building scalable software products across global markets, he drives architecture decisions and engineering culture across every engagement.
More articles by AmitYou might also like
We ship software that scales. Let's work together.