12

I have an Asp.Net WEB API 2 project and I would like to implement an Instant Payment Notification (IPN) listener controller.

I can't find any example and nuget package. All I need is to acknowledge that the user paid with the standard html button on Paypal. It's quite simple.

All the nuget packages are to create invoice or custom button. It's not what I need

The samples on paypal are for classic asp.net and not for MVC or WEB API MVC

I'm sure somebody did that already and when I started coding I had a feeling that I was reinventing the wheel.

Is there any IPN listener controller example?

At least a PaypalIPNBindingModel to bind the Paypal query.

    [Route("IPN")]
    [HttpPost]
    public IHttpActionResult IPN(PaypalIPNBindingModel model)
    {
        if (!ModelState.IsValid)
        {
            return BadRequest();
        }

        return Ok();
    }

EDIT

So far I have the following code

        [Route("IPN")]
        [HttpPost]
        public void IPN(PaypalIPNBindingModel model)
        {
            if (!ModelState.IsValid)
            {
                // if you want to use the PayPal sandbox change this from false to true
                string response = GetPayPalResponse(model, true);

                if (response == "VERIFIED")
                {

                }
            }
        }

        string GetPayPalResponse(PaypalIPNBindingModel model, bool useSandbox)
        {
            string responseState = "INVALID";

            // Parse the variables
            // Choose whether to use sandbox or live environment
            string paypalUrl = useSandbox ? "https://www.sandbox.paypal.com/"
            : "https://www.paypal.com/";

            using (var client = new HttpClient())
            {
                client.BaseAddress = new Uri(paypalUrl);
                client.DefaultRequestHeaders.Accept.Clear();
                client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/x-www-form-urlencoded"));

                //STEP 2 in the paypal protocol
                //Send HTTP CODE 200
                HttpResponseMessage response = client.PostAsJsonAsync("cgi-bin/webscr", "").Result;

                if (response.IsSuccessStatusCode)
                {
                    //STEP 3
                    //Send the paypal request back with _notify-validate
                    model.cmd = "_notify-validate";
                    response = client.PostAsync("cgi-bin/webscr", THE RAW PAYPAL REQUEST in THE SAME ORDER ).Result;

                    if(response.IsSuccessStatusCode)
                    {
                        responseState = response.Content.ReadAsStringAsync().Result;
                    }
                }
            }

            return responseState;
        }

but for the step 3 I tried to post my model as json but paypal returns a HTML page instead of VALIDATED or INVALID. I figured out that I have to use application/x-www-form-urlencoded and it the parameters as to be in the same order.

How can I get the request URL?

I would use the query Url and add &cmd=_notify-validate to it

Marc
  • 16,170
  • 20
  • 76
  • 119
  • Does [this sample](http://www.codeproject.com/Tips/84538/Setting-up-PayPal-Instant-Payment-Notification-IPN) on CodeProject help? – Jason Z Oct 30 '14 at 17:38
  • Also, [here's the IPN sample on GitHub](https://github.com/paypal/ipn-code-samples/blob/master/paypal_ipn.asp) for asp.net. (Meant to include that in my previous response). – Jason Z Oct 30 '14 at 17:47
  • See the [Receiving an INVALID message from PayPal](https://developer.paypal.com/docs/classic/ipn/integration-guide/IPNTesting/#id091GFE00WY4) page on the PayPal Developer site. It explains exactly how your response URL should be formatted. As you stated, it must include all the URL parameters you received with the notification in the exact same order, but with `cmd=_notify-validate` **preceding** the other URL parameters. – Jason Z Oct 30 '14 at 18:35
  • My problem is that I have a PaypalIPNBindingModel object instead of the raw request. I can't be sure that I use the same order. I'm trying to figure out how to get the raw post data. – Marc Oct 30 '14 at 18:39
  • possible duplicate of [Paypal IPN Listener for ASP.NET MVC](http://stackoverflow.com/questions/2447468/paypal-ipn-listener-for-asp-net-mvc) – Michal Hosala Sep 16 '15 at 21:11
  • As requested initially by @Marc solution is required that works with MVC or Web API. I'm also trying to use this with web api and unable to make it work. It mostly gives error that input stream has some invalid values. Another problem is with web api we don't know the exact sequence for variables posted, because web API accepts data in model directly. Following is noted by PayPal Dev Guide, which is hard to achieve with Web API Contains exactly the same variables and values as the original IPN. Places these variables and values in the same order as does the original IPN. – Amit Andharia Mar 01 '17 at 06:43

5 Answers5

13

Based on accepted answer I came up with the following code implementing IPN listener for ASP.NET MVC. The solution has already been deployed and appears to work correctly.

[HttpPost]
public async Task<ActionResult> Ipn()
{
    var ipn = Request.Form.AllKeys.ToDictionary(k => k, k => Request[k]);
    ipn.Add("cmd", "_notify-validate");

    var isIpnValid = await ValidateIpnAsync(ipn);
    if (isIpnValid)
    {
        // process the IPN
    }

    return new EmptyResult();
}

private static async Task<bool> ValidateIpnAsync(IEnumerable<KeyValuePair<string, string>> ipn)
{
    using (var client = new HttpClient())
    {
        const string PayPalUrl = "https://www.paypal.com/cgi-bin/webscr";

        // This is necessary in order for PayPal to not resend the IPN.
        await client.PostAsync(PayPalUrl, new StringContent(string.Empty));

        var response = await client.PostAsync(PayPalUrl, new FormUrlEncodedContent(ipn));

        var responseString = await response.Content.ReadAsStringAsync();
        return (responseString == "VERIFIED");
    }
}

EDIT:

Let me share my experience - the above code was working just fine up until now, but suddenly it failed for one IPN it was processing, i.e. responseString == "INVALID".

The issue turned out to be that my account was set up to use charset == windows-1252 which is PayPal default. However, FormUrlEncodedContent uses UTF-8 for encoding and therefore the validation failed because of national characters like "ř". The solution was to set charset to UTF-8, which can be done in Profile > My selling tools > PayPal button language encoding > More Options, see this SO thread.

Community
  • 1
  • 1
Michal Hosala
  • 5,570
  • 1
  • 22
  • 49
  • Great call on the UTF-8. It is a little hard to find but it is there. – pat capozzi Apr 06 '16 at 04:40
  • 1
    If this code used to be working, it is not now. PayPal requires for verification the data to be posted back **in the same order AND preceded by the cmd variable**. This code does not meet any of these requirements. – bilal.haider Jun 24 '16 at 14:18
  • 1
    why do you post a blank request to paypal before posting again with the request data? – rdans Oct 25 '16 at 09:36
  • @rdans I believe the comment above the `Post` invocation should be rpetty self-explanatory. – Michal Hosala Oct 25 '16 at 10:32
  • not really. why would an empty post prevent Paypal from re-sending the IPN? Theres no information in the request to tell Paypal which transaction the post is for so how would it know which one not to send again? – rdans Oct 25 '16 at 11:17
  • 1
    @rdans alright, I see your point and have to admit I am not sure, will have to spend some more time on this. Please see my comments below the second highest voted answer, seems like i was wondering about the same as you do... – Michal Hosala Oct 25 '16 at 11:48
  • 2
    Amazing. I was lost with these situation, changing the encoding was the solution to my situation. Thanks. – HolloW Feb 21 '17 at 19:54
6

This is my code

Feel free to review is something is wrong

        [Route("IPN")]
        [HttpPost]
        public IHttpActionResult IPN()
        {
            // if you want to use the PayPal sandbox change this from false to true
            string response = GetPayPalResponse(true);

            if (response == "VERIFIED")
            {
                //Database stuff
            }
            else
            {
                return BadRequest();
            }

            return Ok();
        }

        string GetPayPalResponse(bool useSandbox)
        {
            string responseState = "INVALID";
            // Parse the variables
            // Choose whether to use sandbox or live environment
            string paypalUrl = useSandbox ? "https://www.sandbox.paypal.com/"
            : "https://www.paypal.com/";

            using (var client = new HttpClient())
            {
                client.BaseAddress = new Uri(paypalUrl);
                client.DefaultRequestHeaders.Accept.Clear();
                client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/x-www-form-urlencoded"));

                //STEP 2 in the paypal protocol
                //Send HTTP CODE 200
                HttpResponseMessage response = client.PostAsJsonAsync("cgi-bin/webscr", "").Result;

                if (response.IsSuccessStatusCode)
                {
                    //STEP 3
                    //Send the paypal request back with _notify-validate
                    string rawRequest = response.Content.ReadAsStringAsync().Result;
                    rawRequest += "&cmd=_notify-validate";

                    HttpContent content = new StringContent(rawRequest);

                    response = client.PostAsync("cgi-bin/webscr", content).Result;

                    if(response.IsSuccessStatusCode)
                    {
                        responseState = response.Content.ReadAsStringAsync().Result;
                    }
                }
            }

            return responseState;
        }
Pascal Paradis
  • 4,275
  • 5
  • 37
  • 50
Marc
  • 16,170
  • 20
  • 76
  • 119
  • 1
    Thanks for the helpful lines, but I am wondering whether STEP 2 is really necessary. I am looking at the [IPN code samples](https://github.com/paypal/ipn-code-samples) and I don't see two separate POST messages, just the one from STEP 3. – Michal Hosala Sep 11 '15 at 23:07
  • Even though PayPal documentation is not terribly clear on this either, looking at the ["Receiving your first notification"](https://developer.paypal.com/docs/classic/ipn/gs_IPN/) I see STEP 1 "Upon receipt of a notification from PayPal, send an empty HTTP 200 response.", but the [other piece of documentation](https://developer.paypal.com/docs/classic/ipn/ht_ipn/) doesn't mention this step at all, which is what I see in the code samples as well... – Michal Hosala Sep 11 '15 at 23:07
  • I know this question is super old, but just for clarification. I believe step two is being misunderstood here. Step two, is PayPal saying that your response to the initial IPN should be an empty 200 response, not that you need to send an additional empty 200 request. – kim3er Aug 01 '19 at 08:44
  • Furthermore, this code appears to be validating the redundant empty request, not the actual IPN. – kim3er Aug 01 '19 at 08:50
  • There are a few blatant programming errors in this code. You should never create new HttpClient, but rather, inject it in the constructor and re-use it. Otherwise you open many sockets and waste resources. Then you use an async method without await, and instead call .Result to run it synchronously. I believe this can cause deadlocks under certain scenarios. Other than that, is the actual logic doing its job, or is there better code somewhere? – Etienne Charland Oct 15 '20 at 23:57
  • @EtienneCharland Write an answer then – Luke Nov 10 '22 at 13:28
3

I was also looking for a solution similar to the OP's original question Is there any IPN listener controller example? At least a PaypalIPNBindingModel to bind the Paypal query. and I got to this page. I tried the other solutions mentioned in this thread, they all worked but I really need the PayPal query-to-model solution so I googling until I stumbled on Carlos Rodriguez's Creating a PayPal IPN Web API Endpoint blogpost.

Here's an overview on what Carlos did:

  1. Create a model. Base the properties you'll define in the model from the ipn response you'll get from PayPal.

    public class IPNBindingModel
    {
        public string PaymentStatus { get; set; }
        public string RawRequest { get; set; }
        public string CustomField { get; set; }    
    }
    
  2. Create a PayPal Validator class.

    public class PayPalValidator
    {
        public bool ValidateIPN(string body)
        {
            var paypalResponse = GetPayPalResponse(true, body);
            return paypalResponse.Equals("VERIFIED");
        }
    
        private string GetPayPalResponse(bool useSandbox, string rawRequest)
        {
            string responseState = "INVALID";
            string paypalUrl = useSandbox ? "https://www.sandbox.paypal.com/"
            : "https://www.paypal.com/";
    
            ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12;
            using (var client = new HttpClient())
            {
                client.BaseAddress = new Uri(paypalUrl);
                client.DefaultRequestHeaders.Accept.Clear();
                client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/x-www-form-urlencoded"));
                HttpResponseMessage response = client.PostAsJsonAsync("cgi-bin/webscr", "").Result;
                if (response.IsSuccessStatusCode)
                {
                    rawRequest += "&cmd=_notify-validate";
                    HttpContent content = new StringContent(rawRequest);
                    response = client.PostAsync("cgi-bin/webscr", content).Result;
                    if (response.IsSuccessStatusCode)
                    {
                        responseState = response.Content.ReadAsStringAsync().Result;
                    }
                }
            }
            return responseState;
        }
    }
    
  3. Create your controller.

    [RoutePrefix("paypal")]
    public class PayPalController : ApiController
    {
        private PayPalValidator _validator;
    
        public PayPalController()
        {
           this._validator = new PayPalValidator();
        }
    
        [HttpPost]
        [Route("ipn")]
        public void ReceiveIPN(IPNBindingModel model)
        {
            if (!_validator.ValidateIPN(model.RawRequest)) 
                throw new Exception("Error validating payment");
    
            switch (model.PaymentStatus)
            {
    
                case "Completed":
                    //Business Logic
                    break;
            }
       }
    }
    
  4. Create a model binder that will define how Web Api will automatically create the model for you.

    public class IPNModelBinder : IModelBinder
    {
        public bool BindModel(HttpActionContext actionContext, ModelBindingContext bindingContext)
        {
            if (bindingContext.ModelType != typeof(IPNBindingModel))
            {
               return false;
            }
        var postedRaw = actionContext.Request.Content.ReadAsStringAsync().Result;
    
        Dictionary postedData = ParsePaypalIPN(postedRaw);
        IPNBindingModel ipn = new IPNBindingModel
        {
            PaymentStatus = postedData["payment_status"],
            RawRequest = postedRaw,
            CustomField = postedData["custom"]
        };
    
        bindingContext.Model = ipn;
        return true;
    }
    
    private Dictionary ParsePaypalIPN(string postedRaw)
    {
        var result = new Dictionary();
        var keyValuePairs = postedRaw.Split('&');
        foreach (var kvp in keyValuePairs)
        {
            var keyvalue = kvp.Split('=');
            var key = keyvalue[0];
            var value = keyvalue[1];
            result.Add(key, value);
        }
    
        return result;
    }
    }
     }
    
  5. Register your model binder to WebApiConfig.cs. config.BindParameter(typeof(IPNBindingModel), new IPNModelBinder());

Hope this helps somebody else. Thank you Carlos Rodriguez for your amazing code.

Annie Lagang
  • 3,185
  • 1
  • 29
  • 36
2

Extending Michal Hosala's answer, there are two things needed to get a successful handshake with PayPal

First, setting the security protocol before making request to PayPal

ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12;

Second, avoiding the dictionary because for verification, PayPal requires the data to be posted back in the same order and preceded by the cmd variable. I ended up doing this

Request.InputStream.Seek(0, SeekOrigin.Begin);
string rawRequestBody = new StreamReader(Request.InputStream).ReadToEnd();
var ipnVarsWithCmd = rawRequestBody.Split('&').Select(x => new KeyValuePair<string, string>(x.Split('=')[0], x.Split('=')[1])).ToList();
ipnVarsWithCmd.Insert(0, new KeyValuePair<string, string>("cmd", "_notify-validate"));
Community
  • 1
  • 1
bilal.haider
  • 318
  • 1
  • 4
  • 18
  • "PayPal requires the data to be posted back in the same order and preceded by the cmd variable" - have any proof for that? I believe HTML specification says that order of inputs doesn't matter, so I think dictionary is just ok here. Have you actually tried it? And how about setting the security protocol? Can you extend your answer there? Obviously I am not setting it and to reiterate, my implementation works just fine... – Michal Hosala Oct 11 '16 at 12:54
  • 1
    @Michal In the [PayPal Integration Guide](https://developer.paypal.com/docs/classic/ipn/integration-guide/IPNImplementation/) we can find this. "Prefix the returned message with the cmd=_notify-validate variable, but do not change the message fields, the order of the fields, or the character encoding from the original message." – bilal.haider Oct 12 '16 at 16:48
  • Yes I know what Paypal integration guide says, but I am saying in the real world the real world the order should not matter according to HTML spec. – Michal Hosala Oct 13 '16 at 10:07
  • @MichalHosala I did this integration a while back so cannot say for sure but I think I was not getting the acknowledgement back when I used dictionary. – bilal.haider Oct 17 '16 at 18:49
1

There is an official c# example here: https://github.com/paypal/ipn-code-samples in path \c#\paypal_ipn_mvc.cs

The C# example shows an ASP.NET MVC controller with an action that responds to the IPN.

Hakakou
  • 522
  • 3
  • 6