Building an Integration (Stripe) - Part 2 - Web API Controllers
This article series covers the planning and implementation of the Stripe payment gateway implementation.
The previous article covered the process of creating necessary settings categories and keys, adding resource strings to the Xperience admin app, and adding Stripe Checkout to the live site.
The admin library of this integration has two main tasks to accomplish. First, it needs to accept webhook requests from Stripe and update Xperience orders appropriately. Second, it needs to capture funds for payments using delayed capture.
This article will focus on the first part, which is one of the most vital to the integration. With the live site code alone, orders can be paid for through stripe, but there is no way to automatically propagate that information back to Kentico. Store staff would have to manually keep track of orders and update them according to data in Stripe. We’ll create an endpoint so that Stripe webhooks can automatically keep our Xperience orders up to date.
The Series
-
Appraising the available tools and choosing which are right for an integration
-
Getting hands-on
Setting up the library
First, in your WebApp solution, create a new .NET Framework class library called Custom.Xperience.Stripe.Admin. In this project, install the Kentico.Xperience.Libraries, Newtonsoft.Json, Microsoft.AspNet.WebApi, and Stripe.net NuGet packages (the Kentico libraries version should match the hotfix version of your project). Then, add a reference from the WebApp project to the Custom.Xperience.Stripe.Admin class library.
Adding constants
There are some strings that need to be re-used throughout the admin side of the integration, so let’s make some re-usable constants.
Add a folder to the Custom.Xperience.Stripe.Admin library called Constants. Add a static class called XperienceStripeConstants in the Custom.Xperience.Stripe namespace with the following strings.
namespace Custom.Xperience.Stripe
{
public static class XperienceStripeConstants
{
public const string PAYMENT_INTENT_ID_KEY = "StripePaymentIntentID";
public const string CHECKOUT_ID_KEY = "StripeCheckoutID";
}
}
Creating a controller
To update orders in Xperience based on what happens in Stripe, we need a controller that will process webhook messages.
Add a Controllers folder to the class library, and create a new class called StripeController. Define it in the namespace Custom.Xperience.Stripe.Endpoint, and give it the following using directives:
using System.Linq;
using System.Net.Http;
using System.Net;
using System.Web.Http;
using System.IO;
using System.Web;
using System.Threading.Tasks;
using System.Collections.Generic;
using Stripe;
using Stripe.Checkout;
using CMS.Core;
using CMS.Ecommerce;
Make your class inherit from APIController
public class StripeController : ApiController
This code will need several services to function, so let’s add each of the following.
- An IEventLogService to log errors and information to the Event log.
- An IAppSettingsService to pull the values of settings keys from the web.config file.
- An ILocalizationService to resolve the value of resource strings.
- An IOrderInfoProvider to work with Xperience orders.
- An IOrderStatusInfoProvider to work with Xperience order statuses.
- An IPaymentOptionInfoProvider to work with Xperience payment options.
Resolving these services in the constructor will let you use a single instance throughout the whole class, rather than having to materialize the service each time you need to access the information that it provides.
private IEventLogService eventLogService;
private IAppSettingsService appSettingsService;
private ILocalizationService localizationService;
private IOrderInfoProvider orderInfoProvider;
private IOrderStatusInfoProvider orderStatusInfoProvider;
private IPaymentOptionInfoProvider paymentOptionInfoProvider;
public StripeController()
{
eventLogService = Service.Resolve<IEventLogService>();
appSettingsService = Service.Resolve<IAppSettingsService>();
localizationService = Service.Resolve<ILocalizationService>();
orderInfoProvider = Service.Resolve<IOrderInfoProvider>();
orderStatusInfoProvider = Service.Resolve<IOrderStatusInfoProvider>();
paymentOptionInfoProvider = Service.Resolve<IPaymentOptionInfoProvider>();
}
Next, we’ll create some utility methods to keep our code organized.
Validating and retrieving the order status
Since this code relies so heavily upon the Stripe payment method’s settings for the order statuses associated with various states, let’s create a method to make sure a provided order status exists and return it if so.
Our method will follow a similar principle to the TryParse methods used by numeric types, returning true and passing a value as an out parameter if the status exists and returning false if not.
Create a new bool method called TryGetValidStatus, which takes an int parameter to hold the order status ID and an OrderStatusInfo out parameter for the order status itself.
bool TryGetValidStatus(int statusID, out OrderStatusInfo status)
The IOrderStatusInfoProvider that we resolved in the constructor has an overload of its Get method that takes a numerical ID as a parameter and returns the corresponding OrderStatusInfo if it exists. Execute this query, setting its result to status, and return true if there was a result.
status = OrderStatusInfo.Provider.Get(statusID);
return status != null;
The result should look like the following.
bool TryGetValidStatus(int statusID, out OrderStatusInfo status)
{
status = OrderStatusInfo.Provider.Get(statusID);
return status != null;
}
Setting the order status
Now we can create some methods to update the status of an order, which can be called based on the type of message we get from Stripe. These operations are fundamental to the purpose of our endpoint.
First, create a method to update the order to paid status. It should be void and take an order as a parameter.
protected virtual void UpdateOrderToPaid(OrderInfo order)
Since this will be used when Stripe tells us the order was successfully paid for, we know that the order should be marked as paid when we receive this message. Set OrderIsPaid to true after confirming that the order is not null.
if (order != null)
{
order.OrderIsPaid = true;
//…
}
If our goal was to be as robust as possible, it might be worth logging an error if the order is null, but for the purposes of this integration, that will be handled higher up in the call stack, before this method is reached. If you plan to use these methods in a different context, though, it may be advisable to add extra logging.
It is possible to configure an order status to set this property automatically when the order reaches it. However, there is no guarantee that whoever uses this integration will take the time to configure that, so let’s set it explicitly to be sure.
Next, pull the Stripe payment option based on the order provided as a parameter.
var paymentOption = paymentOptionInfoProvider.Get(order.OrderPaymentOptionID);
After that, check if the query found a result. If it did, we can use the TryGetValidStatus method from earlier to validate the ID saved to its PaymentOptionSucceededOrderStatusID setting. Based on the result of this method, either set the order’s OrderStatusID or log an error to the Event log.
if (paymentOption != null && TryGetValidStatus(paymentOption.PaymentOptionFailedOrderStatusID, out OrderStatusInfo status))
{
order.OrderStatusID = status.StatusID;
}
else
{
eventLogService.LogEvent(EventTypeEnum.Error, "Stripe", localizationService.GetString("custom.stripe.error.failedstatusnotset"));
}
The completed method should look like this.
protected virtual void UpdateOrderToPaid(OrderInfo order)
{
if (order != null)
{
order.OrderIsPaid = true;
var paymentOption = paymentOptionInfoProvider.Get(order.OrderPaymentOptionID);
if (paymentOption != null && TryGetValidStatus(paymentOption.PaymentOptionSucceededOrderStatusID, out OrderStatusInfo status))
{
order.OrderStatusID = status.StatusID;
}
else
{
eventLogService.LogEvent(EventTypeEnum.Error, "Stripe", localizationService.GetString("custom.stripe.error.paidstatusnotset"));
}
}
}
Next, create a method called UpdateOrderToFailed that follows similar logic, setting order.OrderIsPaid to false, and order.OrderStatusID to the failed status specified by the payment option. It should look like the following.
protected virtual void UpdateOrderToFailed(OrderInfo order)
{
if (order != null)
{
order.OrderIsPaid = false;
var paymentOption = paymentOptionInfoProvider.Get(order.OrderPaymentOptionID);
if (paymentOption != null && TryGetValidStatus(paymentOption.PaymentOptionFailedOrderStatusID, out OrderStatusInfo status))
{
order.OrderStatusID = status.StatusID;
}
else
{
eventLogService.LogEvent(EventTypeEnum.Error, "Stripe", localizationService.GetString("custom.stripe.error.failedstatusnotset"));
}
}
}
The last method in this group should set the order to the authorized status, which will involve an additional step, and an extra parameter.
Since it will be used when a status is meant to be paid later, we need to give it information that Xperience can use to find the proper payment in Stripe in the future. Define the method with an additional parameter to accept the ID of a Payment Intent from Stripe.
protected virtual void UpdateOrderToAuthorized(OrderInfo order, string paymentIntentId)
Start by validating the parameters, and log an error if there is no paymentIntentId found.
if (!string.IsNullOrEmpty(paymentIntentId))
{
if (order != null)
{
//…
}
}
else
{
eventLogService.LogEvent(EventTypeEnum.Error, "Stripe", localizationService.GetString("custom.stripe.error.nopaymentintentid"));
}
After ensuring that order.OrderIsPaid is false, because payment has not been captured yet, save the value of the paymentIntentId parameter to the order’s OrderCustomData field.
order.OrderIsPaid = false;
order.OrderCustomData.SetValue(XperienceStripeConstants.PAYMENT_INTENT_ID_KEY, paymentIntentId);
The rest of the method should be like the previous two, resulting in the following.
protected virtual void UpdateOrderToAuthorized(OrderInfo order, string paymentIntentId)
{
if (!string.IsNullOrEmpty(paymentIntentId))
{
if (order != null)
{
order.OrderIsPaid = false;
order.OrderCustomData.SetValue(XperienceStripeConstants.PAYMENT_INTENT_ID_KEY, paymentIntentId);
var paymentOption = paymentOptionInfoProvider.Get(order.OrderPaymentOptionID);
if (paymentOption != null && TryGetValidStatus(paymentOption.PaymentOptionAuthorizedOrderStatusID, out OrderStatusInfo status))
{
order.OrderStatusID = status.StatusID;
}
else
{
eventLogService.LogEvent(EventTypeEnum.Error, "Stripe", localizationService.GetString("custom.stripe.error.authorizedstatusnotset"));
}
}
}
else
{
eventLogService.LogEvent(EventTypeEnum.Error, "Stripe", localizationService.GetString("custom.stripe.error.nopaymentintentid"));
}
}
Retrieving the order
The integration should receive messages about two types of objects from Stripe— Checkout Sessions and Payment Intents. Let’s make a method to retrieve the related Xperience order based on each.
The method to retrieve an order when we’re processing a Payment Intent should take the ID of the Payment Intent as a parameter and return an OrderInfo object.
protected virtual OrderInfo GetOrderFromPaymentIntent(string paymentIntentID)
We can search the custom data of our orders to see if the payment intent ID was saved to the custom data of any orders.
var orders = orderInfoProvider.Get().WhereLike("OrderCustomData", $"%<{XperienceStripeConstants.PAYMENT_INTENT_ID_KEY}>{paymentIntentID}</{XperienceStripeConstants.PAYMENT_INTENT_ID_KEY}>%");
Finally, we can return the result of this query. Unless the person using the integration uses the stripe API to change the ClientReferenceId saved in Stripe, multiple orders should never be associated with the same payment intent. If there are multiple orders, or no orders, then log an error and return FirstOrDefault.
try
{
return orders.Single();
}
catch
{
eventLogService.LogEvent(EventTypeEnum.Error, "Stripe", localizationService.GetString("custom.stripe.error.ordersfrompaymentintent"), $"PaymentIntentId: {paymentIntentID }\r\nNumber of orders: {orders.Count}");
return orders.FirstOrDefault();
}
The resulting method should look like the following.
protected virtual OrderInfo GetOrderFromPaymentIntent(string paymentIntentID)
{
var orders = orderInfoProvider.Get().WhereLike("OrderCustomData", $"%<{XperienceStripeConstants.PAYMENT_INTENT_ID_KEY}>{paymentIntentID}</{XperienceStripeConstants.PAYMENT_INTENT_ID_KEY}>%");
try
{
return orders.Single();
}
catch
{
eventLogService.LogEvent(EventTypeEnum.Error, "Stripe", localizationService.GetString("custom.stripe.error.ordersfrompaymentintent"), $"PaymentIntentId: {paymentIntentID }\r\nNumber of orders: {orders.Count}");
return orders.FirstOrDefault();
}
}
Next up, we’ll look into retrieving the order from a Checkout Session. Checkout event messages from Stripe will have the ClientReferenceID field available, so take it as the parameter for the method.
protected virtual OrderInfo GetOrderFromCheckoutSession(string clientReferenceID)
Since this parameter references an Xperience OrderID directly, we can find it with a query for equality, unlike the previous case that used WhereLike. The parameter comes from the Stripe event as a string, which we can use with the WhereEquals extension method.
var orders = orderInfoProvider.Get().WhereEquals("OrderID", clientReferenceID);
If the query returns a result, return it via SingleOrDefault. The primary key status of OrderID should prevent there from ever being more than one result. If the query returns empty, then log an error that references the clientReferenceId and return null.
if (orders.Count > 0)
{
return orders.SingleOrDefault();
}
eventLogService.LogEvent(EventTypeEnum.Error, "Stripe", localizationService.GetString("custom.stripe.error.noordersfromcheckout"), $"clientReferenceID: {clientReferenceID}");
return null;
The full method should look like the following.
protected virtual OrderInfo GetOrderFromCheckoutSession(string clientReferenceID)
{
var orders = orderInfoProvider.Get().WhereEquals("OrderID", clientReferenceID);
if (orders.Count > 0)
{
return orders.SingleOrDefault();
}
eventLogService.LogEvent(EventTypeEnum.Error, "Stripe", localizationService.GetString("custom.stripe.error.noordersfromcheckout"), $"clientReferenceID: {clientReferenceID}");
return null;
}
Updating the order
Now we should have the tools to retrieve and update an order based on the events that Stripe sends.
First, create a method to update an Xperience Order (OrderInfo) based on a checkout Session and the Stripe Event that session came from.
protected virtual void UpdateOrderFromCheckoutSession(Session checkoutSession, OrderInfo order, Event stripeEvent)
Within this new method, validate the parameters and save the Stripe Checkout Session's ID to the order's custom data.
This isn’t used in the integration, but it may come in handy for shops who want to find the Stripe object with which a given Xperience order is associated.
if (checkoutSession != null && order != null && stripeEvent != null)
{
order.OrderCustomData.SetValue(XperienceStripeConstants.CHECKOUT_ID_KEY, checkoutSession.Id);
//…
}
Next, check on the event’s Type, and the PaymentStatus of the checkout session. If the checkout session is completed, it can have two potential payment statuses— paid or unpaid.
If the checkout session is completed and paid, update the order to the Paid status with the method you built earlier.
if ((stripeEvent.Type == Events.CheckoutSessionCompleted && checkoutSession.PaymentStatus == "paid"))
{
UpdateOrderToPaid(order);
}
If the checkout session is completed but the order is unpaid, the payment will be captured later. In this case, we should consider this payment authorized. In this case, call the UpdateOrderToAuthorized method from earlier.
else if (stripeEvent.Type == Events.CheckoutSessionCompleted && checkoutSession.PaymentStatus == "unpaid")
{
UpdateOrderToAuthorized(order, checkoutSession.PaymentIntentId);
}
Similarly, if the checkout session expired, we can use our earlier method to set the order to the Failed status.
else if (stripeEvent.Type == Events.CheckoutSessionExpired)
{
UpdateOrderToFailed(order);
}
Lastly, we can log an error if the event turns out not to fit any of the supported types and update the order.
else
{
eventLogService.LogEvent(EventTypeEnum.Error, "Stripe", localizationService.GetString("custom.stripe.error.unsupportedeventtype"), stripeEvent.Type);
}
order.Update();
The result should be the following.
protected virtual void UpdateOrderFromCheckoutSession(Session checkoutSession, OrderInfo order, Event stripeEvent)
{
if (checkoutSession != null && order != null && stripeEvent != null)
{
order.OrderCustomData.SetValue(XperienceStripeConstants.CHECKOUT_ID_KEY, checkoutSession.Id);
if ((stripeEvent.Type == Events.CheckoutSessionCompleted && checkoutSession.PaymentStatus == "paid"))
{
UpdateOrderToPaid(order);
}
else if (stripeEvent.Type == Events.CheckoutSessionCompleted && checkoutSession.PaymentStatus == "unpaid")
{
UpdateOrderToAuthorized(order, checkoutSession.PaymentIntentId);
}
else if (stripeEvent.Type == Events.CheckoutSessionExpired)
{
UpdateOrderToFailed(order);
}
else
{
eventLogService.LogEvent(EventTypeEnum.Error, "Stripe", localizationService.GetString("custom.stripe.error.unsupportedeventtype"), stripeEvent.Type);
}
order.Update();
}
}
Starting with the parameters provided by the event, there’s still a bit of setup we need to do before we can use this method.
Create a new method taking an Event as the parameter.
Protected virtual void UpdateOrderFromCheckoutSessioEvent(Event stripeEvent)
We need to parse a Session (checkout session) from the provided Event’s Data.Object property, so let’s validate that each of these pieces are not null, logging an error if necessary. Then we can use the as operator to get a Session object, and similarly validate it.
if(stripeEvent != null && stripeEvent.Data != null && stripeEvent.Data.Object != null)
{
var checkoutSession = stripeEvent.Data.Object as Session;
if (checkoutSession != null)
{
//…
}
else
{
eventLogService.LogEvent(EventTypeEnum.Error, "Stripe", localizationService.GetString("custom.stripe.error.nocheckoutsessioninevent"));
}
}
else
{
eventLogService.LogEvent(EventTypeEnum.Error, "Stripe", localizationService.GetString("custom.stripe.error.noobjectinevent"));
}
The ClientReferenceId in Stripe is not a required field, so check whether it is filled in, before using it to get the order. Log an error if it is absent.
if (!string.IsNullOrEmpty(checkoutSession.ClientReferenceId))
{
var order = GetOrderFromCheckoutSession(checkoutSession.ClientReferenceId);
//…
}
else
{
eventLogService.LogEvent(EventTypeEnum.Error, "Stripe", localizationService.GetString("custom.stripe.error.clientreferenceidempty"), $"Session.Id: {checkoutSession.Id} \r\nPaymentIntentId: {checkoutSession.PaymentIntentId}");
}
Then, if the order is not null, call the UpdateOrderFromCheckoutSession method, passing along the Session, OrderInfo, and Event. Log an error if it is null.
if (order != null)
{
UpdateOrderFromCheckoutSession(checkoutSession, order, stripeEvent);
}
else
{
eventLogService.LogEvent(EventTypeEnum.Error, "Stripe", localizationService.GetString("custom.stripe.error.orderNotFound"), $"OrderID: {checkoutSession.ClientReferenceId} \r\nPaymentIntentId: {checkoutSession.PaymentIntentId}");
}
When you’re done, the result should look like this.
protected virtual void UpdateOrderFromCheckoutSessionEvent(Event stripeEvent)
{
if(stripeEvent != null && stripeEvent.Data != null && stripeEvent.Data.Object != null)
{
var checkoutSession = stripeEvent.Data.Object as Session;
if (checkoutSession != null)
{
if (!string.IsNullOrEmpty(checkoutSession.ClientReferenceId))
{
var order = GetOrderFromCheckoutSession(checkoutSession.ClientReferenceId);
if (order != null)
{
UpdateOrderFromCheckoutSession(checkoutSession, order, stripeEvent);
}
else
{
eventLogService.LogEvent(EventTypeEnum.Error, "Stripe", localizationService.GetString("custom.stripe.error.orderNotFound"), $"OrderID: {checkoutSession.ClientReferenceId} \r\nPaymentIntentId: {checkoutSession.PaymentIntentId}");
}
}
else
{
eventLogService.LogEvent(EventTypeEnum.Error, "Stripe", localizationService.GetString("custom.stripe.error.clientreferenceidempty"), $"Session.Id: {checkoutSession.Id} \r\nPaymentIntentId: {checkoutSession.PaymentIntentId}");
}
}
else
{
eventLogService.LogEvent(EventTypeEnum.Error, "Stripe", localizationService.GetString("custom.stripe.error.nocheckoutsessioninevent"));
}
}
else
{
eventLogService.LogEvent(EventTypeEnum.Error, "Stripe", localizationService.GetString("custom.stripe.error.noobjectinevent"));
}
}
The analogous process for Payment Intents requires less setup and can fit into a single method.
protected virtual void UpdateOrderFromPaymentIntent(Event stripeEvent)
Start by taking the Event as a parameter once more, and mirror the previous steps of validating and casting it to a PaymentIntent.
if(stripeEvent != null && stripeEvent.Data != null && stripeEvent.Data.Object != null)
{
var paymentIntent = stripeEvent.Data.Object as PaymentIntent;
if (paymentIntent != null)
{
//…
}
else
{
eventLogService.LogEvent(EventTypeEnum.Error, "Stripe", localizationService.GetString("custom.stripe.error.nopaymentintentinevent"));
}
}
else
{
eventLogService.LogEvent(EventTypeEnum.Error, "Stripe", localizationService.GetString("custom.stripe.error.noobjectinevent"));
}
Get the order using the GetOrderFromPaymentIntent method from earlier.
var order = GetOrderFromPaymentIntent(paymentIntent.Id);
If the order can be found, set its status based on whether the payment intent indicates success or failure, and log an error if it indicates an unsupported event.
if (order != null)
{
if (stripeEvent.Type == Events.PaymentIntentSucceeded)
{
UpdateOrderToPaid(order);
}
else if (stripeEvent.Type == Events.PaymentIntentPaymentFailed)
{
UpdateOrderToFailed(order);
}
else
{
eventLogService.LogEvent(EventTypeEnum.Error, "Stripe", localizationService.GetString("custom.stripe.error.unsupportedeventtype"), stripeEvent.Type);
}
//…
}
Lastly, update the order with any changes that may have occurred.
order.Update();
This should leave you with a method that looks like the following.
protected virtual void UpdateOrderFromPaymentIntent(Event stripeEvent)
{
if(stripeEvent != null && stripeEvent.Data != null && stripeEvent.Data.Object != null)
{
var paymentIntent = stripeEvent.Data.Object as PaymentIntent;
if (paymentIntent != null)
{
var order = GetOrderFromPaymentIntent(paymentIntent.Id);
if (order != null)
{
if (stripeEvent.Type == Events.PaymentIntentSucceeded)
{
UpdateOrderToPaid(order);
}
else if (stripeEvent.Type == Events.PaymentIntentPaymentFailed)
{
UpdateOrderToFailed(order);
}
else
{
eventLogService.LogEvent(EventTypeEnum.Error, "Stripe", localizationService.GetString("custom.stripe.error.unsupportedeventtype"), stripeEvent.Type);
}
order.Update();
}
}
else
{
eventLogService.LogEvent(EventTypeEnum.Error, "Stripe", localizationService.GetString("custom.stripe.error.nopaymentintentinevent"));
}
}
else
{
eventLogService.LogEvent(EventTypeEnum.Error, "Stripe", localizationService.GetString("custom.stripe.error.noobjectinevent"));
}
}
We also need a method to get a Stripe event based on the request posted to our endpoint, in order to supply the Event parameter of the methods we’ve just created.
Stripe has a utility method to construct an event that takes a specific header value, along with the JSON body of the request and the webhook secret key as parameters. Let’s have our method accept the same parameters with one caveat— take the header value as an enumerable set of strings so that it can accept the out-parameter value of the HttpRequestMessage method TryGetValues.
protected virtual Event GetStripeEvent(IEnumerable<string> headerValues, string json, string webhookSecret)
First, extract the Stripe header from the enumerable parameter, and create a null Event object.
var signatureHeader = headerValues.FirstOrDefault();
Event stripeEvent = null;
Verify that the signature header was retrieved before passing it along.
if (!string.IsNullOrEmpty(signatureHeader))
{
//…
}
Now, try to populate this Event using Stripe’s utility method and log an error if it fails.
try
{
//this both creates the event object and checks if the secret key matches
stripeEvent = EventUtility.ConstructEvent(json, signatureHeader, webhookSecret);
}
catch (StripeException ex)
{
eventLogService.LogEvent(EventTypeEnum.Error, "Stripe", localizationService.GetString("custom.stripe.error.noevent"), ex.Message + "\r\n" + ex.StackTrace);
}
Finally, return the Event, outside of the conditional.
return stripeEvent;
The resulting method should look like the following.
protected virtual Event GetStripeEvent(IEnumerable<string> headerValues, string json, string webhookSecret)
{
var signatureHeader = headerValues.FirstOrDefault();
Event stripeEvent = null;
if (!string.IsNullOrEmpty(signatureHeader))
{
try
{
//this both creates the event object and checks if the secret key matches
stripeEvent = EventUtility.ConstructEvent(json, signatureHeader, webhookSecret);
}
catch (StripeException ex)
{
eventLogService.LogEvent(EventTypeEnum.Error, "Stripe", localizationService.GetString("custom.stripe.error.noevent"), ex.Message + "\r\n" + ex.StackTrace);
}
}
return stripeEvent;
}
Now we should have everything the endpoint will need, so let’s add it.
Define an asynchronous method that returns a Task for an HttpResponseMessage, and decorate it with the HttpPost attribute.
[HttpPost]
public virtual async Task<HttpResponseMessage> Update()
Collect the JSON from the request body using the StreamReader’s ReadToEndAsync method and pull the CustomStripeWebhookSecret setting from the web.config of the admin app.
var json = await new StreamReader(HttpContext.Current.Request.InputStream, HttpContext.Current.Request.ContentEncoding).ReadToEndAsync();
var webhookSecret = appSettingsService["CustomStripeWebhookSecret"];
If the setting was successfully retrieved, get the Stripe-Signature header from the request, logging errors if either of these pieces can’t be found.
if (!string.IsNullOrEmpty(webhookSecret))
{
if (Request.Headers.TryGetValues("Stripe-Signature", out var values))
{
//…
}
else
{
eventLogService.LogEvent(EventTypeEnum.Error, "Stripe", localizationService.GetString("custom.stripe.error.signaturenotfound"));
}
}
else
{
eventLogService.LogEvent(EventTypeEnum.Error, "Stripe", localizationService.GetString("custom.stripe.error.webhooksecretmissing"));
}
Using the Stripe-Signature value, along with the JSON object and the settings key, get an Event object from Stripe via the GetStripeEvent method from earlier.
var stripeEvent = GetStripeEvent(values, json, webhookSecret);
After validating the Data.Object property of the event, evaluate whether the event is for a Payment Intent or a Checkout Session and call the appropriate Update… method, logging an error if it fits neither category.
if(stripeEvent != null && stripeEvent.Data != null && stripeEvent.Data.Object != null)
{
if (stripeEvent.Data.Object.Object == "checkout.session")
{
UpdateOrderFromCheckoutSessionEvent(stripeEvent);
}
else if (stripeEvent.Data.Object.Object == "payment_intent")
{
UpdateOrderFromPaymentIntent(stripeEvent);
}
else
{
eventLogService.LogEvent(EventTypeEnum.Error, "Stripe", localizationService.GetString("custom.stripe.error.unsupportedobjecttype"), stripeEvent.Data.Object.Object);
}
}
Finally, return a 200 OK status code, to let Stripe know that the request was received.
return Request.CreateResponse(HttpStatusCode.OK);
In the end, it should look like this.
[HttpPost]
public virtual async Task<HttpResponseMessage> Update()
{
var json = await new StreamReader(HttpContext.Current.Request.InputStream, HttpContext.Current.Request.ContentEncoding).ReadToEndAsync();
var webhookSecret = appSettingsService["CustomStripeWebhookSecret"];
if (!string.IsNullOrEmpty(webhookSecret))
{
if (Request.Headers.TryGetValues("Stripe-Signature", out var values))
{
var stripeEvent = GetStripeEvent(values, json, webhookSecret);
if(stripeEvent != null && stripeEvent.Data != null && stripeEvent.Data.Object != null)
{
if (stripeEvent.Data.Object.Object == "checkout.session")
{
UpdateOrderFromCheckoutSessionEvent(stripeEvent);
}
else if (stripeEvent.Data.Object.Object == "payment_intent")
{
UpdateOrderFromPaymentIntent(stripeEvent);
}
else
{
eventLogService.LogEvent(EventTypeEnum.Error, "Stripe", localizationService.GetString("custom.stripe.error.unsupportedobjecttype"), stripeEvent.Data.Object.Object);
}
}
}
else
{
eventLogService.LogEvent(EventTypeEnum.Error, "Stripe", localizationService.GetString("custom.stripe.error.signaturenotfound"));
}
}
else
{
eventLogService.LogEvent(EventTypeEnum.Error, "Stripe", localizationService.GetString("custom.stripe.error.webhooksecretmissing"));
}
return Request.CreateResponse(HttpStatusCode.OK);
}
Registering a Web API module
Now that the controller is in place, we need to register it.
In the Custom.Xperience.Stripe.Admin project, open AssemblyInfo.cs, located under Properties in the solution explorer. Add a using directive for CMS, and add the AssemblyDiscoverable attribute.
using CMS;
[assembly: AssemblyDiscoverable]
Now, add a new folder called ModuleRegistration to the project, containing a class XperienceStripeEndpointModule.
Add the following using directives.
using CMS;
using CMS.DataEngine;
using Custom.Xperience.Stripe.Endpoint;
using System.Web.Http;
Add the RegisterModule assembly attribute, passing the type of your class as the parameter. Use the namespace Custom.Xperience.Stripe.Endpiont for the class, and have it extend Module.
[assembly: RegisterModule(typeof(XperienceStripeEndpointModule))]
namespace Custom.Xperience.Stripe.Endpoint
{
public class XperienceStripeEndpointModule : Module
{
//…
}
}
Have the constructor call the base constructor, passing a name for your module, XperienceStripeEndpoint, as the parameter.
public XperienceStripeEndpointModule() : base("XperienceStripeEndpoint")
{
}
Override the OnInit method, and call base. Then map a route to the StripeController’s Update action.
protected override void OnInit()
{
base.OnInit();
//Map route to endpoint.
GlobalConfiguration.Configuration.Routes.MapHttpRoute(
"xperience-stripe",
"xperience-stripe/updateorder",
defaults: new { controller = "Stripe", action = "Update" }
);
}
What’s next
The next article will cover the implementation of a helper class, which can capture approved payments and a global event handler which automatically captures payments when an order enters a specific status.