Tuesday, June 9, 2015

Using MVC Web API and SQL Server to create your own PayPal IPN Listener

Recently I was tasked with creating a PayPal IPN Listener, so I started with getting familiar with the PayPal Instant Payment Notification (IPN) documentation:

https://developer.paypal.com/webapps/developer/docs/classic/ipn/integration-guide/IPNIntro/

For this implementation I'm using a MVC Web API Controller (VB.Net) for the listener, along with some helper classes/extensions/methods. I'm also using a SQL Server 2012 table and stored procedure to create a notifications log, which allows you to:

1) Listen
2) Log
3) React

This is important because it allows the listener to do it's job quickly, and lets you focus on the processing later.

Let's start with our SQL Server database Notifications table.
 SET ANSI_NULLS ON  
 GO  
   
 SET QUOTED_IDENTIFIER ON  
 GO  
   
 SET ANSI_PADDING ON  
 GO  
   
 CREATE TABLE [dbo].[Notifications](  
      [NotificationID] [INT] IDENTITY(1,1) NOT NULL,  
      [Timestamp] [DATETIME] NOT NULL CONSTRAINT [DF_Notifications_Timestamp] DEFAULT (GETDATE()),  
      [Type] [VARCHAR](50) NOT NULL CONSTRAINT [DF_Notifications_Type] DEFAULT (''),  
      [IPAddress] [VARCHAR](25) NOT NULL CONSTRAINT [DF_Notifications_IPAddress] DEFAULT (''),  
      [UrlRequest] [VARCHAR](MAX) NOT NULL CONSTRAINT [DF_Notifications_UrlRequest] DEFAULT (''),  
      [UserAgent] [VARCHAR](MAX) NOT NULL CONSTRAINT [DF_Notifications_UserAgent] DEFAULT (''),  
      [Data] [VARCHAR](MAX) NOT NULL CONSTRAINT [DF_Notifications_Message] DEFAULT (''),  
      [Status] [VARCHAR](MAX) NOT NULL CONSTRAINT [DF_Notifications_Status] DEFAULT (''),  
      [Method] [VARCHAR](10) NOT NULL CONSTRAINT [DF_Notifications_Method] DEFAULT (''),  
      [Processed] [BIT] NOT NULL CONSTRAINT [DF_Notifications_Processed] DEFAULT ((0)),  
      [ProcessedDate] [DATETIME] NULL,  
      [Notes] [VARCHAR](MAX) NOT NULL DEFAULT (''),  
      [TransactionID] [VARCHAR](50) NOT NULL DEFAULT (''),  
      [TransactionType] [VARCHAR](50) NOT NULL DEFAULT (''),  
      [ItemName] [VARCHAR](128) NOT NULL DEFAULT (''),  
      [ItemNumber] [VARCHAR](128) NOT NULL DEFAULT (''),  
      [Option] [VARCHAR](200) NOT NULL DEFAULT (''),  
      [Email] [VARCHAR](100) NOT NULL DEFAULT (''),  
      [PaymentStatus] [VARCHAR](25) NOT NULL DEFAULT (''),  
  CONSTRAINT [PK_Notifications] PRIMARY KEY CLUSTERED   
 (  
      [NotificationID] ASC  
 )WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON)  
 )  
   
 GO  
   
 SET ANSI_PADDING OFF  
 GO  

Next is the Notifications_Save stored procedure to save data in the Notifications table.
 SET ANSI_NULLS ON  
 GO  
   
 SET QUOTED_IDENTIFIER ON  
 GO  
   
 CREATE PROCEDURE [dbo].[Notifications_Save]  
 (  
      @NotificationID      INT,  
      @Type                VARCHAR(50),  
      @IPAddress           VARCHAR(25),  
      @UrlRequest          VARCHAR(MAX),  
      @UserAgent           VARCHAR(MAX),  
      @Data                VARCHAR(MAX),  
      @Status              VARCHAR(MAX),  
      @Method              VARCHAR(10),  
      @Processed           BIT,  
      @ProcessedDate       DATETIME  
 )  
 AS  
 BEGIN  
      -- check to see if record exists  
      IF EXISTS (SELECT NotificationID FROM dbo.Notifications WHERE NotificationID = @NotificationID)  
      BEGIN  
           -- update  
           UPDATE    dbo.Notifications  
           SET       Type = @Type,  
                     IPAddress = @IPAddress,  
                     UrlRequest = @UrlRequest,  
                     UserAgent = @UserAgent,  
                     Data = @Data,  
                     Status = @Status,  
                     Method = @Method,  
                     Processed = @Processed,  
                     ProcessedDate = @ProcessedDate  
           WHERE     NotificationID = @NotificationID  
      END  
      ELSE  
      BEGIN  
           -- insert  
           INSERT INTO dbo.Notifications  
                (Type, IPAddress, UrlRequest, UserAgent, Data, Status, Method, Processed, ProcessedDate)  
           VALUES  
                (@Type, @IPAddress, @UrlRequest, @UserAgent, @Data, @Status, @Method, @Processed, @ProcessedDate)  
    
           -- get identity value  
           SET @NotificationID = SCOPE_IDENTITY()  
      END  
    
      -- return value  
      RETURN @NotificationID  
 END  

Next we'll create the Notification model object in our MVC project.
 Public Class Notification  
   Public Property NotificationID As Integer = 0  
   Public Property Timestamp As DateTime = DateTime.MinValue  
   Public Property Type As String = String.Empty  
   Public Property IPAddress As String = String.Empty  
   Public Property UrlRequest As String = String.Empty  
   Public Property UserAgent As String = String.Empty  
   Public Property Data As String = String.Empty  
   Public Property Status As String = String.Empty  
   Public Property Method As String = String.Empty  
   Public Property Processed As Boolean = False  
   Public Property ProcessedDate As DateTime?  
 End Class  
   

Then we'll create the Notifications data layer class for calling the stored procedure.
 Imports Microsoft.Practices.EnterpriseLibrary.Data  
 Imports System.Data.Common  
   
 Public Class Notifications     
   Public Function Save(ByVal Notification As Model.Notification) As Integer  
     'variables  
     Dim DB As Database = New DatabaseProviderFactory().CreateDefault()  
     Dim ReturnValue As Integer = -1  
   
     'command  
     Using cmd As DbCommand = DB.GetStoredProcCommand("dbo.Notifications_Save")  
       'parameters  
       With Notification  
         DB.AddInParameter(cmd, "@NotificationID", DbType.Int32, .NotificationID)  
         DB.AddInParameter(cmd, "@Type", DbType.String, .Type)  
         DB.AddInParameter(cmd, "@IPAddress", DbType.String, .IPAddress)  
         DB.AddInParameter(cmd, "@UrlRequest", DbType.String, .UrlRequest)  
         DB.AddInParameter(cmd, "@UserAgent", DbType.String, .UserAgent)  
         DB.AddInParameter(cmd, "@Data", DbType.String, .Data)  
         DB.AddInParameter(cmd, "@Status", DbType.String, .Status)  
         DB.AddInParameter(cmd, "@Method", DbType.String, .Method)  
         DB.AddInParameter(cmd, "@Processed", DbType.Boolean, .Processed)  
         DB.AddInParameter(cmd, "@ProcessedDate", DbType.DateTime, IIf(.ProcessedDate.HasValue AndAlso Not .ProcessedDate.Equals(DateTime.MinValue), .ProcessedDate, DBNull.Value))  
       End With  
   
       'return value parameter  
       DB.AddParameter(cmd, "@ReturnValue", DbType.Int32, 4, ParameterDirection.ReturnValue, False, 0, 0, "@ReturnValue", DataRowVersion.Default, Nothing)  
   
       'execute query  
       DB.ExecuteNonQuery(cmd)  
   
       'get return value  
       ReturnValue = DB.GetParameterValue(cmd, "@ReturnValue")  
     End Using  
   
     'return value  
     Return ReturnValue  
   End Function  
 End Class  

Next we have a few helper classes/extensions/methods that we are going to be calling from the listener controller.

1) IsBlank (extension method)
2) ContainsValue (extension method)
3) Result (model object)
4) PostContentToUrl (shared method)

The IsBlank extension method seems trivial, especially for such a small example, but this is just one of the many extension methods that I keep in my utilities library to keep my code, clean, simple, and consistent.
   <Extension()> _  
   Public Function IsBlank(Value As String) As Boolean  
     Dim ReturnValue As Boolean = True  
   
     If Value IsNot Nothing Then  
       ReturnValue = Value.Trim().Length = 0  
     End If  
   
     Return ReturnValue  
   End Function  

The ContainsValue extension method is another one I keep in my library to to simplify the code so you don't have to deal with case sensitivity all over the place.
   <Extension()> _  
   Public Function ContainsValue(Value As String, CompareValue As String) As Boolean  
     Dim ReturnValue As Boolean = False  
   
     If Value IsNot Nothing AndAlso CompareValue IsNot Nothing Then  
       ReturnValue = Value.Trim().IndexOf(CompareValue.Trim(), StringComparison.OrdinalIgnoreCase) >= 0  
     End If  
   
     Return ReturnValue  
   End Function  

The Result model object is a simple helper class we're going to use when we post our data to the PayPal verification URL, which will allow us to return back a property indicating success or failure, as well as the response body.
 Public Class Result  
   Public Property Success As Boolean = False  
   Public Property Message As String = String.Empty    
 End Class  

Then we have our PostContentToUrl method, which was written with re-usability in mind. It accepts a URL and a request body. This allows you to use it for POST'ing data generically to any service.
   Public Shared Function PostContentToUrl(ByVal Url As String, ByVal Data As String) As Model.Result  
     'variables  
     Dim Result As New Model.Result  
   
     Try  
       'create web request  
       Dim MyRequest As WebRequest = WebRequest.Create(Url)  
       Dim ByteArray As Byte() = Encoding.UTF8.GetBytes(Data)  
   
       'properties  
       MyRequest.Method = "POST"  
       MyRequest.ContentType = "application/x-www-form-urlencoded"  
       MyRequest.ContentLength = ByteArray.Length  
   
       'get the request stream  
       Using DataStream As Stream = MyRequest.GetRequestStream()  
         'write the data to the request stream  
         DataStream.Write(ByteArray, 0, ByteArray.Length)  
   
         'close the stream object  
         DataStream.Close()  
       End Using  
   
       'response  
       Using MyResponse As HttpWebResponse = DirectCast(MyRequest.GetResponse(), HttpWebResponse)  
         Using MyReader As New StreamReader(MyResponse.GetResponseStream())  
           Result.Message = MyReader.ReadToEnd()  
           Result.Success = True  
         End Using  
       End Using  
     Catch ex As Exception  
       Result.Message = ex.Message  
     End Try  
   
     'return  
     Return Result  
   End Function  

Next we'll setup a BaseController class to expose several properties for the current HTTP request.
 Public Class BaseController   
   Inherits System.Web.Http.ApiController   
   
   Public ReadOnly Property QueryString() As String   
    Get   
     Try   
      Return DirectCast(Request.Properties("MS_HttpContext"), HttpContextBase).Request.QueryString.ToString   
     Catch ex As Exception   
      Return String.Empty   
     End Try   
    End Get   
   End Property   
     
   Public ReadOnly Property IPAddress() As String   
    Get   
     Try   
      Return DirectCast(Request.Properties("MS_HttpContext"), HttpContextBase).Request.UserHostAddress   
     Catch ex As Exception   
      Return String.Empty   
     End Try   
    End Get   
   End Property   
     
   Public ReadOnly Property UrlRequest() As String   
    Get   
     Try   
      Return DirectCast(Request.Properties("MS_HttpContext"), HttpContextBase).Request.Url.AbsoluteUri   
     Catch ex As Exception   
      Return String.Empty   
     End Try   
    End Get   
   End Property   
     
   Public ReadOnly Property RawUrl() As String   
    Get   
     Try   
      Return DirectCast(Request.Properties("MS_HttpContext"), HttpContextBase).Request.RawUrl   
     Catch ex As Exception   
      Return String.Empty   
     End Try   
    End Get   
   End Property   
     
   Public ReadOnly Property UrlReferrer() As String   
    Get   
     Try   
      Return DirectCast(Request.Properties("MS_HttpContext"), HttpContextBase).Request.UrlReferrer.AbsoluteUri   
     Catch ex As Exception   
      Return String.Empty   
     End Try   
    End Get   
   End Property   
     
   Public ReadOnly Property UserAgent() As String   
    Get   
     Try   
      Return DirectCast(Request.Properties("MS_HttpContext"), HttpContextBase).Request.UserAgent   
     Catch ex As Exception   
      Return String.Empty   
     End Try   
    End Get   
   End Property   
     
   Public ReadOnly Property Crawler() As Boolean   
    Get   
     Try   
      Return DirectCast(Request.Properties("MS_HttpContext"), HttpContextBase).Request.Browser.Crawler   
     Catch ex As Exception   
      Return False   
     End Try   
    End Get   
   End Property  

   Public ReadOnly Property Method() As String  
     Get  
       Try  
         Return DirectCast(Request.Properties("MS_HttpContext"), HttpContextBase).Request.HttpMethod  
       Catch ex As Exception  
         Return False  
       End Try  
     End Get  
   End Property  
 End Class  

Now that we've got all the core pieces in place let's take a look at the ListenerController, which is the simplest part of the entire process.
 Imports System.Net  
 Imports System.Web.Http  
 Imports System.IO  
   
 Namespace Controllers  
   <RoutePrefix("listener")>  
   Public Class ListenerController  
     Inherits BaseController  
   
     <HttpPost>  
     <Route("ipn")>  
     Public Function InstantPaymentNotification() As String  
       'variables  
       Dim Data As String = String.Empty  
       Dim Notification As New Model.Notification  
   
       'get data  
       Try  
         Data = New StreamReader(HttpContext.Current.Request.InputStream).ReadToEnd()  
       Catch ex As Exception  
         Data = String.Empty  
       End Try  
   
       'check for valid data  
       If Data.IsBlank Then  
         Return String.Empty  
       End If  
   
       'PayPal HTTP POSTs an IPN message to your listener that notifies it of an event.  
       'Your listener returns an empty HTTP 200 response to PayPal.  
       'Your listener HTTP POSTs the complete, unaltered message back to PayPal; the message must contain the same fields (in the same order) as the original message and be encoded in the same way as the original message.  
       'PayPal sends a single word back - either VERIFIED (if the message matches the original) or INVALID (if the message does not match the original).  
   
       'Every IPN message you receive from PayPal includes a User-Agent HTTP request header whose value is PayPal IPN ( https://www.paypal.com/ipn ).  
       'Do not use this header to verify that an IPN really came from PayPal and has not been tampered with.  
       'Rather, to verify these things, you must use the IPN authentication protocol outlined above.  
       If Not MyBase.UserAgent.ContainsValue("PayPal") Then  
         Return String.Empty  
       End If  
   
       'Before you can trust the contents of the message, you must first verify that the message came from PayPal.  
       'To verify the message, you must send back the contents in the exact order they were received and precede it with the command _notify-validate, as follows:  
       Dim VerifyUrl As String = IIf(Data.ContainsValue("test_ipn=1"), "https://www.sandbox.paypal.com/cgi-bin/webscr", "https://www.paypal.com/cgi-bin/webscr")  
       Dim VerifyData As String = String.Format("cmd=_notify-validate&{0}", Data)  
       Dim Result As Model.Result = Functions.PostContentToUrl(Url:=VerifyUrl, Data:=VerifyData)  
   
       'properties  
       Notification.Type = "IPN"  
       Notification.IPAddress = MyBase.IPAddress  
       Notification.UrlRequest = MyBase.UrlRequest  
       Notification.UserAgent = MyBase.UserAgent  
       Notification.Data = Data  
       Notification.Status = Result.Message  
       Notification.Method = MyBase.Method  
   
       'save  
       Using DB As New Data.Notifications  
         DB.Save(Notification)  
       End Using  
   
       'Important: After you have authenticated an IPN message (received a VERIFIED response from PayPal), you must perform these important checks before you can assume that the IPN is both legitimate and has not already been processed:  
       'Check that the payment_status is Completed.  
       'If the payment_status is Completed, check the txn_id against the previous PayPal transaction that you processed to ensure the IPN message is not a duplicate.  
       'Check that the receiver_email is an email address registered in your PayPal account.  
       'Check that the price (carried in mc_gross) and the currency (carried in mc_currency) are correct for the item (carried in item_name or item_number).  
       'Once you have completed these checks, IPN authentication is complete.  
       'Now, you can update your database with the information provided and initiate any back-end processing that's appropriate.  
   
       'return  
       Return String.Empty  
     End Function  
   End Class  
 End Namespace  

Because we've used <RoutePrefix("listener")> for the controller and <Route("ipn")> for the InstantPaymentNotification function, our IPN listener service would be exposed as follows:

http://yourdomain.com/listener/ipn

At this point you have an IPN listener that can:

1) Receive IPN notifications from PayPal
2) Respond back instantly to PayPal for the verification handshake
3) Log notification activity to the [Notifications] table in your database

This is the point in the process where you could go several different directions, and ultimately how you validate and process the data is going to be dependent on what types of transaction types (txn_type) you support and what types of services you are selling (e.g. products, subscriptions, etc).

With that said, here is an example of how you might do some data processing in SQL (e.g. a scheduled job that runs every minute).

We'll start with a Split table-valued function to help with splitting the IPN data vertically.
 SET QUOTED_IDENTIFIER ON  
 GO  

 CREATE FUNCTION [dbo].[Split]  
 (  
      @List          VARCHAR(MAX),  
      @Delimiter     VARCHAR(1)  
 )  
 RETURNS @Table TABLE  
 (   
      ID        INT IDENTITY(1,1),  
      Value     VARCHAR(MAX)  
 )  
 AS  
 BEGIN  
      -- loop through the list  
      WHILE (CHARINDEX(@Delimiter, @List) > 0)  
      BEGIN  
           -- add the value to the table  
           INSERT INTO @Table  
                (Value)  
                SELECT Value = LTRIM(RTRIM(SUBSTRING(@List, 1, CHARINDEX(@Delimiter, @List)-1)))  
   
           -- remove the value from the list  
           SET @List = SUBSTRING(@List, CHARINDEX(@Delimiter, @List) + 1, LEN(@List))  
      END  
   
      -- insert remaining value from the list  
      INSERT INTO @Table  
           (Value)  
           SELECT Value = LTRIM(RTRIM(@List))  
        
      -- return  
      RETURN  
 END  
   
 GO  

Next we'll use a SQL script to process any unprocessed notifications.
 /*  
 Important: After you have authenticated an IPN message (received a VERIFIED response from PayPal), you must perform these important checks before you can assume that the IPN is both legitimate and has not already been processed:  
 Check that the payment_status is Completed.  
 If the payment_status is Completed, check the txn_id against the previous PayPal transaction that you processed to ensure the IPN message is not a duplicate.  
 Check that the receiver_email is an email address registered in your PayPal account.  
 Check that the price (carried in mc_gross) and the currency (carried in mc_currency) are correct for the item (carried in item_name or item_number).  
 Once you have completed these checks, IPN authentication is complete.  
 Now, you can update your database with the information provided and initiate any back-end processing that's appropriate.  
 */  
   
 -- set nocount on  
 SET NOCOUNT ON;  
   
 -- if status is not verified mark it as processed and move on  
 UPDATE    dbo.Notifications  
 SET       Processed = 1,  
           ProcessedDate = GETDATE(),  
           Notes = 'Invalid'  
 WHERE     Type = 'IPN'  
 AND       Processed = 0  
 AND       Status != 'VERIFIED'  
   
 -- temp table to get a list of records that are being processed in this batch
 DECLARE @Batch TABLE  
 (  
      NotificationID     INT              NOT NULL DEFAULT 0  
 )  
   
 -- temp table to split out the IPN data vertically
 DECLARE @Details TABLE  
 (  
      BatchID            VARCHAR(50)      NOT NULL DEFAULT '',  
      NotificationID     INT              NOT NULL DEFAULT 0,  
      Property           VARCHAR(50)      NOT NULL DEFAULT '',  
      Value              VARCHAR(MAX)     NOT NULL DEFAULT ''  
 )  
   
 -- get unprocessed notifications that are verified and save in temp table  
 INSERT INTO @Batch  
   (NotificationID)  
      SELECT       NotificationID  
      FROM         dbo.Notifications  
      WHERE        Type = 'IPN'  
      AND          Processed = 0  
      AND          Status = 'VERIFIED'  
      ORDER BY     NotificationID  
   
 -- declare variables for cursor  
 DECLARE @NotificationID    INT           = 0  
 DECLARE @Data              VARCHAR(MAX)  = ''  
   
 -- declare cursor  
 DECLARE MyCursor CURSOR LOCAL FAST_FORWARD  
 FOR  
      SELECT       n.NotificationID,  
                   n.Data  
      FROM         dbo.Notifications n  
      JOIN         @Batch x ON n.NotificationID = x.NotificationID  
      ORDER BY     n.NotificationID  
   
 -- open cursor  
 OPEN MyCursor  
   
 -- get the first result  
 FETCH NEXT FROM MyCursor INTO @NotificationID, @Data  
   
 -- loop through results  
 WHILE @@FETCH_STATUS = 0  
 BEGIN       
      -- variables  
      DECLARE @BatchID    VARCHAR(50) = FORMAT(GETDATE(), 'yyyyMMddHHmmssfffffff') + dbo.GetRandomString(5)  
        
      -- split IPN data vertically
      INSERT INTO @Details  
           (BatchID, NotificationID, Property, Value)  
           SELECT    BatchID = @BatchID,  
                     @NotificationID,  
                     Property = (SELECT Value FROM dbo.Split(Value, '=') WHERE ID = 1),  
                     Value = (SELECT dbo.DecodeValue(Value) FROM dbo.Split(Value, '=') WHERE ID = 2)  
           FROM      dbo.Split(@Data, '&')  
   
      -- fetch the next record  
      FETCH NEXT FROM MyCursor INTO @NotificationID, @Data  
 END  
   
 -- close cursor  
 CLOSE          MyCursor  
 DEALLOCATE     MyCursor  
   
 -- extract core information that we are interested in for our payment processing
 -- txn_id, txn_type, item_name, item_number, option_selection1, custom, payment_status
 UPDATE    n  
 SET       n.TransactionID = ISNULL(x.txn_id, ''),  
           n.TransactionType = ISNULL(x.txn_type, ''),  
           n.ItemName = ISNULL(x.item_name, ''),  
           n.ItemNumber = ISNULL(x.item_number, ''),  
           n.[Option] = ISNULL(x.option_selection1, ''),  
           n.Email = ISNULL(x.custom, ''),  
           n.PaymentStatus = ISNULL(x.payment_status, '')  
 FROM      @Details d  
 PIVOT     (  
                MAX(d.Value)  
                FOR Property IN (txn_id, txn_type, item_name, item_number, option_selection1, custom, payment_status)  
           )     AS x  
 JOIN      dbo.Notifications n ON x.NotificationID = n.NotificationID  
   
 -- process non-payments  
 UPDATE    n  
 SET       n.Processed = 1,  
           n.ProcessedDate = GETDATE(),  
           n.Notes = 'Non-payment'  
 FROM      dbo.Notifications n  
 JOIN      @Batch x ON n.NotificationID = x.NotificationID  
 WHERE     n.Processed = 0  
 AND       (LEN(n.TransactionID) = 0 OR LEN(n.PaymentStatus) = 0)  
   
 -- process duplicate transactions  
 UPDATE    n  
 SET       n.Processed = 1,  
           n.ProcessedDate = GETDATE(),  
           n.Notes = 'Duplicate'  
 FROM      dbo.Notifications n  
 JOIN      @Batch x ON n.NotificationID = x.NotificationID  
 WHERE     n.Processed = 0  
 AND       n.TransactionID IN (SELECT y.TransactionID FROM dbo.Notifications y WHERE y.Processed = 1)  
   
 -- process non-completed payments  
 UPDATE    n  
 SET       n.Processed = 1,  
           n.ProcessedDate = GETDATE(),  
           n.Notes = 'Unknown payment status'  
 FROM      dbo.Notifications n  
 JOIN      @Batch x ON n.NotificationID = x.NotificationID  
 WHERE     n.Processed = 0  
 AND       n.PaymentStatus != 'Completed'  
   
 -- process completed payments  
 UPDATE    n  
 SET       n.Processed = 1,  
           n.ProcessedDate = GETDATE(),  
           n.Notes = 'Completed'  
 FROM      dbo.Notifications n  
 JOIN      @Batch x ON n.NotificationID = x.NotificationID  
 WHERE     n.Processed = 0  
 AND       n.PaymentStatus = 'Completed'  

This is the point where if you have new "completed" payments you could trigger other workflow items in your system, such as email notifications to send a license key, or renewing a subscription, or otherwise giving access to the product/service to the payee.

Remember this is just a starting point and your specific data processing implementation will vary based on your needs.

Matt Pavey is a Microsoft Certified software developer who specializes in ASP.Net, VB.Net, C#, AJAX, LINQ, XML, XSL, Web Services, SQL, jQuery, and more. Follow on Twitter @matthewpavey

0 comments: