Skip to main content
Being Idea Innovations
PayPal Webhooks in PHP: The Modern Replacement for IPN
Back to Blog

PayPal Webhooks in PHP: The Modern Replacement for IPN

1 May 20186 min readPHP
Share:

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.

Why the old IPN code no longer works

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:

  • No Host header. PayPal required HTTP/1.1 with a Host header from 2016. An HTTP/1.0 request without one is rejected outright.
  • TLS negotiation. 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.

IPN versus Webhooks

IPN requires posting the payload back to PayPal for verification; Webhooks verify a cryptographic signature instead IPN — deprecated PayPal Your server 1. POST form data 2. post the whole payload back to ask “is this yours?” 3. plain text “VERIFIED” extra round trip · no signature · trust rests on a string comparison Webhooks — current PayPal Your server POST JSON + signature headers verify signature against PayPal’s public certificate One request. Authenticity is proven cryptographically, so a forged notification cannot pass.
Webhooks remove the verification round trip and replace string comparison with a signature you can actually trust.

Setting up

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 captured
  • PAYMENT.CAPTURE.COMPLETED — money actually captured. This is the one that means "paid".
  • PAYMENT.CAPTURE.DENIED and PAYMENT.CAPTURE.REFUNDED

Save the Webhook ID PayPal generates — signature verification requires it.

Receiving and verifying

<?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.

Idempotency: the mistake that ships duplicate orders

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.

Verify the amount

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);
}

Never confirm payment from the browser redirect

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.

Testing

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.

Checklist

  • Use Webhooks, not IPN.
  • Read the raw body before any middleware parses it.
  • Verify the signature on every request; reject with 401 if it fails.
  • Store event IDs with a unique constraint for idempotency.
  • Check the captured amount and currency against your own record.
  • Return 200 quickly — queue slow work rather than doing it inline.
  • Treat PAYMENT.CAPTURE.COMPLETED, not order approval, as "paid".
  • Never trust the browser redirect as confirmation.
Share:

Written by

Amit Verma

Founder / 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 Amit

Want to talk tech?

We ship software that scales. Let's work together.

No long-term contracts
Senior engineers only
US · AU · NZ timezone coverage
14-day trial on retainers