PayPal IPN Payment Notification Example in C#

I must say, they learning curve for PayPal IPN integration into your web site could be a lot better. By far!

I thought I would write a post about the ins and outs. At the end of the day it was pretty simple. Getting there was painful though. I am not going to explain all the nuances etc, I would just work around each as I came to it and logging them all would have been such a pain.

Let me start by saying that I am not going to explain how to use PayPal, how to set up an account, I am not going to explain how to use their site or navigate their documentation. That would be pointless because this is the web, it changes often and those links will be old or broken in no time.

One thing that drove me nuts until I realised...

The PayPal Sandbox site does not necessarily stick to its own domain.

Why is that a problem? Because while you are setting up your test button or something you suddenly find yourself on the real site and not logged in properly.

How do I mitigate this confusion? I use different web browsers for the sandbox and for the real site. When you have a sandbox button it is totally separate, the logins everything. So I add to that separation by using different browsers.

I will cut to the chase; the example code below is a snippet of how I integrate the PayPal IPN, go an read the documentation yourself, link not included. When someone makes a purchase (e.g. you throw them to paypal.com via a button) you can set the thing up to make a call back to your web site to flag the transaction as complete. This is neat if you want to do something like send an email with a license key of something. In addition, if you make a refund via the PayPal administration site, it sends another message and then you can automatically do something else, close off an account or something.

/// <summary>
/// When PayPal gets a purchase with the IPN set up we get a POST with a bunch of variables.
/// If all is good we verify by calling back (GET) with the same parameters, we get a "VERIFIED" response.
/// We then in turn respond with a 200, all is OK.
/// </summary>
/// <returns>200 if all is good.</returns>
/// <remarks>
/// https://developer.paypal.com/webapps/developer/docs/classic/ipn/integration-guide/IPNIntro/
/// </remarks>
[AllowAnonymous]
[ActionName("call_it_what_you_want_buddy")]
public ActionResult SomePayPalIpnEndPoint()
{
  var sb = new StringBuilder();
  var baseUrl = "https://www.paypal.com/cgi-bin/webscr?cmd=_notify-validate";
  var id = "";
  var isRefund = false;
  var isPaymentCompleted = false;

  // build a list of the keys
  foreach (var key in GetFormKeys())
  {
    var value = GetFormValue(key);
    var encoded = HttpUtility.UrlEncode(value);

    sb.Append("&");
    sb.Append(key);
    sb.Append("=");
    sb.Append(encoded);

    if (String.IsNullOrEmpty(key))
    {
      continue;
    }

    if (key.Equals("option_selection1", StringComparison.InvariantCultureIgnoreCase))
    {
      // option_selection1=POF8C4AABC
      id = value;
    }
    else if (key.Equals("test_ipn", StringComparison.InvariantCultureIgnoreCase) &&
      value == "1")
    {
      // test_ipn=1
      baseUrl = "https://www.sandbox.paypal.com/cgi-bin/webscr?cmd=_notify-validate";
    }
    else if (key.Equals("reason_code", StringComparison.InvariantCultureIgnoreCase) &&
      value == "refund")
    {
      // reason_code=refund
      isRefund = true;
    }
    else if (key.Equals("payment_status", StringComparison.InvariantCultureIgnoreCase) &&
         "Completed".Equals(value, StringComparison.InvariantCultureIgnoreCase))
    {
      // payment_status=Refunded OR payment_status=Completed
      isPaymentCompleted = true;
    }
  }

  var url = baseUrl + sb;
  var ipnResponse = ExecuteIpnResponse(url);

  if (ipnResponse.Equals("VERIFIED", StringComparison.InvariantCultureIgnoreCase))
  {
    if (String.IsNullOrEmpty(id))
    {
      throw new InvalidOperationException("No purchase to match");
    }
    if (isRefund == false && isPaymentCompleted == false)
    {
      throw new InvalidOperationException("Should be either RECIEVED or VERIFIED");
    }
    if (isPaymentCompleted)
    {
      // todo - ExecutePurchaseComplete(id);
    }
    if (isRefund)
    {
      // todo - ExecuteRefundActions(id);
    }

    // others....?
    return new HttpStatusCodeResult(200);
  }

  throw new InvalidOperationException("paypal ipn failed.");
}

protected virtual string ExecuteIpnResponse(string url)
{
  var ipnClient = new WebClient();
  var ipnResponse = ipnClient.DownloadString(url);
  return ipnResponse;
}

protected virtual string GetFormValue(string key)
{
  return Request.Form.Get(key);
}

protected virtual string[] GetFormKeys()
{
  return Request.Form.AllKeys;
}

The reason I use the virtual methods to get the form data and send a HTTP request is that it is easy to stub out in a test. You can either setup mock controller unit tests and create services etc or if they are a few lines of code I tend to co for a test double (or test assessor).

Here is a unit test...

[SetUp]
public void TestSetUp()
{
  // Setup mocks etc...
}

[TearDown]
public void TestTearDown()
{
  //mocks.VerifyAll(); etc
}

private PayPalIpnSampleControllerAccessor GetTarget()
{
  // insert mocks etc
  var controller = new PayPalIpnSampleControllerAccessor();
  return controller;
}

[Test]
public void Simulate_the_PayPal_system_calling_my_IPN_Endpoint_wth_a_Completed_status()
{
  // setup test data etc
  var someId = "POF8C4AABC";

  var controller = GetTarget();

  // add some "POST" data - note there are many more fields...
  controller.FormKeys.Add("payment_status", "Completed");
  controller.FormKeys.Add("test_ipn", "1");
  controller.FormKeys.Add("mc_gross", "10");
  controller.FormKeys.Add("address_status", "unconfirmed");
  controller.FormKeys.Add("payer_id", "A83A5CB83D0BQ");
  controller.FormKeys.Add("option_selection1", someId);

  var result = controller.SomePayPalIpnEndPoint() as HttpStatusCodeResult;

  // expect a 200 response for the IPN
  Assert.IsNotNull(result);
  Assert.AreEqual(200, result.StatusCode);

  // you would also verify that your system for example completed its side of the business transaction....
  Debug.WriteLine(controller.IpnCall);
}

The unit test code above makes use of a Test Assessor. It inherits from the controller under test and overrides the bits that cause friction... The assessor looks like this:

public class PayPalIpnSampleControllerAccessor : PayPalIpnSampleController
{
  public Dictionary<string, string> FormKeys { get; private set; }
  public string IpnCall { get; set; }
  public string IpnResponse { get; set; }

  public PayPalIpnSampleControllerAccessor()
  {
    FormKeys=new Dictionary<string, string>();
    IpnResponse = "VERIFIED";
  }

  protected override string[] GetFormKeys()
  {
    return FormKeys.Keys.ToArray();
  }

  protected override string GetFormValue(string key)
  {
    return FormKeys[key];
  }

  protected override string ExecuteIpnResponse(string url)
  {
    // store it
    IpnCall = url;

    // return mock response
    return IpnResponse;
  }
}

If you run this test it pushes some form “post” variables in and then calls the IPN, we look for a 200 response. I make use of the PayPal custom fields to push an ID through, it gets posted back and that how I match up the purchase. It also shows up on the invoice etc that goes to the customer so it can’t be a secret etc.

Once these parts were in place the system was nicely integrated with PayPal, when a customer makes a purchase the IPN is hit with the details, I can act as required.

Test debug output (the URL it hits PayPal with):

https://www.sandbox ... /cgi-bin/webscr?cmd=notify-validate&paymentstatus=Completed&testipn=1&mcgross=10&addressstatus=unconfirmed&payerid=A83A5CB83D0BQ&option_selection1=POF8C4AABC