Screenshot API with C# and .NET: Complete Integration Guide
C# and .NET developers tend to be thorough. They want strongly-typed models, proper async patterns, clean dependency injection, and code that fits naturally into the Microsoft ecosystem. This guide covers all of it — from a basic HttpClient call through to Azure Functions triggers and Blazor Server components.
Basic Setup: HttpClient with Typed Response
Start with a typed client wrapper. This handles the HTTP concerns cleanly and is easy to mock in tests.
using System.Net.Http;
using System.Net.Http.Headers;
public class ScreenshotOptions
{
public string Url { get; set; } = string.Empty;
public int Width { get; set; } = 1280;
public int Height { get; set; } = 800;
public int Delay { get; set; } = 0;
public string Format { get; set; } = "png";
public bool FullPage { get; set; } = false;
public bool BlockAds { get; set; } = false;
public string? Js { get; set; }
}
public class ScreenshotResult
{
public bool Success { get; set; }
public byte[]? ImageData { get; set; }
public string? ContentType { get; set; }
public string? ErrorMessage { get; set; }
}
public class ScreenshotApiClient
{
private readonly HttpClient _httpClient;
private readonly string _baseUrl = "https://hermesforge.dev/api/screenshot";
public ScreenshotApiClient(HttpClient httpClient, string apiKey)
{
_httpClient = httpClient;
_httpClient.DefaultRequestHeaders.Add("X-API-Key", apiKey);
}
public async Task<ScreenshotResult> CaptureAsync(
ScreenshotOptions options,
CancellationToken cancellationToken = default)
{
var queryParams = new Dictionary<string, string?>
{
["url"] = options.Url,
["width"] = options.Width.ToString(),
["height"] = options.Height.ToString(),
["format"] = options.Format,
["full_page"] = options.FullPage.ToString().ToLower(),
["block_ads"] = options.BlockAds.ToString().ToLower(),
};
if (options.Delay > 0)
queryParams["delay"] = options.Delay.ToString();
if (!string.IsNullOrEmpty(options.Js))
queryParams["js"] = options.Js;
var qs = string.Join("&", queryParams
.Where(kv => kv.Value != null)
.Select(kv => $"{kv.Key}={Uri.EscapeDataString(kv.Value!)}"));
var requestUrl = $"{_baseUrl}?{qs}";
try
{
var response = await _httpClient.GetAsync(requestUrl, cancellationToken);
if (!response.IsSuccessStatusCode)
{
return new ScreenshotResult
{
Success = false,
ErrorMessage = $"API returned {(int)response.StatusCode}: {response.ReasonPhrase}"
};
}
var imageBytes = await response.Content.ReadAsByteArrayAsync(cancellationToken);
var contentType = response.Content.Headers.ContentType?.MediaType ?? "image/png";
return new ScreenshotResult
{
Success = true,
ImageData = imageBytes,
ContentType = contentType
};
}
catch (HttpRequestException ex)
{
return new ScreenshotResult { Success = false, ErrorMessage = ex.Message };
}
}
}
Dependency Injection in ASP.NET Core
Register the client in Program.cs:
// Program.cs
builder.Services.AddHttpClient<ScreenshotApiClient>(client =>
{
client.Timeout = TimeSpan.FromSeconds(30);
})
.AddTransientHttpErrorPolicy(policy =>
policy.WaitAndRetryAsync(3, retryAttempt =>
TimeSpan.FromSeconds(Math.Pow(2, retryAttempt))));
builder.Services.AddScoped<ScreenshotApiClient>(provider =>
{
var httpClient = provider.GetRequiredService<IHttpClientFactory>()
.CreateClient(nameof(ScreenshotApiClient));
var apiKey = builder.Configuration["ScreenshotApi:ApiKey"]
?? throw new InvalidOperationException("ScreenshotApi:ApiKey not configured");
return new ScreenshotApiClient(httpClient, apiKey);
});
In appsettings.json:
{
"ScreenshotApi": {
"ApiKey": "your_api_key_here"
}
}
Inject and use in a controller:
[ApiController]
[Route("api/[controller]")]
public class CaptureController : ControllerBase
{
private readonly ScreenshotApiClient _screenshotClient;
public CaptureController(ScreenshotApiClient screenshotClient)
{
_screenshotClient = screenshotClient;
}
[HttpGet("capture")]
public async Task<IActionResult> Capture([FromQuery] string url, CancellationToken ct)
{
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) ||
(uri.Scheme != "http" && uri.Scheme != "https"))
{
return BadRequest("Invalid URL");
}
var result = await _screenshotClient.CaptureAsync(new ScreenshotOptions
{
Url = url,
Width = 1280,
Height = 800,
Delay = 1000,
Format = "webp",
}, ct);
if (!result.Success)
return StatusCode(502, result.ErrorMessage);
return File(result.ImageData!, result.ContentType!, "screenshot.webp");
}
}
Azure Functions: HTTP Trigger
Azure Functions is popular in the Microsoft ecosystem for event-driven capture workflows.
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
using System.Net;
public class ScreenshotFunction
{
private readonly ScreenshotApiClient _screenshotClient;
private readonly ILogger<ScreenshotFunction> _logger;
public ScreenshotFunction(ScreenshotApiClient screenshotClient, ILogger<ScreenshotFunction> logger)
{
_screenshotClient = screenshotClient;
_logger = logger;
}
[Function("CaptureScreenshot")]
public async Task<HttpResponseData> RunAsync(
[HttpTrigger(AuthorizationLevel.Function, "post")] HttpRequestData req,
FunctionContext context)
{
var body = await req.ReadFromJsonAsync<CaptureRequest>();
if (body?.Url is null)
{
var badReq = req.CreateResponse(HttpStatusCode.BadRequest);
await badReq.WriteStringAsync("Missing 'url' field");
return badReq;
}
_logger.LogInformation("Capturing screenshot of {Url}", body.Url);
var result = await _screenshotClient.CaptureAsync(new ScreenshotOptions
{
Url = body.Url,
Width = body.Width ?? 1280,
Height = body.Height ?? 800,
Delay = body.Delay ?? 1000,
Format = "png",
});
if (!result.Success)
{
_logger.LogWarning("Screenshot capture failed: {Error}", result.ErrorMessage);
var errorResp = req.CreateResponse(HttpStatusCode.BadGateway);
await errorResp.WriteStringAsync(result.ErrorMessage ?? "Capture failed");
return errorResp;
}
var response = req.CreateResponse(HttpStatusCode.OK);
response.Headers.Add("Content-Type", "image/png");
await response.Body.WriteAsync(result.ImageData!);
return response;
}
}
public record CaptureRequest(string? Url, int? Width, int? Height, int? Delay);
Register in Program.cs for Azure Functions isolated worker:
var host = new HostBuilder()
.ConfigureFunctionsWorkerDefaults()
.ConfigureServices((context, services) =>
{
services.AddHttpClient<ScreenshotApiClient>();
services.AddSingleton(sp =>
{
var http = sp.GetRequiredService<IHttpClientFactory>().CreateClient();
var key = context.Configuration["ScreenshotApi:ApiKey"]!;
return new ScreenshotApiClient(http, key);
});
})
.Build();
host.Run();
Azure Functions: Timer Trigger for Scheduled Monitoring
public class ScheduledMonitorFunction
{
private readonly ScreenshotApiClient _client;
private readonly ILogger<ScheduledMonitorFunction> _logger;
// Pages to monitor: stored in Azure App Configuration or appsettings
private static readonly string[] MonitorTargets =
[
"https://yoursite.com",
"https://yoursite.com/pricing",
"https://yoursite.com/product",
];
public ScheduledMonitorFunction(ScreenshotApiClient client, ILogger<ScheduledMonitorFunction> logger)
{
_client = client;
_logger = logger;
}
// Runs every 6 hours: "0 0 */6 * * *"
[Function("ScheduledMonitor")]
public async Task RunAsync([TimerTrigger("0 0 */6 * * *")] TimerInfo timer)
{
_logger.LogInformation("Starting scheduled visual monitor at {Time}", DateTimeOffset.UtcNow);
var tasks = MonitorTargets.Select(url => CaptureAndStoreAsync(url));
var results = await Task.WhenAll(tasks);
var failed = results.Count(r => !r);
_logger.LogInformation(
"Monitor complete: {Success}/{Total} captures succeeded",
results.Length - failed, results.Length);
}
private async Task<bool> CaptureAndStoreAsync(string url)
{
var result = await _client.CaptureAsync(new ScreenshotOptions
{
Url = url,
Width = 1440,
Height = 900,
Delay = 2000,
Format = "png",
});
if (!result.Success)
{
_logger.LogWarning("Failed to capture {Url}: {Error}", url, result.ErrorMessage);
return false;
}
// Store to Azure Blob Storage (BlobClient omitted for brevity)
var slug = new Uri(url).AbsolutePath.Trim('/').Replace('/', '_');
var timestamp = DateTimeOffset.UtcNow.ToString("yyyyMMdd_HHmmss");
var blobName = $"screenshots/{slug}/{timestamp}.png";
_logger.LogInformation("Stored {Bytes} bytes for {Url} as {Blob}",
result.ImageData!.Length, url, blobName);
return true;
}
}
Blazor Server: Display Live Screenshots
In a Blazor Server component, call the API and display the result inline:
@page "/preview"
@inject ScreenshotApiClient ScreenshotClient
<h3>Page Preview</h3>
<EditForm Model="@model" OnValidSubmit="@CaptureScreenshot">
<InputText @bind-Value="model.Url" placeholder="https://example.com" class="form-control" />
<button type="submit" class="btn btn-primary mt-2" disabled="@isLoading">
@(isLoading ? "Capturing..." : "Capture")
</button>
</EditForm>
@if (errorMessage is not null)
{
<div class="alert alert-danger mt-3">@errorMessage</div>
}
@if (imageDataUri is not null)
{
<div class="mt-3">
<img src="@imageDataUri" class="img-fluid border rounded" alt="Screenshot" />
</div>
}
@code {
private CaptureModel model = new();
private string? imageDataUri;
private string? errorMessage;
private bool isLoading;
private async Task CaptureScreenshot()
{
isLoading = true;
errorMessage = null;
imageDataUri = null;
try
{
var result = await ScreenshotClient.CaptureAsync(new ScreenshotOptions
{
Url = model.Url,
Width = 1280,
Height = 800,
Delay = 1000,
Format = "png",
});
if (result.Success)
{
var base64 = Convert.ToBase64String(result.ImageData!);
imageDataUri = $"data:{result.ContentType};base64,{base64}";
}
else
{
errorMessage = result.ErrorMessage;
}
}
finally
{
isLoading = false;
}
}
private class CaptureModel
{
public string Url { get; set; } = string.Empty;
}
}
Saving to Disk or Returning as File Download
// Save to disk
public static async Task SaveScreenshotAsync(ScreenshotResult result, string outputPath)
{
if (!result.Success || result.ImageData is null)
throw new InvalidOperationException(result.ErrorMessage);
await File.WriteAllBytesAsync(outputPath, result.ImageData);
}
// Return as file download from MVC/API controller
public IActionResult DownloadScreenshot(ScreenshotResult result, string filename = "screenshot.png")
{
if (!result.Success || result.ImageData is null)
return BadRequest(result.ErrorMessage);
return File(result.ImageData, result.ContentType ?? "image/png", filename);
}
// Convert to base64 string (for embedding in JSON responses or HTML)
public static string ToBase64DataUri(ScreenshotResult result)
{
if (!result.Success || result.ImageData is null)
throw new InvalidOperationException(result.ErrorMessage);
var base64 = Convert.ToBase64String(result.ImageData);
return $"data:{result.ContentType ?? "image/png"};base64,{base64}";
}
Unit Testing with Mocks
using Moq;
using Xunit;
public class CaptureControllerTests
{
[Fact]
public async Task Capture_ReturnsFile_WhenApiSucceeds()
{
// Arrange
var mockClient = new Mock<ScreenshotApiClient>(
new HttpClient(), "test-key");
mockClient
.Setup(c => c.CaptureAsync(It.IsAny<ScreenshotOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ScreenshotResult
{
Success = true,
ImageData = new byte[] { 0x89, 0x50, 0x4E, 0x47 }, // PNG header
ContentType = "image/png"
});
var controller = new CaptureController(mockClient.Object);
// Act
var result = await controller.Capture("https://example.com", CancellationToken.None);
// Assert
var fileResult = Assert.IsType<FileContentResult>(result);
Assert.Equal("image/png", fileResult.ContentType);
}
[Fact]
public async Task Capture_ReturnsBadRequest_ForInvalidUrl()
{
var mockClient = new Mock<ScreenshotApiClient>(new HttpClient(), "test-key");
var controller = new CaptureController(mockClient.Object);
var result = await controller.Capture("not-a-url", CancellationToken.None);
Assert.IsType<BadRequestObjectResult>(result);
}
}
Key Takeaways
HttpClientwrapper: Encapsulate API details in a typed client; register viaAddHttpClient<T>for connection pooling and Polly retry support- DI registration: Use
IConfigurationfor the API key — never hardcode or commit to source control - Azure Functions: HTTP trigger for on-demand capture, Timer trigger for scheduled monitoring; both fit naturally with the isolated worker model
- Blazor Server: Convert
byte[]todata:image/...;base64,...URI for inline display — no file server needed - Testing: The client wrapper is easy to mock with Moq; test the controller logic independently of HTTP
- Cancellation: Pass
CancellationTokenthrough all async calls — especially important in Azure Functions where the host can cancel on timeout or scale-down