Article

Capturing a Screenshot of a Webpage in ASP.NET Core

·
Ivan Kahl
·
26 min read

There are many reasons you, as a developer, might want to screenshot a webpage. For example, you may want to take regular screenshots of a website for monitoring or compliance purposes, generate images from dynamic HTML, CSS, and SVG, or create image previews of URLs for social media or your own directory of links.

When you deal with webpage screenshots, it's common to think you need to use JavaScript to interact with a web page to screenshot it. For example, you might think of writing a Node.js service that will accept REST requests for screenshots, process them, and return the screenshot. Granted, this is a possible solution, but it isn't straightforward!

This article will show you how to take screenshots of webpages in an ASP.NET Core application without JavaScript. First, you'll set up a Reading List Web API. Then, you'll see how you can use different NuGet packages to take screenshots of webpages from an ASP.NET Core application. You'll also learn to use the Urlbox library to take screenshots asynchronously.

Prerequisites

The code samples in this article are compatible with .NET 6. .NET 6 is the latest LTS version of .NET from Microsoft, which lets you write performant, cross-platform applications using a single codebase. Please download the .NET 6 SDK to follow along with the code samples.

Many code samples use Google Chrome to render and screenshot the web pages. While most libraries automatically download Chrome, it's best to manually install it before starting so that you have all the necessary dependencies to run the browser from code.

This tutorial also requires PowerShell 7.2 for some of the NuGet package setup scripts. PowerShell is a cross-platform task automation solution developed by Microsoft. You can download the latest version of PowerShell for your platform here.

You can find all the completed projects from this tutorial in this GitHub repository.

Setting Up the Project

To demonstrate taking screenshots in an ASP.NET Core application, you'll build a basic Reading List Web API. The API will let you save and retrieve URLs in your reading list. You'll also see how you can take screenshots of those URLs for link previews on your user interface.

You can download the starter code from this GitHub repository if you want to skip to taking screenshots in ASP.NET Core.

Create the Project

Open a new terminal window and navigate to the folder where you want to store your code. Next, run the following command to create a new ASP.NET Core Web API using the .NET CLI.

dotnet new webapi -o ReadingListApi

Write the IScreenshotService Interface

Interfaces make it easy to describe method headers without implementing them. You'll write an IScreenshotService interface to define the methods available to screenshot a web page. This interface will make it easy to update the application to use the different screenshot service implementations presented in this article.

Create a Services folder in the project directory. Then, create a Screenshot subdirectory in that folder. Next, create the file IScreenshotService.cs and paste in the code below:

IScreenshotService.cs

namespace ReadingListApi.Services.Screenshot
{
    public interface IScreenshotService
    {
        Task<byte[]> ScreenshotUrlAsync(string url); 
    }
}

Create the IReadingListService Interface and Implement It

It's best practice to keep controller methods lightweight. To do this, you'll implement the Reading List API's logic in a service.

In the Services folder, create a ReadingList folder. Next, you'll make the model classes and service interface and class. Create Models and DTO (Data Transfer Object) folders inside the ReadingList folder. Inside the Models folder, create a ReadingItemModel class, which will store the URL in the database.

Create the file ReadingItemModel.cs with the following content:

namespace ReadingListApi.Services.ReadingList.Models
{
    public class ReadingItemModel
    {
        public Guid Id { get; set; }
        public string Title { get; set; }
        public DateTime Reminder { get; set; }
        public string Url { get; set; }
        public bool ScreenshotTaken { get; set; }
    }
}

Next, you'll create data transfer objects that you'll use for requests and responses in the REST API. First, create a ReadingItemDTO.cs file in the DTO folder. This DTO will return the details for an item in the database. Then, copy the following code into the new file named ReadingItemDTO.cs:

namespace ReadingListApi.Services.ReadingList.DTO
{
    public class ReadingItemDTO
    {
        public Guid Id { get; set; }
        public string Title { get; set; }
        public DateTime Reminder { get; set; }
        public string Url { get; set; }
        public bool ScreenshotTaken { get; set; }
    }
}

Make a new file in the DTO folder and call it ReadingItemCreateDTO.cs. This DTO will be the request object for creating a new item in the database. You can paste the following code into the file:

namespace ReadingListApi.Services.ReadingList.DTO
{
    public class ReadingItemCreateDTO
    {
        public string Title { get; set; }
        public DateTime Reminder { get; set; }
        public string Url { get; set; }
    }
}

Once you've created the necessary model and DTO objects, you'll need to write the service interface and implement it. To do this, create the file IReadingListService.cs in the ReadingList folder and copy the following code into it:

using ReadingListApi.Services.ReadingList.DTO;
 
namespace ReadingListApi.Services.ReadingList
{
    public interface IReadingListService
    {
        Task<Guid> CreateReadingItemAsync(ReadingItemCreateDTO readingItem);
        Task<byte[]?> GetReadingItemScreenshotAsync(Guid id);
        Task<IEnumerable<ReadingItemDTO>> ListReadingItemsAsync();
    }
}

Once you've created the interface, implement it by creating a ReadingListService.cs file and copying the following class code into it:

using ReadingListApi.Services.ReadingList.DTO;
using ReadingListApi.Services.ReadingList.Models;
using ReadingListApi.Services.Screenshot;
 
namespace ReadingListApi.Services.ReadingList
{
    public class ReadingListService : IReadingListService
    {
        private readonly List<ReadingItemModel> _readingItems = new();
 
        private readonly IScreenshotService _screenshotService;
        private readonly IConfiguration _configuration;
 
        public ReadingListService(IScreenshotService screenshotService, IConfiguration configuration)
        {
            _screenshotService = screenshotService;
            _configuration = configuration;
        }
 
        public async Task<Guid> CreateReadingItemAsync(ReadingItemCreateDTO readingItem)
        {
            // Create a new model for the reading item
            var model = new ReadingItemModel()
            {
                Id = Guid.NewGuid(),
                Reminder = readingItem.Reminder,
                Title = readingItem.Title,
                Url = readingItem.Url
            };
 
            // Determine where to save the screenshot
            var fileName = $"{model.Id}.png";
            var fullFilePath = Path.Combine(_configuration["ScreenshotsFolder"], fileName);
 
            // Take the screenshot
            var screenshotBytes = await _screenshotService.ScreenshotUrlAsync(readingItem.Url);
            await File.WriteAllBytesAsync(fullFilePath, screenshotBytes);
 
            // Update our model with the file name
            model.ScreenshotTaken = true;
 
            // Add the model to our "database"
            _readingItems.Add(model);
 
            return model.Id;
        }
 
        public async Task<byte[]?> GetReadingItemScreenshotAsync(Guid id)
        {
            // Try to get the item from the database
            var item = _readingItems.FirstOrDefault(x => x.Id == id);
 
            // Make sure we have selected a reading item record and that
            // a screenshot was taken. If no item was found or the screenshot
            // has not been taken, return null.
            if (item == null || item.ScreenshotTaken == false)
                return null;
 
            // Retrieve the screenshot and return it as a byte array.
            var fullFilePath = Path.Combine(_configuration["ScreenshotsFolder"], $"{item.Id}.png");
            return await File.ReadAllBytesAsync(fullFilePath);
        }
 
        public Task<IEnumerable<ReadingItemDTO>> ListReadingItemsAsync()
        {
            // Return all the reading items in the database
            return Task.FromResult(_readingItems.Select(x => new ReadingItemDTO() 
            {
                Id = x.Id,
                Reminder = x.Reminder,
                Title = x.Title,
                Url = x.Url,
                ScreenshotTaken = x.ScreenshotTaken
            }));
        }
    }
}

Finally, register the ReadingListService implementation in the Program.cs file by adding this line after creating the builder variable. Further down in the same file, add code to create the screenshots folder if it doesn't already exist:

// ...
 
var builder = WebApplication.CreateBuilder(args);
 
// Add services to the container
builder.Services.AddSingleton<IReadingListService, ReadingListService>();
 
// ...
 
app.UseHttpsRedirection();
 
Directory.CreateDirectory(app.Configuration["ScreenshotsFolder"]);
 
// ...

Make sure you configure the ScreenshotsFolder in the appsettings.json file:

{
  // ..
  "ScreenshotsFolder": "<PATH TO SCREENSHOTS DIRECTORY>"
}

Set Up the ReadingListController

The last scaffolding step is to set up the ReadingListController class. This class will expose the actual endpoints in the REST API. In the Controllers folder, create a ReadingListController.cs file and paste in the following code:

using Microsoft.AspNetCore.Mvc;
using ReadingListApi.Services.ReadingList;
using ReadingListApi.Services.ReadingList.DTO;
 
namespace ReadingListApi.Controllers
{
    [ApiController]
    [Route("[controller]")]
    public class ReadingListController : ControllerBase
    {
        private readonly ILogger<ReadingListController> _logger;
        private readonly IReadingListService _readingListService;
 
        public ReadingListController(ILogger<ReadingListController> logger, IReadingListService readingListService)
        {
            _logger = logger;
            _readingListService = readingListService;
        }
 
        [HttpPost]
        public async Task<IActionResult> CreateReadingItemAsync(ReadingItemCreateDTO readingItem)
        {
            try
            {
                return Ok(await _readingListService.CreateReadingItemAsync(readingItem));
            }
            catch (Exception ex)
            {
                _logger.LogError("An error occurred while creating a reading item: {Exception}", new { Exception = ex });
                return StatusCode(StatusCodes.Status500InternalServerError, "An error occurred while creating a reading item");
            }
        }
 
        [HttpGet]
        public async Task<IActionResult> ListReadingItemsAsync()
        {
            try
            {
                return Ok(await _readingListService.ListReadingItemsAsync());
            }
            catch (Exception ex)
            {
                _logger.LogError("An error occurred while listing all the reading items: {Exception}", new { Exception = ex });
                return StatusCode(StatusCodes.Status500InternalServerError, "An error occurred while listing all the reading items");
            }
        }
 
        [HttpGet("{id}/screenshot")]
        public async Task<IActionResult> GetScreenshotAsync(Guid id)
        {
            try
            {
                var file = await _readingListService.GetReadingItemScreenshotAsync(id);
                
                if (file == null)
                    return NotFound();
 
                return File(file, "image/png");
            }
            catch (Exception ex)
            {
                _logger.LogError("An error occurred while retrieving the screenshot file: {Exception}", new { Exception = ex });
                return StatusCode(StatusCodes.Status500InternalServerError, "An error occurred while retrieving the screenshot file");
            }
        }
    }
}

You are now ready to start implementing screenshot functionality in your application.

Using PuppeteerSharp

The PuppeteerSharp NuGet package is a .NET port of the popular Node.js Puppeteer API. The package lets you use Google Chrome programmatically for various automation tasks, such as taking screenshots.

With a terminal open in the project folder, run the following command to install the package:

dotnet add package PuppeteerSharp --version 7.1.0

You'll now implement the IScreenshotService interface using the PuppeteerSharp package. First, create a file called PuppeteerSharpScreenshotService.cs in the Services > Screenshot folder. Then, copy and paste the following code into the file:

using PuppeteerSharp;
 
namespace ReadingListApi.Services.Screenshot
{
    public class PuppeteerSharpScreenshotService : IScreenshotService
    {
        public async Task<byte[]> ScreenshotUrlAsync(string url)
        {
            // First download the browser (this will only happen once)
            await DownloadBrowserAsync();
 
            // Start a new instance of Google Chrome in headless mode
            var browser = await Puppeteer.LaunchAsync(new LaunchOptions()
            {
                Headless = true,
                DefaultViewport = new ViewPortOptions() 
                {
                    Width = 1920,
                    Height = 1080
                }
            });
 
            // Create a new tab/page in the browser and navigate to the URL
            var page = await browser.NewPageAsync();
            await page.GoToAsync(url);
 
            // Screenshot the page and return the byte stream
            var bytes = await page.ScreenshotDataAsync();
 
            await browser.CloseAsync();
 
            return bytes;
        }
 
        private async Task DownloadBrowserAsync()
        {
            using var browserFetcher = new BrowserFetcher();
            await browserFetcher.DownloadAsync(BrowserFetcher.DefaultChromiumRevision);
        }
    }
}

The method above first downloads Google Chrome if it hasn't already been downloaded. It then creates a new browser instance with some launch options. Once the browser runs, the method creates a new page and navigates to the specified URL; when the page has loaded, the code takes a screenshot and closes the browser. Finally, the method returns the screenshot's raw bytes.

In the Program.cs file, register the PuppeteerSharpScreenshotService class so you can use it in the ReadingListService. Do this by adding the following line before the line that registers the ReadingListService class:

// ...
 
var builder = WebApplication.CreateBuilder(args);
 
// Add services to the container.
builder.Services.AddSingleton<IScreenshotService, PuppeteerSharpScreenshotService>();
builder.Services.AddSingleton<IReadingListService, ReadingListService>();
 
// ...

Start the application by running the following command in the terminal:

dotnet run

Once the project runs, open the Swagger page in a browser by going to https://localhost:<PORT>/swagger/index.html.

Add a new URL to your reading list on the Swagger page using the POST /ReadingList endpoint. The endpoint will save the object to the database, take a screenshot of the URL, and return the ID of the new reading item in the response.

Use the POST /ReadingList endpoint to add a new URL to your reading list

Copy the ID from the response object and use it in the GET /ReadingList/{id}/screenshot endpoint. This endpoint will retrieve the screenshot for the saved URL.

Use the GET /ReadingList/url/screenshot endpoint to retrieve the screenshot for the reading item

Below is a screenshot of the TechCrunch homepage taken with PuppeteerSharp:

The screenshot PuppeteerSharp took of the TechCrunch homepage

The screenshot is appropriately sized and portrays the web page accurately. However, one immediate eyesore is the advertisements on the page's top and right. There is also a banner at the bottom of the web page, which looks unappealing, and a scrollbar on the right side of the page.

Using Selenium

Selenium is a browser automation tool similar to PuppeteerSharp. However, unlike PuppeteerSharp, Selenium is compatible with several browser vendors, meaning you're not limited to using Chromium-based browsers. The Selenium WebDriver API enables the library to communicate with different browsers.

Run the following commands in the terminal. The first command installs the Selenium NuGet package. The second command installs a NuGet package that will assist in downloading the correct WebDriver to use with Chrome:

dotnet add package Selenium.WebDriver
dotnet add package WebDriverManager

Once you've installed the packages, create another class called SeleniumScreenshotService in the Services > Screenshot directory, and paste in the following code:

using OpenQA.Selenium.Chrome;
using OpenQA.Selenium;
using WebDriverManager;
using WebDriverManager.DriverConfigs.Impl;
using System.Drawing;
 
namespace ReadingListApi.Services.Screenshot
{
    public class SeleniumScreenshotService : IScreenshotService
    {
        public Task<byte[]> ScreenshotUrlAsync(string url)
        {
            // We need to download the correct driver for Chrome
            new DriverManager().SetUpDriver(new ChromeConfig());
 
            var options = new ChromeOptions();
            options.AddArgument("headless");
 
            // Use the driver to start a new instance of Google
            // Chrome
            var driver = new ChromeDriver(options);
 
            // Set the window size appropriately
            driver.Manage().Window.Size = new Size(1920, 1080);
 
            // Navigate to the specified URL
            driver.Navigate().GoToUrl(url);
 
            // Take a screenshot of the web page and return the 
            // image's raw bytes
            var screenshot = (driver as ITakesScreenshot).GetScreenshot();
            var bytes = screenshot.AsByteArray;
 
            driver.Close();
            driver.Quit();
 
            return Task.FromResult(bytes);
        }
    }
}

The code first downloads the correct WebDriver for Chrome using the WebDriverManager package. It then creates a new window in Chrome and navigates to the specified URL. Once the website has loaded, the method takes a screenshot and receives the image's raw bytes. Finally, the browser is closed, and the raw bytes are returned.

In the Program.cs file, replace the line that registered the PuppeteerSharpScreenshotService with the following:

// ...
 
var builder = WebApplication.CreateBuilder(args);
 
// Add services to the container.
builder.Services.AddSingleton<IScreenshotService, SeleniumScreenshotService>();
builder.Services.AddSingleton<IReadingListService, ReadingListService>();
 
// ...

Rerun the application using the following command:

dotnet run

Navigate to the Swagger page and follow the same process as before to save a new URL to the reading list and retrieve its screenshot. You should see an image similar to the one below if you saved the TechCrunch homepage:

The screenshot Selenium took of the TechCrunch homepage

The screenshot looks remarkably similar to the one taken with PuppeteerSharp. You'll notice that advertisements are still appearing on the page. The ugly scrollbar is also still visible, as is the distracting bottom banner.

Using Playwright

Playwright is a modern browser automation library developed by Microsoft. While it's designed primarily for end-to-end testing of web apps, you can also use it for browser automation tasks. Like Selenium, it supports multiple browser vendors with a single API.

In the terminal, run the following command to install the Playwright NuGet package:

dotnet add package Microsoft.Playwright

After adding the package to the project, Playwright must complete its setup with a PowerShell script. To do this, build the project. Once built, run the PowerShell script and wait for it to finish:

dotnet build
pwsh bin\Debug\net6.0\playwright.ps1 install

In the Services > Screenshot folder, create a file called PlaywrightScreenshotService.cs and paste the following code in there:

using Microsoft.Playwright;
 
namespace ReadingListApi.Services.Screenshot
{
    public class PlaywrightScreenshotService : IScreenshotService
    {
        public async Task<byte[]> ScreenshotUrlAsync(string url)
        {
            // Create a new instance of Playwright
            using var playwright = await Playwright.CreateAsync();
 
            // Open a new instance of the Google Chrome browser in headless mode
            await using var browser = await playwright.Chromium.LaunchAsync(new() { Headless = true });
 
            // Create a new page in the browser
            var page = await browser.NewPageAsync(new() 
            { 
                ViewportSize = new() { Width = 1920, Height = 1080 } 
            });
            await page.GotoAsync(url);
            
            // Screenshot the page and return the raw bytes
            return await page.ScreenshotAsync();
        }
    }
}

The Playwright implementation is the most straightforward code so far! The code first instantiates a new instance of Playwright. Next, the Playwright instance launches Chrome. Once the browser runs, a new page is created with the desired viewport dimensions and used to navigate to the specified URL. Finally, the method takes a screenshot and returns the raw bytes.

Update the Program.cs file to use the PlaywrightScreenshotService implementation of the IScreenshotService interface:

// ...
 
var builder = WebApplication.CreateBuilder(args);
 
// Add services to the container.
builder.Services.AddSingleton<IScreenshotService, PlaywrightScreenshotService>();
builder.Services.AddSingleton<IReadingListService, ReadingListService>();
 
// ...

Rerun the application:

dotnet run

Open Swagger and use the REST endpoints to save a URL and retrieve its screenshot. Below is an example of a screenshot of the TechCrunch homepage taken with Playwright.

The TechCrunch homepage screenshotted using Playwright

Once again, the screenshot looks similar to the previous screenshots. The advertisements and ugly scrollbar are still visible, and the banner on the bottom also still blocks portions of the page. While this is easier to implement, the final screenshot has the same flaws as the previous screenshots.

Using Urlbox

Urlbox is a service that specializes in taking web page screenshots. The service exposes a REST API that's simple yet incredibly flexible. Their service offering includes advanced functionality such as retina screenshots, web font, and emoji support, SVG support, ad blocking, and webhooks. They offer a seven-day free trial to try all their features, after which you can upgrade to one of their paid plans, which start at $10/month.

Sign up for a seven-day free trial. You can log in after confirming your email address. When you log in, you'll be taken to the Dashboard page, where you'll find your API Key and Secret. You'll need these later, so be sure to take note of them.

To interact with Urlbox, you will use the Urlbox .NET library. The library wraps the Urlbox REST API and makes it easy to interact with Urlbox from .NET code.

Download the Urlbox .NET GitHub repository, extract the ZIP file, and copy the Urlbox folder to your project folder. Once copied, run the following command to reference the Urlbox project from the ReadingListApi project:

dotnet add ReadingListApi.csproj reference Urlbox/Urlbox.csproj

Now that your application references the Urlbox library, you'll write a screenshot service implementation that uses Urlbox. To do this, create a UrlboxScreenshotService.cs file in the Services > Screenshot folder and paste in the following code:

using Screenshots;
 
namespace ReadingListApi.Services.Screenshot
{
    public class UrlboxScreenshotService : IScreenshotService
    {
        private readonly Urlbox _urlbox;
 
        public UrlboxScreenshotService(Urlbox urlbox)
        {
            _urlbox = urlbox;
        }
 
        public async Task<byte[]> ScreenshotUrlAsync(string url)
        {
            // Download the image
            var image = await _urlbox.DownloadAsBase64(new Dictionary<string, object> {
                ["format"] = "png",
                ["url"] = url,
                ["width"] = 1920,
                ["height"] = 1080,
                ["block_ads"] = true,
                ["hide_cookie_banners"] = true,
                ["retina"] = true,
                ["click_accept"] = true,
                ["hide_selector"] = ".pn-ribbon"
            });
 
            // The default Base64 string includes the content type
            // which .NET doesn't want
            // e.g. image/png;base64,XXXXXXXXXXXXXXX
            image = image.Substring(image.IndexOf(",") + 1);
 
            return Convert.FromBase64String(image);
        }
    }
}

This short code sample has a lot going on. The code requests a PNG file before taking the screenshot. The method also specifies the width and height of the screenshot. Some of Urlbox's more powerful features, such as ad blocking, retina screenshots, and CSS selector-based element hiding, are employed. After configuring the options for the screenshot, the code downloads the image as a Base64 string from Urlbox and returns the raw bytes.

Update the Program.cs file to register the UrlboxScreenshotService. You also need to configure and register the Urlbox service so the UrlboxScreenshotService class can use it:

// ...
 
var builder = WebApplication.CreateBuilder(args);
 
// Add services to the container.
builder.Services.AddSingleton<Urlbox>(sp => new Urlbox(builder.Configuration["Urlbox:ApiKey"], builder.Configuration["Urlbox:ApiSecret"]));
 
builder.Services.AddSingleton<IScreenshotService, UrlboxScreenshotService>();
builder.Services.AddSingleton<IReadingListService, ReadingListService>();
 
// ...

When configuring the Urlbox service, the code retrieves the API key and secret from the configuration object. Update the appsettings.json file to include your Urlbox API key and secret:

{
  // ...,
  "Urlbox": {
    "ApiKey": "<API KEY>",
    "ApiSecret": "<API SECRET>"
  }
}

Run the application:

dotnet run

Open Swagger in your browser and save a URL. Then, retrieve the screenshot and see the result. To demonstrate, if you took a screenshot of the TechCrunch homepage, you should see something similar to the image below:

The TechCrunch homepage screenshot taken by Urlbox

The screenshot looks much better! You'll first notice that the ads are gone, as is the scrollbar. The screenshot is also retina quality, resulting in superior image quality compared to the earlier screenshots. The banner at the page's bottom is also gone, thanks to the custom selector used in the code. Overall, this screenshot looks much better than the previous ones.

Using Urlbox with Webhooks

As a bonus exercise, you'll see how you can use webhooks with Urlbox to make API calls asynchronously. In other words, you can send a request for a screenshot without needing to wait for a response. Instead, Urlbox will send a JSON message to your specified webhook URL once the screenshot is complete.

First, install the Newtonsoft.Json NuGet package in your project. You'll use this to parse JSON content to dynamics in .NET. Add the package with the following command:

dotnet add package Newtonsoft.Json

In the Services > Screenshot directory, create a file called IWebhookScreenshotService.cs. The file will contain an interface describing methods that you can use to take a screenshot asynchronously. Paste the following code in the file:

namespace ReadingListApi.Services.Screenshot
{
    public interface IWebhookScreenshotService
    {
        Task<string> SendScreenshotUrlRequestAsync(string url);
 
        Task<(byte[] FileBytes, string RenderId)> ProcessScreenshotUrlWebhookAsync(dynamic webhook);
    }
}

Instead of having one method to take a screenshot and return the raw bytes, there are two. The first method accepts a URL as a parameter, sends a request to take a screenshot, and then returns a Render ID that you can later use to reconcile the webhook message with a specific reading item. The second method accepts a webhook message as a parameter so that it can download the rendered screenshot. You'll notice the webhook message parameter is dynamic to support different webhook formats. Finally, the method returns the raw screenshot bytes and Render ID, which you can use to save the screenshot to the correct reading item in the database.

Write an implementation for this interface by creating a new file called UrlboxWebhookScreenshotService.cs in the Services > Screenshot directory and pasting in the following code:

using System.Net.Http.Headers;
using System.Text.Json.Nodes;
 
namespace ReadingListApi.Services.Screenshot
{
    public class UrlboxWebhookScreenshotService : IWebhookScreenshotService
    {
        private readonly IConfiguration _configuration;
 
        public UrlboxWebhookScreenshotService(IConfiguration configuration)
        {
            _configuration = configuration;
        }
 
        public async Task<(byte[] FileBytes, string RenderId)> ProcessScreenshotUrlWebhookAsync(dynamic webhook)
        {
            // Retrieve the Render URL from the dynamic object and convert it to a string
            var renderUrl = webhook.result.renderUrl?.ToString();
 
            // Always a good idea to implement error handling in case we can't find the
            // renderURL
            if (renderUrl == null)
                throw new Exception("renderUrl is null!");
 
            // Use an HTTP client to query the renderUrl. If successful, the raw bytes
            // of the screenshot are returned along with the renderId field on the webhook.
            // Otherwise, throw an exception with the response body in the message.
            using (var client = new HttpClient())
            {
                using (var result = await client.GetAsync(renderUrl))
                {
                    if (result.IsSuccessStatusCode)
                    {
                        return (await result.Content.ReadAsByteArrayAsync(), webhook.renderId);
                    }
                    else
                    {
                        throw new Exception($"Failed to download renderUrl: {await result.Content.ReadAsStringAsync()}");
                    }
                }
            }
        }
 
        public async Task<string> SendScreenshotUrlRequestAsync(string url)
        {
            // Create a dictionary of options for the screenshot. This is
            // the same as the previous dictionary with the addition of one
            // configuration option: webhook_url.
            var options = new Dictionary<string, object> {
                ["format"] = "png",
                ["url"] = url,
                ["width"] = 1920,
                ["height"] = 1080,
                ["block_ads"] = true,
                ["hide_cookie_banners"] = true,
                ["retina"] = true,
                ["click_accept"] = true,
                ["hide_selector"] = ".pn-ribbon",
                // Specify the endpoint that that webhook can be sent to
                ["webhook_url"] = _configuration["BaseUrl"] + "/ReadingList/webhooks/screenshot"
            };
 
            // Send the request to the https://api.urlbox.io/v1/render endpoint
            using (var client = new HttpClient())
            {
                // For authentication, you must use "Bearer <ApiSecret>" in the Authorization header
                client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _configuration["Urlbox:ApiSecret"]);
 
                using (var result = await client.PostAsJsonAsync("https://api.urlbox.io/v1/render", options))
                {
                    if (result.IsSuccessStatusCode)
                    {
                        // Retrieve the JSON response as a string and parse it. This is so you can
                        // get the renderId property which you can store to later reconcile the
                        // webhook with the correct reading item.
                        var json = JsonObject.Parse(await result.Content.ReadAsStringAsync());
                        var renderId = json?["renderId"]?.GetValue<string>();
 
                        if (renderId == null)
                            throw new Exception("No renderId returned!");
 
                        return renderId;
                    }
                    else
                    {
                        throw new Exception($"Failed to send render request: {await result.Content.ReadAsStringAsync()}");
                    }
                }
            }
        }
    }
}

In the method to send a screenshot request (SendScreenshotUrlRequestAsync), you'll notice the request to the Urlbox service is slightly different from before. The code sends a POST request to a /render endpoint. In addition, the code also specifies a webhook_url configuration option. This option contains the endpoint URL that Urlbox will call when the screenshot is complete.

When you look at the method to process a webhook (ProcessScreenshotUrlWebhookAsync), the code retrieves the renderUrl field from the webhook. This URL is then used to retrieve the completed screenshot file.

You'll notice that the code uses the "BaseUrl" configuration option to construct the webhook endpoint. For this to work, you must add it to your appsettings.json file:

{
  // ...,
  "BaseUrl": "https://<BASE URL>"
}

Now that you've created a Urlbox webhook service, you must update the ReadingListService to use the IWebhookScreenshotService instead of the IScreenshotService. First, open IReadingListService.cs and create another method called ProcessScreenshotWebhookAsync to process a webhook:

using ReadingListApi.Services.ReadingList.DTO;
 
namespace ReadingListApi.Services.ReadingList
{
    public interface IReadingListService
    {
        Task<Guid> CreateReadingItemAsync(ReadingItemCreateDTO readingItem);
        Task ProcessScreenshotWebhookAsync(dynamic webhook);
        Task<byte[]?> GetReadingItemScreenshotAsync(Guid id);
        Task<IEnumerable<ReadingItemDTO>> ListReadingItemsAsync();
    }
}

Once you've updated the interface, change the ReadingListService class by adding the new method and updating the existing methods to use IWebhookScreenshotService instead of IScreenshotService:

using ReadingListApi.Services.ReadingList.DTO;
using ReadingListApi.Services.ReadingList.Models;
using ReadingListApi.Services.Screenshot;
 
namespace ReadingListApi.Services.ReadingList
{
    public class ReadingListService : IReadingListService
    {
        private readonly List<ReadingItemModel> _readingItems = new();
 
        private readonly IWebhookScreenshotService _screenshotService;
        private readonly IConfiguration _configuration;
 
        public ReadingListService(IWebhookScreenshotService screenshotService, IConfiguration configuration)
        {
            _screenshotService = screenshotService;
            _configuration = configuration;
        }
 
        public async Task<Guid> CreateReadingItemAsync(ReadingItemCreateDTO readingItem)
        {
            // Create a new model for the reading item
            var model = new ReadingItemModel()
            {
                Id = Guid.NewGuid(),
                Reminder = readingItem.Reminder,
                Title = readingItem.Title,
                Url = readingItem.Url
            };
 
            // Determine where to save the screenshot
            var fileName = $"{model.Id}.png";
            var fullFilePath = Path.Combine(_configuration["ScreenshotsFolder"], fileName);
 
            // Send the screenshot request and get the render ID
            var renderId = await _screenshotService.SendScreenshotUrlRequestAsync(readingItem.Url);
            
            // Update our model with the render ID and set screenshot taken to false
            model.RenderId = renderId;
            model.ScreenshotTaken = false;
 
            // Add the model to our "database"
            _readingItems.Add(model);
 
            return model.Id;
        }
 
        public async Task<byte[]?> GetReadingItemScreenshotAsync(Guid id)
        {
            // Try get the item from the database
            var item = _readingItems.FirstOrDefault(x => x.Id == id);
 
            // Make sure we have selected a reading item record and that
            // a screenshot was taken. If no item was found or the screenshot
            // has not been taken, return null.
            if (item == null || item.ScreenshotTaken == false)
                return null;
 
            // Retrieve the screenshot and return it as a byte array.
            var fullFilePath = Path.Combine(_configuration["ScreenshotsFolder"], $"{item.Id}.png");
            return await File.ReadAllBytesAsync(fullFilePath);
        }
 
        public Task<IEnumerable<ReadingItemDTO>> ListReadingItemsAsync()
        {
            // Return all the reading items in the database
            return Task.FromResult(_readingItems.Select(x => new ReadingItemDTO() 
            {
                Id = x.Id,
                Reminder = x.Reminder,
                Title = x.Title,
                Url = x.Url,
                ScreenshotTaken = x.ScreenshotTaken
            }));
        }
 
        public async Task ProcessScreenshotWebhookAsync(dynamic webhook)
        {
            // Use the screenshot service to download the image and retrieve the Render ID
            // from the webhook message
            (byte[] FileBytes, string RenderId) result = await _screenshotService.ProcessScreenshotUrlWebhookAsync(webhook);
 
            // Look for the item with the specified renderId.
            var item = _readingItems.FirstOrDefault(x => x.RenderId == result.RenderId);
 
            if (item == null)
                throw new Exception("Could not find a reading list item with the specified renderId");
 
            // Generate the file name to use for the screenshot if a reading list item was found and
            // save the image there
            var fullFilePath = Path.Combine(_configuration["ScreenshotsFolder"], $"{item.Id}.png");
            await File.WriteAllBytesAsync(fullFilePath, result.FileBytes);
            
            // Update the item to reflect that the screenshot was taken and saved
            item.ScreenshotTaken = true;
        }
    }
}

Now, instead of saving the image to a file right after sending a screenshot request, the code saves the Render ID to the reading item in the database. This minor change will require an update on the ReadingListModel class, which you will do next. However, before doing that, notice that the method that processes the webhook uses the screenshot service to download the rendered screenshot and then saves it to the correct file.

Make the following update to the ReadingListModel.cs file so that you can store the Render ID:

namespace ReadingListApi.Services.ReadingList.Models
{
    public class ReadingItemModel
    {
        public Guid Id { get; set; }
        public string RenderId { get; set; }
        public string Title { get; set; }
        public DateTime Reminder { get; set; }
        public string Url { get; set; }
        public bool ScreenshotTaken { get; set; }
    }
}

The Program.cs file also needs to be updated to register the new Urlbox service. Replace the code that registers the existing Urlbox service with the code below:

// ...
 
var builder = WebApplication.CreateBuilder(args);
 
// Add services to the container.
builder.Services.AddSingleton<IWebhookScreenshotService, UrlboxWebhookScreenshotService>();
builder.Services.AddSingleton<IReadingListService, ReadingListService>();
 
// ...

Lastly, update the ReadingListController.cs file to add a new endpoint to which Urlbox can send the webhook message. Do this by opening the file and adding the following method to the bottom of the class:

using System.Dynamic;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using ReadingListApi.Services.ReadingList;
using ReadingListApi.Services.ReadingList.DTO;
 
namespace ReadingListApi.Controllers
{
    [ApiController]
    [Route("[controller]")]
    public class ReadingListController : ControllerBase
    {
        // ...
 
        [HttpPost("webhooks/screenshot")]
        public async Task<IActionResult> ProcessScreenshotWebhookAsync([FromBody] dynamic webhook)
        {
            try
            {
                await _readingListService.ProcessScreenshotWebhookAsync(JsonConvert.DeserializeObject<ExpandoObject>(webhook.ToString(), new ExpandoObjectConverter()));
 
                return Ok();
            }
            catch (Exception ex) when (ex.Message == "Could not find a reading list item with the specified renderId")
            {
                _logger.LogError("Could not find a reading list item with the specified renderId");
                return Ok();
            }
            catch (Exception ex)
            {
                _logger.LogError("An error occurred while processing a screenshot webhook: {Exception}", new { Exception = ex });
                return StatusCode(StatusCodes.Status500InternalServerError, "An error occurred while processing a screenshot webhook");
            }
        }
    }
}

The request's body is converted to a dynamic type using Newtonsoft's ExpandoObjectConverter. The code then passes the converted type to the ProcessScreenshotWebhookAsync method on the IReadingListService.

You can now run the application. First, Urlbox needs to call your webhook endpoint, so you will need to host the application somewhere like Azure or use a reverse proxy like ngrok to expose your localhost to the internet. Once that's sorted, use the Swagger page to request a screenshot and download it.

The initial request to take the screenshot is much faster because the code doesn't wait for the image to download. Instead, a few seconds after saving the URL to our reading list, Urlbox calls the webhook and sends it the URL to the completed screenshot, which the application then downloads and saves.

Conclusion

You might want to incorporate screenshots into your ASP.NET Core application for many reasons. In this article, you've seen how you can use different NuGet packages to take screenshots of a website locally. You also saw the benefit of using a screenshot service like Urlbox. With it, you can take ad-free, high-quality screenshots with minimal effort in an ASP.NET Core application. Finally, you learned how to use the webhook functionality provided by Urlbox to take screenshots asynchronously.

If you're looking for an easy, quick, and affordable way to create high-quality screenshots that stand out, look no further than Urlbox.