PayPal IPN with PHP

How To Implement an Instant Payment Notification listener script in PHP

Using PayPal’s Instant Payment Notification (IPN) service can be a little complex and difficult to troubleshoot to developers new to PayPal. In this post I will walk you through the entire process using the PHP-PayPal-IPN project to avoid many of the common pitfalls in the back-end PHP code.

Read the Manual… Seriously

To implement an IPN listener you should know what IPN is, how it works, and why you need it. Read the PayPal Instant Payment Notification Guide. I will also hammer in a couple of points that trip up a lot of developers in the next section.

Understanding the IPN Process

The most common problem I see developers having with IPN integration is with the concept that the IPN is done between the PayPal server and your web server. It is a completely separate a process from the end user clicking through the paypal site to pay and returning to your site. Moreover, these processes are asynchronous–you do not know if PayPal will send the IPN before or after the user returns to your website.

In the Client-server model used by the HTTP protocol, the end-user’s web browser is usually the “client” and your web server “server”. With an Instant Payment Notification the PayPal server is the “client” instead of the end-user’s web browser. No web browser is involved. Nobody sees the output of your IPN script.

What this means is that your IPN listener will not output anything. You cannot use echo or print in your IPN script. Debugging must be done using logs and email.

As an example, let’s say you have a “Buy Now” button on a page called shop.php. Your button is configured to send the user to success.php after they submit their payment and ipn.php is configured as the IPN listener. Looking at the diagram below, the blue path shows the process the end user follows in this scenario, and the red path shows the IPN between PayPal and your web server.

PayPal IPN Process Diagram

Create a PayPal Sandbox Account

If you have not done so already, you should have a PayPal Sandbox account setup with two test accounts, one as a test buyer and one as a test seller. The sandbox allows you to test transactions and IPN processing without having to perform live transactions. Follow the instructions in the Sandbox User Guide to setup your accounts.

Code the IPN Listener

  1. Create a file called ipn.php on your web server. The URL to this script is your IPN listener.
  2. Download PHP-PayPal-IPN and copy ipnlistener.php to the same directory as ipn.php or somewhere in your PHP include path.

Open up ipn.php in your favorite PHP editor and enter the following skeleton code just to get started.

<?php
// tell PHP to log errors to ipn_errors.log in this directory
ini_set('log_errors', true);
ini_set('error_log', dirname(__FILE__).'/ipn_errors.log');

// intantiate the IPN listener
include('ipnlistener.php');
$listener = new IpnListener();

// tell the IPN listener to use the PayPal test sandbox
$listener->use_sandbox = true;

// try to process the IPN POST
try {
    $listener->requirePostMethod();
    $verified = $listener->processIpn();
} catch (Exception $e) {
    error_log($e->getMessage());
    exit(0);
}

// TODO: Handle IPN Response here

?>

IPN Verification

The processIpn() method in the try/catch block handles all the tricky bits of the IPN listener for you. This method will:

  1. Encode all of the POST data for posting back to PayPal.
  2. Post the data back to correct PayPal URL.
  3. Ensure PayPal responds with an HTTP 200 status code.
  4. Parse the response from PayPal.

The processIpn() method will return true if the IPN response was “VERIFIED” and return false if the response was “INVALID”. Exceptions are thrown for fatal errors such as problems connecting or unexpected responses from PayPal.

Error Logging

Since the IPN will be happening between PayPal and your web server (and not your browser), the only way you can troubleshoot problems is by logging errors to a file or email. The code above sets up PHP to log errors to a file called ipn_errors.log in the same folder as the ipn.php script.

Security Warning: In a production environment you should make sure that the error log is not accessible from the web. Ideally you would set ‘error_log’ to a directory outside of your web root. At the very least you should configure your web server to block access to that log file (or all .log files). In Apache, this can be done using the .htaccess file.

Before moving forward you should test to make sure that this error logging is working so you can troubleshoot connection and server configuration problems. Since PayPal uses the ‘POST’ method for IPN, the requirePostMethod() method will throw an exception if you try to access the ipn listener using any other method. Thus, you can force an exception by accessing ipn.php directly in your web browser. Point your web browser to the your ipn.php URL.

You should get a blank screen and/or an HTTP 405 error. The ipn_errors.log should have been created and contain a message like the following:

[09-Sep-2011 11:57:09] Invalid HTTP request method.

If ipn_errors.log was not created or it does not contain the error message after directly accessing ipn.php then your web server most likely does not have permission to create and write to that file. It’s good to know that now rather than wasting time later not knowing why the IPN is not working. If you do not know how to securely give PHP write permission to that log file then you will need to contact your hosting company or system administrator for help or use a different type of logging strategy.

Note: You can change the exception handler to send the error message to yourself in an email, however, I would advise against doing so on a live system. If (or dare I say… when) PayPal servers go down or have a bug you don’t want to get an email for every IPN attempt as it could bog down your email server.

Handle IPN Response

The return value from the call to the processIpn() method is stored in the $verified variable. A simple if/else block can be used to handle the IPN response. Put this code immediately after the try/catch block where the TODO comment is.

// ... 

if ($verified) {
    // TODO: Implement additional fraud checks and MySQL storage
    mail('YOUR EMAIL ADDRESS', 'Valid IPN', $listener->getTextReport());
} else {
    // manually investigate the invalid IPN
    mail('YOUR EMAIL ADDRESS', 'Invalid IPN', $listener->getTextReport());
}

// ...

For now you are sending yourself an email regardless of whether the IPN was VERIFIED or INVALID. An email notification is typically suitable for the INVALID condition. However, you will need to do a bit more coding for the VERIFIED condition. But first, you check to make sure the code is working properly thus far.

The getTextReport() method is a convenient way to generate a report with all the IPN data. It includes the URI that was used to post back to paypal, the networking library used (cURL or fsockopen), the complete response content, and all of the POST fields nicely formatted in plain text.

Test the IPN Listener

The PayPal Sandbox includes a handy dandy little tool to test your IPN listener without having to go through all the steps required to place test orders. Log in to your PayPal Sandbox account and click “Test Tools” in the left hand navigation.

PayPal Sandbox Test Tools

Click the “Instant Payment Notification (IPN) Simulator” link and enter the URL to your ipn.php script. Choose any Transaction Type (normally you would choose the transaction type that fits your project needs, but, it does not matter for testing your script thus far).

 

PayPal Sandbox IPN Simulator

PayPal will fill in a bunch of default values for the IPN post. You can leave them alone for now and scroll down to the “Send IPN” button at the bottom of the form.

After clicking the “Send IPN” button one of 2 things should have happened. You should either have a new entry in ipn_errors.log with a fatal error or you should have an email with the IPN report. If neither is the case, double-check the following:

  1. Make sure you entered the correct URL in the IPN Simulator tool.
  2. Make sure you correctly entered your email address in the source code where it says ‘YOUR EMAIL ADDRESS’.
  3. Go back to Error Logging and make sure your error log is working properly.

Secure the IPN Listener

Now that the IPN listener is working you need to add a few more checks against fraudulent transactions. The exact details of an implementation may vary depending on what your specific business does and what PayPal services are being utilized, but, the basic fraud prevention techniques remain the same.

  1. Make sure the payment status is “Completed” as you will get also get IPNs for other status’ like “Pending”.
  2. Make sure seller email matches your primary account email.
  3. Make sure the amount(s) paid match what you are actually charging for your good or service.
  4. Make sure the currency code matches.
  5. Ensure the transaction is not a duplicate of an order you already processed.

For the sake of this example, let us assume that you are selling a digital download for 9.99 USD, say an e-book, using a PayPal “Buy Now” button. When the IPN is verified, you will send the customer and email with a link to download their purchase.

The first 4 fraud checks in the list above are easy enough to check. The code that handles your IPN response should be changed to the following (fill in your own values for YOUR EMAIL ADDRESS and YOUR PRIMARY PAYPAL EMAIL):

// ...

if ($verified) {

    $errmsg = '';   // stores errors from fraud checks

    // 1. Make sure the payment status is "Completed" 
    if ($_POST['payment_status'] != 'Completed') { 
        // simply ignore any IPN that is not completed
        exit(0); 
    }

    // 2. Make sure seller email matches your primary account email.
    if ($_POST['receiver_email'] != 'YOUR PRIMARY PAYPAL EMAIL') {
        $errmsg .= "'receiver_email' does not match: ";
        $errmsg .= $_POST['receiver_email']."\n";
    }

    // 3. Make sure the amount(s) paid match
    if ($_POST['mc_gross'] != '9.99') {
        $errmsg .= "'mc_gross' does not match: ";
        $errmsg .= $_POST['mc_gross']."\n";
    }

    // 4. Make sure the currency code matches
    if ($_POST['mc_currency'] != 'USD') {
        $errmsg .= "'mc_currency' does not match: ";
        $errmsg .= $_POST['mc_currency']."\n";
    }

    // TODO: Check for duplicate txn_id

    if (!empty($errmsg)) {

        // manually investigate errors from the fraud checking
        $body = "IPN failed fraud checks: \n$errmsg\n\n";
        $body .= $listener->getTextReport();
        mail('YOUR EMAIL ADDRESS', 'IPN Fraud Warning', $body);

    } else {

        // TODO: process order here
    }

} else {
    // manually investigate the invalid IPN
    mail('YOUR EMAIL ADDRESS', 'Invalid IPN', $listener->getTextReport());
}

// ...

Note: The exact fields you would need check are dependent upon the PayPal service you are using. See IPN and PDT Variables in the PayPal documentation a list of fields that may be present in an IPN.

The $errmsg variable is populated with an error message for every field that does not have the value we expect. If errors are found, then the IPN report is sent in an email along with those error messages.

At this point you can test each of your fraud checks using the method described in the Test the IPN Listener section, only this time you need to make sure to use “Web Accept” as the transaction type and you will need to adjust the fields to simulate the scenario you are testing for.

Now you need to check the txn_id to make sure you have already processed this transaction. To do this, create a simple little MySQL table in which you will store every completed order. You can then check the txn_id against this table in each IPN.

CREATE TABLE orders (
  order_id INT NOT NULL auto_increment PRIMARY KEY,
  txn_id VARCHAR(19) NOT NULL,
  payer_email VARCHAR(75) NOT NULL,
  mc_gross FLOAT(9,2) NOT NULL,
  UNIQUE INDEX (txn_id)
);

Note: You can expand this table to include whichever fields you want or add a txn_id column to an existing order table if you already have one. This is just a simple example.

With the MySQL table in place you can finish up your IPN listener script. First, replace the comment // TODO: Check for duplicate txn_id with the following code (fill in your own database connection parameters):

    // ...

    // 5. Ensure the transaction is not a duplicate.
    mysql_connect('localhost', 'DB_USER', 'DB_PW') or exit(0);
    mysql_select_db('DB_NAME') or exit(0);

    $txn_id = mysql_real_escape_string($_POST['txn_id']);
    $sql = "SELECT COUNT(*) FROM orders WHERE txn_id = '$txn_id'";
    $r = mysql_query($sql);

    if (!$r) {
        error_log(mysql_error());
        exit(0);
    }

    $exists = mysql_result($r, 0);
    mysql_free_result($r);

    if ($exists) {
        $errmsg .= "'txn_id' has already been processed: ".$_POST['txn_id']."\n";
    }

    // ...

A simple SELECT COUNT(*) query checks to see if the txn_id exists already. Any MySQL errors are logged to that same error log.

Next, replace the comment // TODO: process order here with the following code:

    // ...

    // add this order to a table of completed orders
    $payer_email = mysql_real_escape_string($_POST['payer_email']);
    $mc_gross = mysql_real_escape_string($_POST['mc_gross']);
    $sql = "INSERT INTO orders VALUES 
            (NULL, '$txn_id', '$payer_email', $mc_gross)";

    if (!mysql_query($sql)) {
        error_log(mysql_error());
        exit(0);
    }

    // send user an email with a link to their digital download
    $to = filter_var($_POST['payer_email'], FILTER_SANITIZE_EMAIL);
    $subject = "Your digital download is ready";
    mail($to, "Thank you for your order", "Download URL: ...");

    // ...

When the IPN has been verified and all fraud checks pass, you store the order in your table and send the customer an email with a link to their digital download.

Final Sandbox Testing

You are now ready to start testing actual transactions using your two Sanbox accounts. You can create a “Buy Now” button for your Sandbox seller account like the one below. Put that into an HTML page somewhere on your site and go through the purchase process using your Sandbox buyer account.

This real-world testing will allow you to work out any other bugs and fine-tune your fields to meet your needs.

<form name="_xclick" action="https://www.sandbox.paypal.com/cgi-bin/webscr" 
    method="post">
    <input type="hidden" name="cmd" value="_xclick">
    <input type="hidden" name="business" value="YOUR SANDBOX SELLER EMAIL">
    <input type="hidden" name="currency_code" value="USD">
    <input type="hidden" name="item_name" value="Digital Download">
    <input type="hidden" name="amount" value="9.99">
    <input type="hidden" name="return" value="THIS URL">
    <input type="hidden" name="notify_url" value="THE URL TO YOUR ipn.php SCRIPT">
    <input type="image" src="http://www.paypal.com/en_US/i/btn/btn_buynow_LG.gif" 
        border="0" name="submit" alt="Make payments with PayPal - it's fast, free and secure!">
</form>

Leave a comment