Let’s say we’re building a brand new HTTP API that supports webhooks, and we want the best for our ASP.NET consumers. We should document elements like endpoints, payloads, authentication, and responses our API expects from consumers. Documentation can be overwhelming to the brightest of us, especially for developers under the added pressure of deadline crunch.

In this post, we’ll use the full power of ASP.NET to help push webhook developers into the pit of success. For our API consumers, our approach will be plug and play. The process will also allow us to make invisible some of the repetitive tasks that come with implementing webhooks.

What’s A Webhook?

A webhook is a loose specification used by HTTP API developers to give consumers an ability to receive payloads when an event occurs within the API boundary. GitHub uses the webhook approach to inform consumers of events around issues, labels, releases as examples. We say loose specification because the implementation can vary between each API vendor, but there usually are some overlapping features between each.

  1. The payload arrives over HTTP using POST.
  2. The HTTP payload is JSON.
  3. The HTTP request has security headers, normally a hex digest or a hashed string using a secret.
  4. The API expects either a response or successful HTTP status code ranging in the 2xx values.

Expected differences between Webhooks implementations:

  • Different header names that are specific to the API.
  • A set of distinct payloads that are specific to the API.
  • The number of webhook endpoints, anywhere from one endpoint handling all requests, to each event having an endpoint.

Roughly knowing the elements of a webhooks implementation lets us think about building a set of classes that abstract away the tedious parts of building a receiving webhook endpoint.

Leveraging ASP.NET Core

Controllers

Since ASP.NET Core, the conventions around what constitutes a controller has become very squishy. No longer do we need to inherit from Controller or ControllerBase. We can take advantage of this change by using many new constructs to turn Plain Old C# (POCO) objects into HTTP-request-handling brutes.

Before we dive into the code, let’s go over some of the goals we’d like to accomplish for our webhook-implementing developers.

  1. A straightforward implementation of payload handlers.
  2. JSON serialization and deserialization of payloads.
  3. Minimal configuration in an existing ASP.NET application.
  4. The “hard parts” are handled: authentication, routing, validation, etc.
  5. The ability to implement response-based endpoints and one-way endpoints.

The ASP.NET Core Defaults

Luckily for us, many of the webhook features we want to utilize ship with ASP.NET. Let’s talk about the defaults we’ll be using.

  • Model Binding: JSON is the default communication mechanism for all controllers; let’s lean into using it.
  • Controller registrations occur with a call to AddControllers, along with routes.
  • We can accomplish a lot through ActionFilterAttribute implementations, including validation, method constraints, and routing.
  • Route constraints for matching routes based on request payloads.

Let’s look at our webhook handler implementation.

The Base Classes

Let’s look at our pit-of-success-driving base class. Be warned, it might look messy, but we, as webhook framework builders, will be the only ones seeing this.

/// <summary>
/// Base Class For All Handlers
/// </summary>
/// <typeparam name="TRequest"></typeparam>
[ApiController, Route("{prefix:webhookRoutePrefix}/[controller]")]
public abstract class ResponseHandler<TRequest, TResponse>
{
    [HttpPost, Route("")]
    public abstract Task<TResponse> Handle([FromBody] TRequest request);
}

It doesn’t look like much, but let’s walk through some of the cool parts.

  1. ASP.NET sees this class as a controller from the decorating ApiControllerAttribute.
  2. We use explicit routing utilizing the RouteAttribute and placeholders. Later, we’ll see how we can give users configurable routing through route constraints.
  3. The Handle method assumes a payload constrained by the generic type of TRequest and deserialized by ASP.NET using FromBody.

As webhook framework providers, we can continue to constrain functionality and add more invisible features using attributes on our base class. Some additional features specific to an API might include:

  • Payload validation.
  • Payload routing based on Types.
  • Http Status responses based on handler type.
  • Response cache headers based on options.

Here is another handler type that will return a status code of 204 Accepted, which is useful in scenarios where we queue payloads to be processed later.

[ApiController, Route("{prefix:webhookRoutePrefix}/[controller]")]
public abstract class AcceptedHandler<TRequest>
{
    [HttpPost, Route(""), Status(HttpStatusCode.Accepted)]
    public abstract Task Handle([FromBody] TRequest request);
}

public class Status : ActionFilterAttribute
{
    private readonly HttpStatusCode statusCode;

    public Status(HttpStatusCode statusCode)
    {
        this.statusCode = statusCode;
    }

    public override void OnActionExecuted(ActionExecutedContext context)
    {
        context.Result = new StatusCodeResult((int) statusCode);
    }
}

Note that we added functionality to our handler by implementing another ActionFilterAttribute. We can add and order as many attributes as necessary to meet the requirements set forth by our webhook API.

Route Constraints

Routing is “THE” essential part of any HTTP based application, so we must route our request to the right handler. We can accomplish this using route constraints. In this example, we assume all of our webhooks will have a pattern of /webhooks/{handler name}. Remember, webhook APIs can differ, so adjust this approach and the route constraints accordingly. We accomplish routing using two Route attributes: one on the class, and one on the Handle method.

[ApiController, Route("{prefix:webhookRoutePrefix}/[controller]")]
public abstract class ResponseHandler<TRequest, TResponse>
{
    [HttpPost, Route("")]
    public abstract Task<TResponse> Handle([FromBody] TRequest request);
}

We also utilize the [controler] placeholder to lift the name of the handler class.

To give our users the ability to alter the registered path to our webhooks, we need to create the ability to set the route prefix during startup. We do this by providing a WebhookOptions class, along with registering our WebHookRoutePrefixConstraint.

public class WebhookOptions
{
    public string RoutePrefix { get; set; } = "webhooks";
}

public static IServiceCollection AddWebhooks(
    this IServiceCollection services, 
    Action<WebhookOptions> spaceAction = null)
{
    var options = new WebhookOptions();
    
    services.Configure<RouteOptions>(opt =>
    {
        opt.ConstraintMap.Add("webhookRoutePrefix", typeof(WebhookRoutePrefixConstraint));
    });
    spaceAction?.Invoke(options);
    services.AddSingleton(options);

    return services;
}

The route prefix constraint implementation is straightforward. We can alter this constraint to look at more request values like headers, route data, and route direction, to improve matches and route to correct handlers.

public class WebhookRoutePrefixConstraint : IRouteConstraint
{
    public bool Match(HttpContext? httpContext, IRouter route, string routeKey, RouteValueDictionary values,
        RouteDirection routeDirection)
    {
        if (values.TryGetValue("prefix", out var value) && value is string actual)
        {
            var options = (WebhookOptions) httpContext?
                .RequestServices
                .GetService(typeof(WebhookOptions));
            // urls are case sensitive
            var expected = options?.RoutePrefix;
            return expected == actual;
        }
        return false;
    }
}

In the WebhookRoutePrefixConstraint, we look into our ServiceCollection instance and pull the webhook options. The WebhookOptions class allows users some configuration and sets more critical values like the secret hash key (not seen in this example).

public void ConfigureServices(IServiceCollection services)
{
    services.AddControllers();
    services.AddLogging();
    // add webhooks
    services.AddWebhooks(opt =>
    {
       // default is "webhooks"
        opt.RoutePrefix = "wh";
    });
}

Controller Registration

By default conventions, ASP.NET will scan for types that we decorate with ApiController and register them as controller types. We only need to ensure two calls occur in our Startup class.

  1. services.AddControllers in ConfigureServices.
  2. endpoints.MapControllers in UseEndpoints within Configure.

Start writing webhook handlers, and ASP.NET will wire them up for you.

Writing Handlers

We’ve walked through how to set up a webhooks framework for consumers, but what does implementation look like for an everyday developer who wants to get work done?

public class Hello : ResponseHandler<HelloRequest, HelloResponse>
{
    public async override Task<HelloResponse> Handle(HelloRequest request)
    {
        return new HelloResponse {
            Greeting = $"Hello, {request.Name}"
        };
    }
}

Wow, so minimal. There are several assumptions here, and we need to address them.

  1. The handler expects a HelloRequest and returns a HelloResponse. One model in, one model out. The method signature can be different based on the webhook implementation developers are programming against, and we should adjust accordingly.
  2. JSON in and out. The ASP.NET host handles content types.
  3. Async always, even when in this case, we don’t need it.

Running our request through an HTTP test client, we can see the response.

POST https://localhost:5001/webhooks/hello

HTTP/1.1 200 OK
Date: Wed, 02 Sep 2020 15:35:23 GMT
Content-Type: application/json; charset=utf-8
Server: Kestrel
Transfer-Encoding: chunked

{
  "greeting": "Hello, Khalid"
}

Response code: 200 (OK); Time: 291ms; Content length: 28 bytes

What does the implementation of our AcceptedHandler look like?

public class Yep : AcceptedHandler<HelloRequest>
{
    private readonly ILogger<Yep> logger;

    public Yep(ILogger<Yep> logger)
    {
        this.logger = logger;
    }
    
    public override Task Handle(HelloRequest request)
    {
        this.logger.LogInformation($"Hello, {request.Name}");
        return Task.CompletedTask;
    }
}

Here we take in an instance of Ilogger<Yep> to log the request.

POST https://localhost:5001/webhooks/yep

HTTP/1.1 202 Accepted
Date: Wed, 02 Sep 2020 15:42:39 GMT
Server: Kestrel
Content-Length: 0

<Response body is empty>

Response code: 202 (Accepted); Time: 185ms; Content length: 0 bytes

And we see the log message in our console output.

info: Endpointz.Yep[0]
      Hello, Khalid

Awesome!

Downsides

Like all things in life, there are downsides to this approach that folks should consider when applying to their codebase.

  1. It is rigid on purpose. Handling webhooks is a request/response process. Folks won’t be able to redirect or alter the HttpContext easily. At that point, you might as well be using a full-blown controller.
  2. Too much abstraction can feel like magic.
  3. Decent investment upfront in the pattern and base classes.
  4. Relying on a few thinkers means fewer folks are thinking. Whoever is designing the infrastructure may miss a vital part of the implementation.

Other Approaches

The approach we’ve seen can be a home-rolled solution, deployed to anyone implementing webhook handlers, but there are other approaches we may want to consider.

  1. Build a complete pipeline ourselves via Middleware implementations. Custom middleware has the benefit of not using MVC and the burden of reimplementing elements like model-binding and request/response handling.
  2. Using other HTTP frameworks like ApiEndpoints, Carter, or Jasper. The drawback of these APIs is that they expose the HTTP stack and get your webhook handler implementers in “trouble”.
  3. Forgo the ceremony, and trust your developers to do the right thing. The most flexible approach, but refactoring and reimplementing attributes and remembering to place them can be frustrating over time. Some advice may be better than no direction at all.

Conclusion

In this post, we took advantage of the mechanisms already shipping with ASP.NET to support an opinionated approach to developing webhook handlers. As mentioned at the beginning of the post, the webhook specification is a loose one, and many implementations will differ.

Understanding what we can accomplish with a set of base-classes and strategically placed attributes can help us guide webhook implementors into the pit of success. By pushing more set up down into a base class, we can help developers focus on handling payloads without the tedious steps necessary to receive said payloads.

To roll a custom webhooks framework, remember these elements:

  • Abstract base classes help constrain method signatures.
  • Attributes and ActionFilters.
  • Route constraints and RouteOptions.
  • Anything can be a controller.
  • ASP.NET looks at inherited attributes.

At the very least, I hope this post has you thinking about ways to help other developers implement successful webhook handlers to your APIs.

If you use this approach, please let me know in the comments. I love hearing from readers, so don’t be shy.