This page shows how to add website screenshots to an ASP.NET Core app with a minimal API. RenderScreenshot has no C# SDK, so the code calls the HTTP API directly, as in the C# screenshot API guide. It uses a typed HttpClient from IHttpClientFactory, a Polly retry from Microsoft.Extensions.Http.Resilience, a background queue on System.Threading.Channels, and an HMAC-signed og:image URL. The protocol itself is described in the raw HTTP API guide. For SDK languages, see the screenshot API code examples hub. For HTML strings instead of URLs, see converting HTML to an image in C#.
Last tested: 2026-10-18 with the .NET 10.0.401 SDK on macOS, ASP.NET Core 10.0.12,
Microsoft.Extensions.Http.Resilience10.11.0 and xUnit 2.9.3.dotnet testpassed 4 tests. The project also compiled fornet8.0, but we only ran it on .NET 10. We ran the app against a local stub of our API that returns the documented image and error responses, including a 429 withRetry-After: 1. We did not call the live API.
What you build
Three endpoints that cover the usual ways to use screenshots in a .NET web app:
| Endpoint | Use it for | Who calls the API |
|---|---|---|
GET /og/{slug}.png |
An image rendered when someone asks for it | Your server, during the request |
POST /captures |
Thumbnails of many URLs, out of the request path | A BackgroundService |
GET /posts/{slug} |
An og:image that costs your server nothing per crawl |
The crawler, with a signed URL |
Install
dotnet new web -n Shots cd Shots dotnet add package Microsoft.Extensions.Http.Resilience
Store the keys with user secrets in development and as environment variables in production (RenderScreenshot__ApiKey, and so on):
dotnet user-secrets init dotnet user-secrets set "RenderScreenshot:ApiKey" "rs_live_xxxxx" dotnet user-secrets set "RenderScreenshot:PublicKeyId" "rs_pub_xxxxx" dotnet user-secrets set "RenderScreenshot:SecretKey" "rs_secret_xxxxx"
The typed client
// ScreenshotClient.cs using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Serialization; namespace Shots; public sealed record ScreenshotRequest(string Url, string? Preset = "og_card", OutputOptions? Output = null); public sealed record OutputOptions(string Format = "png", int? Quality = null); public sealed record Screenshot(byte[] Bytes, string ContentType, string? CacheUrl, string? Title); public sealed class ScreenshotApiException(int status, string? code, string message, TimeSpan? retryAfter, string? requestId) : Exception(message) { public int Status { get; } = status; public string? Code { get; } = code; public TimeSpan? RetryAfter { get; } = retryAfter; public string? RequestId { get; } = requestId; } public sealed class ScreenshotClient(HttpClient http) { private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web) { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, }; public async Task<Screenshot> CaptureAsync(ScreenshotRequest request, CancellationToken ct = default) { using var response = await http.PostAsJsonAsync("v1/screenshot", request, Json, ct); if (!response.IsSuccessStatusCode) throw await ToExceptionAsync(response, ct); var bytes = await response.Content.ReadAsByteArrayAsync(ct); return new Screenshot( bytes, response.Content.Headers.ContentType?.MediaType ?? "application/octet-stream", Header(response, "X-Cache-URL"), Header(response, "X-Screenshot-Title") is { } t ? Uri.UnescapeDataString(t) : null); } private static async Task<ScreenshotApiException> ToExceptionAsync(HttpResponseMessage response, CancellationToken ct) { string? code = null, message = null, requestId = null; try { using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct)); var error = doc.RootElement.GetProperty("error"); code = error.GetProperty("code").GetString(); message = error.GetProperty("message").GetString(); requestId = doc.RootElement.TryGetProperty("request_id", out var id) ? id.GetString() : null; } catch (JsonException) { } // not JSON, for example a proxy error page catch (KeyNotFoundException) { } return new ScreenshotApiException((int)response.StatusCode, code, message ?? $"HTTP {(int)response.StatusCode}", response.Headers.RetryAfter?.Delta, requestId); } private static string? Header(HttpResponseMessage r, string name) => r.Headers.TryGetValues(name, out var v) ? v.FirstOrDefault() : null; }
SnakeCaseLower turns the C# records into the API's JSON names, and null values are left out, so new ScreenshotRequest("https://example.com") sends {"url":"https://example.com","preset":"og_card"}. Add properties for the other groups (viewport, wait, block) as you need them; the POST /v1/screenshot reference lists every field. The API sends the page title in X-Screenshot-Title as percent-encoded UTF-8 when it contains non-ASCII text, which is why the client decodes it (response headers).
Registration with a resilience pipeline
// RenderScreenshotRegistration.cs using System.Net; using System.Net.Http.Headers; using Microsoft.Extensions.Http.Resilience; using Polly; namespace Shots; public static class RenderScreenshotRegistration { public static IHttpClientBuilder AddRenderScreenshot(this IServiceCollection services, IConfiguration rs) { var client = services.AddHttpClient<ScreenshotClient>(http => { http.BaseAddress = new Uri(rs["BaseUrl"] ?? "https://api.renderscreenshot.com/"); http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", rs["ApiKey"]); http.Timeout = TimeSpan.FromMinutes(5); // the whole call, all retries included }); client.AddResilienceHandler("renderscreenshot", pipeline => { pipeline.AddRetry(new HttpRetryStrategyOptions { MaxRetryAttempts = 3, BackoffType = DelayBackoffType.Exponential, UseJitter = true, Delay = TimeSpan.FromSeconds(2), ShouldRetryAfterHeader = true, // wait as long as a 429 asks ShouldHandle = args => ValueTask.FromResult(args.Outcome switch { { Exception: HttpRequestException } => true, { Result.StatusCode: HttpStatusCode.TooManyRequests } => true, { Result.StatusCode: >= HttpStatusCode.InternalServerError } => true, _ => false, // 4xx, including 422 target errors, go back to the caller }), }); pipeline.AddTimeout(TimeSpan.FromSeconds(90)); // per attempt }); return client; } }
The order of the strategies matters. The retry is added first, so it wraps the timeout, and the 90-second limit applies to each attempt. The API waits at most 60 seconds for a page (wait options), so 90 seconds leaves room for the upload of a large image. HttpClient.Timeout covers the whole call with every retry and defaults to 100 seconds, which a second attempt would exceed. That is why it is raised to five minutes.
We built the pipeline by hand instead of using AddStandardResilienceHandler. The standard handler also adds a rate limiter, a circuit breaker and default timeouts that are much shorter than a 60-second render, so you would have to tune all of them anyway. A 422 target error is left alone here, because the API returns 422 for both temporary problems (timeout, navigation_failed) and permanent ones (blocked). Retry those by code in your own logic if you need to; the error codes page marks which are retryable. The rate limit is per account and shared by all your keys, so a retry loop in one service also slows every other service (rate limits).
The endpoints
// Program.cs using System.Text.Encodings.Web; using System.Text.RegularExpressions; using Shots; var builder = WebApplication.CreateBuilder(args); var rs = builder.Configuration.GetSection("RenderScreenshot"); builder.Services.AddRenderScreenshot(rs); builder.Services.AddSingleton(TimeProvider.System); builder.Services.AddSingleton<CaptureQueue>(); builder.Services.AddHostedService<CaptureWorker>(); var app = builder.Build(); var slugPattern = new Regex("^[a-z0-9-]{1,80}$", RegexOptions.Compiled); app.MapGet("/og/{slug}.png", async (string slug, ScreenshotClient client, HttpContext http, CancellationToken ct) => { if (!slugPattern.IsMatch(slug)) return Results.NotFound(); try { var shot = await client.CaptureAsync(new ScreenshotRequest($"https://example.com/blog/{slug}"), ct); http.Response.Headers.CacheControl = "public, max-age=86400"; return Results.File(shot.Bytes, shot.ContentType); } catch (ScreenshotApiException e) when (e.Status == 429) { http.Response.Headers.RetryAfter = ((int)(e.RetryAfter?.TotalSeconds ?? 30)).ToString(); return Results.StatusCode(StatusCodes.Status503ServiceUnavailable); } catch (ScreenshotApiException) { return Results.StatusCode(StatusCodes.Status502BadGateway); } }); app.MapPost("/captures", async (CaptureRequest body, CaptureQueue queue, CancellationToken ct) => { if (!Uri.TryCreate(body.Url, UriKind.Absolute, out var uri) || uri.Scheme is not ("http" or "https")) return Results.ValidationProblem(new Dictionary<string, string[]> { ["url"] = ["Use an http(s) URL."] }); var id = await queue.EnqueueAsync(uri.ToString(), ct); return Results.Accepted($"/captures/{id}", new { id }); }); app.MapGet("/captures/{id:guid}", (Guid id, CaptureQueue queue) => queue.Status.TryGetValue(id, out var status) ? Results.Ok(new { id, status = status.ToString() }) : Results.NotFound()); app.MapGet("/posts/{slug}", (string slug, TimeProvider clock) => { if (!slugPattern.IsMatch(slug)) return Results.NotFound(); var image = SignedScreenshotUrl.Create( new Dictionary<string, string> { ["url"] = $"https://example.com/posts/{slug}", ["preset"] = "og_card" }, rs["PublicKeyId"]!, rs["SecretKey"]!, SignedScreenshotUrl.WeeklyExpiry(clock.GetUtcNow())); var html = $""" <!doctype html> <html><head> <meta property="og:image" content="{HtmlEncoder.Default.Encode(image)}"> <meta property="og:image:width" content="1200"> <meta property="og:image:height" content="630"> </head><body><h1>{HtmlEncoder.Default.Encode(slug)}</h1></body></html> """; return Results.Content(html, "text/html"); }); app.Run(); public sealed record CaptureRequest(string Url); public partial class Program;
Against the stub, /og/hello-world.png returned the PNG. A 429 with Retry-After: 1 was retried and succeeded after 1.02 seconds, and the Polly log showed one OnRetry event. A 400 and a 422 each became a 502 after one attempt. The slug check returned 404 for Hello_World, and the page URL is built from your own host name, not from the request's Host header. A visitor cannot make your key capture an arbitrary page.
One error cost us a few minutes. TimeProvider is not in the container by default. Without AddSingleton(TimeProvider.System), the minimal API took the clock parameter for a request body. Every endpoint then failed with Body was inferred but the method does not allow inferred body parameters, because ASP.NET Core builds all endpoints on the first request.
The background queue
// CaptureQueue.cs using System.Collections.Concurrent; using System.Threading.Channels; namespace Shots; public sealed record CaptureJob(Guid Id, string Url); public enum CaptureStatus { Queued, Done, Failed } public sealed class CaptureQueue { private readonly Channel<CaptureJob> _channel = Channel.CreateBounded<CaptureJob>( new BoundedChannelOptions(100) { FullMode = BoundedChannelFullMode.Wait }); public ConcurrentDictionary<Guid, CaptureStatus> Status { get; } = new(); public async ValueTask<Guid> EnqueueAsync(string url, CancellationToken ct) { var job = new CaptureJob(Guid.NewGuid(), url); Status[job.Id] = CaptureStatus.Queued; await _channel.Writer.WriteAsync(job, ct); return job.Id; } public IAsyncEnumerable<CaptureJob> ReadAllAsync(CancellationToken ct) => _channel.Reader.ReadAllAsync(ct); } public sealed class CaptureWorker(CaptureQueue queue, IServiceScopeFactory scopes, IWebHostEnvironment env, ILogger<CaptureWorker> log) : BackgroundService { protected override async Task ExecuteAsync(CancellationToken stoppingToken) { var dir = Directory.CreateDirectory(Path.Combine(env.ContentRootPath, "captures")).FullName; await Parallel.ForEachAsync(queue.ReadAllAsync(stoppingToken), new ParallelOptions { MaxDegreeOfParallelism = 2, CancellationToken = stoppingToken }, async (job, ct) => { using var scope = scopes.CreateScope(); var client = scope.ServiceProvider.GetRequiredService<ScreenshotClient>(); try { var shot = await client.CaptureAsync( new ScreenshotRequest(job.Url, Output: new OutputOptions("webp", 80)), ct); await File.WriteAllBytesAsync(Path.Combine(dir, $"{job.Id}.webp"), shot.Bytes, ct); queue.Status[job.Id] = CaptureStatus.Done; } catch (ScreenshotApiException e) { log.LogWarning("Capture {Id} failed: {Status} {Code} ({RequestId})", job.Id, e.Status, e.Code, e.RequestId); queue.Status[job.Id] = CaptureStatus.Failed; } }); } }
MaxDegreeOfParallelism is the number of renders in flight. Keep it low enough that the worker stays under your plan's per-minute rate limit. A BackgroundService is a singleton and a typed client is transient, so the worker resolves the client from a new scope for each job. The bounded channel makes POST /captures wait when 100 jobs are already queued, so memory use stays bounded. This queue lives in memory and is lost on restart. For jobs that must survive a deploy, put the same loop behind a durable queue such as Azure Service Bus or Hangfire. In our run, one job saved a WebP after a 429 retry, and one ended as Failed with invalid_url in the log.
Signing the og:image URL
// SignedScreenshotUrl.cs using System.Security.Cryptography; using System.Text; namespace Shots; public static class SignedScreenshotUrl { // Like the server: keep A-Z a-z 0-9 - _ . ~, space becomes "+", the rest %XX uppercase. private static string Encode(string value) => Uri.EscapeDataString(value).Replace("%20", "+"); public static string Create(IReadOnlyDictionary<string, string> parameters, string keyId, string secret, DateTimeOffset expiresAt, string endpoint = "https://api.renderscreenshot.com/v1/screenshot") { var fields = new Dictionary<string, string>(parameters) { ["key_id"] = keyId, ["expires"] = expiresAt.ToUnixTimeSeconds().ToString(), }; var query = string.Join("&", fields .Select(kv => $"{Encode(kv.Key)}={Encode(kv.Value)}") .OrderBy(pair => pair, StringComparer.Ordinal)); var signature = Convert.ToHexString( HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), Encoding.UTF8.GetBytes(query))).ToLowerInvariant(); return $"{endpoint}?{query}&signature={signature}"; } // Same URL for a whole week, with 7 to 14 days left (the API allows at most 30). public static DateTimeOffset WeeklyExpiry(DateTimeOffset now) { var monday = now.UtcDateTime.Date.AddDays(-(((int)now.UtcDateTime.DayOfWeek + 6) % 7)); return new DateTimeOffset(monday.AddDays(14), TimeSpan.Zero); } }
Uri.EscapeDataString already keeps exactly the unreserved characters and writes uppercase hex, so only the space needs a change. StringComparer.Ordinal matters: the default comparer sorts by culture, and the server sorts by byte. Our xUnit test checks this function against the reference vector on the signed URLs page, and the signature matched. Signed URLs work only for GET /v1/screenshot, so use the flat GET names (url, preset, width, format) in the dictionary.
Testing
Replace the primary handler, and keep the real resilience pipeline:
var services = new ServiceCollection(); services.AddRenderScreenshot(config).ConfigurePrimaryHttpMessageHandler(() => fake); var client = services.BuildServiceProvider().GetRequiredService<ScreenshotClient>();
fake is a small HttpMessageHandler that returns queued responses. With it, our four tests check that a 429 with Retry-After: 1 is retried once and takes at least 0.9 seconds, and that a 422 is not retried and exposes navigation_failed and the request ID. They also check the signature reference vector, and that WeeklyExpiry returns the same date on a Tuesday and the following Sunday.
When to run the browser yourself
If your pages are internal and cannot be reached from the internet, or you need full control of Chrome, run it in-process with PuppeteerSharp or Playwright for .NET. PuppeteerSharp vs Playwright for .NET compares them.
Get a free API key, add it with dotnet user-secrets, and request /og/hello-world.png. The free plan includes 150 credits every month.