PHP Tutorial: PayPal Instant Payment Notification (IPN)

Published:

In a previous post I tried to give an introduction on how to get started with PayPal Payment Data Transfers (PDT). PDT is very handy in several cases, but you can't always rely on it since it requires the user to return to your page after doing the payment. That will often, but it's not guaranteed to happen. If you for example want to mark an order in your system as paid or something like that, you most likely want to use PayPal Instant Payment Notifications (IPN) in addition to PDT.

Instant Payment Notification (IPN) is a message service that notifies you of events related to PayPal transactions. You can use it to automate back-office and administrative functions, such as fulfilling orders, tracking customers, and providing status and other information related to a transaction.

— PayPal

Once again the documentation, tutorials and code samples I found on this was a bit all over the place. Sort of messy and outdated. So, once again I decided to do my own thing and just follow the steps required and implement them myself. And since the tutorial on PDT turned out to be a bit of a success, I decided to share this too. Hopefully it can make the lives of fellow developers easier. 🙂

How it works

The concept is pretty simple. You first give PayPal an address to a listener, for example http://business.example.com/ipn/listener.php. Then, whenever something happens, PayPal will post information about the event to that address. So for example if someone completes a payment, your listener will be notified shortly after with the transaction details and all you need to know.

This tutorial will focus on the listener part, as the setting up part on PayPal is very simple (Leave a comment if you disagree). Just go to the PayPal account settings, find the IPN settings and give the URL to your IPN listener. For testing we don't even need to do that. All we need to do is to set up a PayPal Developer Account. We can then use their very handy Instant Payment Notification (IPN) simulator. So let's get started!

Step 0: Sign up for a Developer Account

To access the IPN simulator, you need to login at developer.paypal.com. Seems to be connected with the regular PayPal accounts now, so just try login with your actual PayPal credentials. If you don't have a PayPal account, sign up for one 🙂

Using the simulator is super simple, just enter the absolute URL to your listener and choose a transaction type. You can then fill out all the data that should be in this fake transaction and then just hit "Send IPN" at the bottom. Luckily PayPal actually fills out all the fields with random test data, so unless you are trying to test something specific, you can just ignore the data and hit the button right away 🙂

Now, to implement the actual listener...

Step 1: Catch the IPN

First thing we need to do in our listener is of course to catch the data we get from PayPal. The data is sent as a POST request, so PHP has actually done that for us. We just need to grab it 🙂

$ipn_post_data = $_POST;

Step 2: Verification

Since what we got could just be plain bogus from some stranger, we need to verify it with PayPal. This is done by taking all the POST data in its unaltered state, add one field to the beginning, and then send it back. In return we should then get one word, VERIFIED or INVALID. To do that, we can use our old buddy cURL.

// Choose url
if(array_key_exists('test_ipn', $ipn_post_data)
&& 1 === (int) $ipn_post_data['test_ipn'])
	$url = 'https://www.sandbox.paypal.com/cgi-bin/webscr';
else
	$url = 'https://www.paypal.com/cgi-bin/webscr';

// Set up request to PayPal
$request = curl_init();
curl_setopt_array($request, array
(
    CURLOPT_URL => $url,
    CURLOPT_POST => TRUE,
    CURLOPT_POSTFIELDS => http_build_query(array('cmd' => '\_notify-validate') + $ipn_post_data),
    CURLOPT_RETURNTRANSFER => TRUE,
    CURLOPT_HEADER => FALSE,
));

// Execute request and get response and status code
$response = curl_exec($request);
$status   = curl_getinfo($request, CURLINFO_HTTP_CODE);

// Close connection
curl_close($request);

if($status == 200 && $response == 'VERIFIED')
{
    // All good! Proceed...
}
else
{
    // Not good. Ignore, or log for investigation...
}

Note that in the very beginning actually check for a field called test_ipn. If that exists with a value of 1, it means it's a request from the sandbox. In other words, we can choose the correct PayPal interface by just looking for that.

📝 If the call doesn't work at all, curl might have problems with the SSL verification. This happened to me on one of my hosts, so documented it here.

Step 3: Fix character set

Now that we know the data is valid, we can start to deal with it. What you get could be in a different character set than what you want though, so we should fix that first. The data should contain a key called charset which specifies what character set the data is using. We just need to check for that and if needed convert from that to what we want, for example UTF-8.

if(array_key_exists('charset', $ipn_data) && ($charset = $ipn_data['charset']))
{
	// Ignore if same as our default
	if($charset == 'utf-8')
		return;

    // Otherwise convert all the values
    foreach($ipn_data as $key => &$value)
    	$value = mb_convert_encoding($value, 'utf-8', $charset);

    // And store the charset values for future reference
    $ipn_data['charset'] = 'utf-8';
    $ipn_data['charset_original'] = $charset;

}

Step 4: Use the data!

Yup, believe it or not, that was everything that was needed to catch an IPN message from PayPal. Where you go from here completely depends on what exactly you need to do.

What you at least should do is the following:

  1. Confirm that the payment status is Completed.
    PayPal sends IPN messages for pending and denied payments as well, so don't ship stuff or anything until the payment has cleared.
  2. Use the transaction ID to verify that the transaction has not already been processed.
    This prevents you from processing the same transaction twice. You can for example store the transaction id in a database and check against those before you do anything with incoming IPNs. If you're smart you could also store the time the IPN came in and the raw IPN data. This way you have a log of all incoming messages you can use if you need to reprocess something or for debugging if something weird is going on.
  3. Make sure the receiver's email address is the one you expected.
  4. Make sure the price, item description, et cetera, match what it should be.

And then finally you should of course make sure your customer gets what they paid for 🙂

You can read more about PayPal Instant Payment Notifications in their IPN Guide.

Complete code

Here's all the base code gathered in a base class, followed by an example listener implementation which simply dumps each IPN into a log-file.

<?php // file: ipn_handler.class.php

/**
 * IPN Handler.
 *
 * @author     Torleif Berger
 * @link       http://www.geekality.net/?p=1420
 *
 * @link https://cms.paypal.com/cms_content/US/en_US/files/developer/IPNGuide.pdf
 * @link http://curl.haxx.se/docs/caextract.html
 */
abstract class IPN_Handler
{
    const paypal_url = 'https://www.paypal.com/cgi-bin/webscr';
    const paypal_sandbox_url = 'https://www.sandbox.paypal.com/cgi-bin/webscr';

    const charset = 'utf-8';


    /**
     * Validates and santizes IPN data from PayPal.
     *
     * @return    mixed    returns the processed data or FALSE if validation failed.
     */
    public function process(array $post_data)
    {
        // Validate
        $valid = self::validate($post_data);
        if($valid !== TRUE)
            return FALSE;

        // Sanitize
        return self::sanitize($post_data);
    }


    /**
     * Validates IPN data.
     *
     * [!!] Verification will fail if the data has been alterend in *any* way.
     *
     * @param   array    raw ipn post data from paypal
     * @return  mixed    returns the reply on error; otherwise `TRUE`
     */
    protected static function validate(array $ipn_post_data)
    {
        // Choose url
        if(array_key_exists('test_ipn', $ipn_post_data) && 1 === (int) $ipn_post_data['test_ipn'])
            $url = self::paypal_sandbox_url;
        else
            $url = self::paypal_url;

        // Set up request to PayPal
        $request = curl_init();
        curl_setopt_array($request, array
        (
            CURLOPT_URL => $url,
            CURLOPT_POST => TRUE,
            CURLOPT_POSTFIELDS => http_build_query(array('cmd' => '_notify-validate') + $ipn_post_data),
            CURLOPT_RETURNTRANSFER => TRUE,
            CURLOPT_HEADER => FALSE,
            CURLOPT_SSL_VERIFYPEER => TRUE,
            CURLOPT_CAINFO => 'cacert.pem',
        ));

        // Execute request and get response and status code
        $response = curl_exec($request);
        $status   = curl_getinfo($request, CURLINFO_HTTP_CODE);

        // Close connection
        curl_close($request);

        if($status == 200 && $response == 'VERIFIED')
            return TRUE;

        return $response;
    }


    protected static function sanitize(array $ipn_data)
    {
        // Just return empty array if empty
        if( ! $ipn_data)
            return array();

        // Fix encoding
        self::fix_encoding($ipn_data);

        // Sort keys (easier to debug)
        ksort($ipn_data);

        return $ipn_data;
    }

    protected static function fix_encoding( & $ipn_data)
    {
        // If charset is specified
        if(array_key_exists('charset', $ipn_data) && ($charset = $ipn_data['charset']))
        {
            // Ignore if same as our default
            if($charset == self::charset)
                return;

            // Otherwise convert all the values
            foreach($ipn_data as $key => &$value)
                $value = mb_convert_encoding($value, self::charset, $charset);

            // And store the charset values for future reference
            $ipn_data['charset'] = self::charset;
            $ipn_data['charset_original'] = $charset;
        }
    }
}
<?php // file: listener.php

require_once 'ipn_handler.class.php';

/**
 * Logs IPN messages to a file.
 */
class Logging_Ipn_Handler extends IPN_Handler
{
    public function process(array $post_data)
    {
        $data = parent::process($post_data);

        if($data === FALSE)
        {
            header('HTTP/1.0 400 Bad Request', true, 400);
            exit;
        }

        $output = implode("\t", array(time(), json_encode($data)));
        file_put_contents('ipn.log', $output.PHP_EOL, FILE_APPEND);
    }
}

date_default_timezone_set('Europe/Oslo');

$handler = new Logging_Ipn_Handler();
$handler->process($_POST);

That's all! Hopefully someone find this useful. Learned a lot writing it at least! 🙂