C# Screenshot API: HttpClient, Polly Retries, Signed URLs

Take website screenshots from C# and .NET 10 without an SDK: a typed HttpClient, Polly retries that honour Retry-After, HMACSHA256 signed URLs, xUnit tests.

By Simon Chiu · Updated

There is no RenderScreenshot SDK for C# yet, so this page is the .NET guide: a tested raw-HTTP client that does what our SDKs do in other languages. It covers a typed HttpClient registered through IHttpClientFactory, retries with Polly through Microsoft.Extensions.Http.Resilience, streaming the image into an ASP.NET Core response, a signed og:image URL built with HMACSHA256, batch requests, and xUnit tests. For languages with an official SDK, see the screenshot API code examples hub.

Last tested: 2026-10-18 with .NET SDK 10.0.401 (net10.0), Microsoft.Extensions.Http.Resilience 10.11.0 and xUnit 2.9.3 on macOS 27 (Apple M1 Pro). dotnet test passed four tests, including the signature reference vector from our signed URL docs. The ASP.NET Core app ran against a local stub of our API that returns the documented image and error bodies, including a 429 with Retry-After. We did not call the production API.

Set up the project

dotnet new web -n Shots.App
cd Shots.App
dotnet add package Microsoft.Extensions.Http.Resilience

Microsoft.Extensions.Http.Resilience is Microsoft's wrapper around Polly v8 for IHttpClientFactory. You do not need the older Microsoft.Extensions.Http.Polly package. Store RS_API_KEY (rs_live_), and for signed URLs RS_PUBLIC_KEY_ID (rs_pub_) and RS_SECRET_KEY (rs_secret_), in user secrets or environment variables; see API keys.

Quick start

The shortest working call needs no packages:

using System.Net.Http.Json;

using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(90) };
http.DefaultRequestHeaders.Authorization = new("Bearer", Environment.GetEnvironmentVariable("RS_API_KEY"));

using var res = await http.PostAsJsonAsync("https://api.renderscreenshot.com/v1/screenshot",
    new { url = "https://example.com", preset = "og_card", output = new { format = "webp" } });
res.EnsureSuccessStatusCode();
await File.WriteAllBytesAsync("example.webp", await res.Content.ReadAsByteArrayAsync());

Two things are easy to get wrong. The body is nested: the format goes in output.format, not a top-level format. And HttpClient defaults to a 100-second timeout, which is fine here, but if you lower it below 60 seconds you cancel renders that are still waiting for a slow page. The POST reference lists every field group.

A typed client

For an app, wrap the API in a typed client so that errors become one exception type with the API's code in it:

using System.Net;
using System.Net.Http.Json;
using System.Text.Json;

namespace Shots;

public sealed record Screenshot(byte[] Bytes, string? ContentType, string? CacheUrl);

public sealed class RenderScreenshotException(
    HttpStatusCode status, string code, string message, string? param, string? requestId, TimeSpan? retryAfter)
    : Exception($"{(int)status} {code}: {message}")
{
    public HttpStatusCode Status { get; } = status;
    public string Code { get; } = code;          // e.g. invalid_url, too_many_requests
    public string? Param { get; } = param;       // e.g. viewport.width
    public string? RequestId { get; } = requestId;
    public TimeSpan? RetryAfter { get; } = retryAfter;

    public static async Task<RenderScreenshotException> FromResponseAsync(HttpResponseMessage res, CancellationToken ct)
    {
        string code = "unknown", message = res.ReasonPhrase ?? "error";
        string? param = null, requestId = null;
        try
        {
            using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync(ct));
            var err = doc.RootElement.GetProperty("error");
            code = err.GetProperty("code").GetString() ?? code;
            message = err.GetProperty("message").GetString() ?? message;
            if (err.TryGetProperty("param", out var p)) param = p.GetString();
            if (doc.RootElement.TryGetProperty("request_id", out var r)) requestId = r.GetString();
        }
        catch (JsonException) { /* a proxy or load balancer answered: keep the status */ }
        return new(res.StatusCode, code, message, param, requestId, res.Headers.RetryAfter?.Delta);
    }
}

public sealed class RenderScreenshotClient(HttpClient http)
{
    // POST /v1/screenshot with the nested JSON body; returns the image bytes.
    public async Task<Screenshot> TakeAsync(object request, CancellationToken ct = default)
    {
        using var res = await http.PostAsJsonAsync("v1/screenshot", request, ct);
        if (!res.IsSuccessStatusCode) throw await RenderScreenshotException.FromResponseAsync(res, ct);

        var bytes = await res.Content.ReadAsByteArrayAsync(ct);
        res.Headers.TryGetValues("X-Cache-URL", out var cacheUrl);
        return new(bytes, res.Content.Headers.ContentType?.MediaType, cacheUrl?.FirstOrDefault());
    }

    // Same request, but the body goes straight to a stream (a file, a response body).
    public async Task TakeToStreamAsync(object request, Stream destination, CancellationToken ct = default)
    {
        using var msg = new HttpRequestMessage(HttpMethod.Post, "v1/screenshot") { Content = JsonContent.Create(request) };
        using var res = await http.SendAsync(msg, HttpCompletionOption.ResponseHeadersRead, ct);
        if (!res.IsSuccessStatusCode) throw await RenderScreenshotException.FromResponseAsync(res, ct);
        await res.Content.CopyToAsync(destination, ct);
    }

    // POST /v1/batch: up to your plan's batch size, one request against the rate limit.
    public async Task<JsonElement> BatchAsync(IEnumerable<string> urls, object options, CancellationToken ct = default)
    {
        using var res = await http.PostAsJsonAsync("v1/batch", new { urls, options }, ct);
        if (!res.IsSuccessStatusCode) throw await RenderScreenshotException.FromResponseAsync(res, ct);
        return await res.Content.ReadFromJsonAsync<JsonElement>(ct);
    }
}

TakeToStreamAsync uses HttpCompletionOption.ResponseHeadersRead, so a 5 MB full-page PNG flows to its destination without a second copy in memory. The exception keeps Param, which the API sets on validation errors (for example viewport.width), and RequestId, which support needs. The error codes page lists every code.

Register it with IHttpClientFactory and Polly

using System.Net;
using Microsoft.Extensions.Http.Resilience;
using Polly;
using Polly.Timeout;
using Shots;

var builder = WebApplication.CreateBuilder(args);
var cfg = builder.Configuration;

builder.Services
    .AddHttpClient<RenderScreenshotClient>(http =>
    {
        http.BaseAddress = new Uri(cfg["RS_BASE_URL"] ?? "https://api.renderscreenshot.com/");
        http.DefaultRequestHeaders.Authorization = new("Bearer", cfg["RS_API_KEY"]); // rs_live_...
        http.Timeout = Timeout.InfiniteTimeSpan; // the resilience pipeline owns timeouts
    })
    .AddResilienceHandler("screenshots", pipeline =>
    {
        pipeline.AddTimeout(TimeSpan.FromMinutes(4)); // total, across all attempts
        pipeline.AddRetry(new HttpRetryStrategyOptions
        {
            MaxRetryAttempts = 3,
            BackoffType = DelayBackoffType.Exponential,
            Delay = TimeSpan.FromSeconds(2),
            UseJitter = true,
            ShouldRetryAfterHeader = true, // wait exactly Retry-After on a 429
            ShouldHandle = args => ValueTask.FromResult(
                args.Outcome.Exception is HttpRequestException or TimeoutRejectedException ||
                args.Outcome.Result?.StatusCode is HttpStatusCode.TooManyRequests
                    or HttpStatusCode.RequestTimeout
                    or >= HttpStatusCode.InternalServerError),
        });
        pipeline.AddTimeout(TimeSpan.FromSeconds(90)); // per attempt; a page may wait up to 60 s
    });

The order of the strategies is the design. The first timeout wraps everything, so a request never takes more than four minutes in total. The retry sits inside it. The last timeout limits each attempt to 90 seconds, which is longer than the API's 60-second maximum page wait. A per-attempt timeout raises Polly's TimeoutRejectedException, not TimeoutException, which is why ShouldHandle names it.

What the retry covers, and why:

  • 429. The limit is per account, shared by every key and every instance of your app (rate limits). ShouldRetryAfterHeader makes Polly wait the Retry-After delay. In our run against the stub, a 429 with Retry-After: 1 was followed by exactly one more request, which succeeded.
  • 408 and 5xx, plus network errors. Exponential backoff with jitter.
  • Not 400, 401 or 403. The same request fails the same way until you change it. The pipeline returns the response, and TakeAsync throws RenderScreenshotException.
  • Not 422. A target-page failure such as timeout may pass on a second try, but a blocked page will not. Retry it once in your own code if your workload needs it.

A failed request does not use a credit, so retries are safe for billing. The pipeline retries POST requests, which is what you want for this API. If you share the handler with other APIs, read Microsoft's note on unsafe methods first.

Stream a preview from ASP.NET Core

var app = builder.Build();

// GET /preview?url=https://example.com streams a 1200x630 PNG back to the caller.
app.MapGet("/preview", async (string url, RenderScreenshotClient shots, HttpContext ctx, CancellationToken ct) =>
{
    try
    {
        ctx.Response.ContentType = "image/png";
        await shots.TakeToStreamAsync(new { url, preset = "og_card" }, ctx.Response.Body, ct);
        return Results.Empty;
    }
    catch (RenderScreenshotException e) when (e.Status == HttpStatusCode.BadRequest)
    {
        return Results.Problem(e.Message, statusCode: 400);
    }
});

The request's CancellationToken flows into the HTTP call, so a browser that closes the tab cancels the render request too. In our run, /preview?url=not-a-url returned a problem response with the API's message: 400 invalid_url: The URL 'not-a-url' is not a valid HTTP(S) URL. Do not expose this endpoint to the public without your own check of which URLs it may capture, or anyone can spend your credits.

Signed URLs with HMACSHA256

A signed URL lets a crawler or an <img> tag fetch a screenshot without your API key:

using System.Security.Cryptography;
using System.Text;

namespace Shots;

public static class SignedUrl
{
    const string Endpoint = "https://api.renderscreenshot.com/v1/screenshot";

    // Form encoding as the server does it: A-Z a-z 0-9 - _ . ~ stay, space -> "+", rest -> %XX.
    static string Encode(string value) => Uri.EscapeDataString(value).Replace("%20", "+");

    public static string Create(IDictionary<string, string> parameters, string keyId, string secret, DateTimeOffset expiresAt)
    {
        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)}")
            .Order(StringComparer.Ordinal));

        var mac = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), Encoding.UTF8.GetBytes(query));
        return $"{Endpoint}?{query}&signature={Convert.ToHexStringLower(mac)}";
    }
}

Three .NET details decide whether the signature matches:

  • Uri.EscapeDataString leaves exactly the RFC 3986 unreserved characters alone, which is what the server expects. WebUtility.UrlEncode and HttpUtility.UrlEncode do not. On .NET 10 both left !, *, ( and ) unencoded and turned ~ into %7E, and HttpUtility also wrote lowercase hex.
  • Sort with StringComparer.Ordinal. The default comparer is culture-aware: on .NET 10 it sorted a_b=1, a-b=1, a=1, ab=1, while an ordinal sort, like the server's, gives a-b=1, a=1, a_b=1, ab=1.
  • expires is Unix seconds, at most 30 days ahead, or the API answers invalid_expiration.

Use it in a Razor view or a minimal API, and HTML-encode the result: HtmlEncoder.Default.Encode(url) turns & into &amp;, which browsers decode before the request. Sign only the flat GET parameters, and never add one after signing.

Batch

var batch = await shots.BatchAsync(
    ["https://example.com/a", "https://example.com/b"],
    new { preset = "og_card" });

foreach (var r in batch.GetProperty("results").EnumerateArray())
    Console.WriteLine($"{r.GetProperty("url")} {r.GetProperty("status")}");

One POST /v1/batch counts as one request against the per-minute limit. Up to 10 URLs without a webhook come back in the same response; larger batches return an id to poll. See the batch endpoint.

Testing with xUnit

Replace the primary handler with a fake, and keep the real resilience pipeline so the tests cover your retry rules:

using System.Net;
using System.Text;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http.Resilience;
using Polly;
using Shots;
using Xunit;

public class FakeApi(params Func<HttpResponseMessage>[] replies) : HttpMessageHandler
{
    public List<string> Bodies { get; } = [];
    int _next;

    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage req, CancellationToken ct)
    {
        Bodies.Add(req.Content is null ? "" : await req.Content.ReadAsStringAsync(ct));
        return replies[Math.Min(_next++, replies.Length - 1)]();
    }
}

public class ClientTests
{
    static HttpResponseMessage Png() => new(HttpStatusCode.OK)
    {
        Content = new ByteArrayContent([0x89, 0x50, 0x4E, 0x47]) { Headers = { ContentType = new("image/png") } },
    };

    static HttpResponseMessage Error(HttpStatusCode status, string code, int? retryAfter = null)
    {
        var res = new HttpResponseMessage(status)
        {
            Content = new StringContent($$"""{"error":{"type":"x","code":"{{code}}","message":"m"},"request_id":"req_1"}""",
                Encoding.UTF8, "application/json"),
        };
        if (retryAfter is int s) res.Headers.RetryAfter = new(TimeSpan.FromSeconds(s));
        return res;
    }

    static RenderScreenshotClient Build(FakeApi api)
    {
        var services = new ServiceCollection();
        services.AddHttpClient<RenderScreenshotClient>(h => h.BaseAddress = new Uri("https://api.renderscreenshot.com/"))
            .ConfigurePrimaryHttpMessageHandler(() => api)
            .AddResilienceHandler("t", p => p.AddRetry(new HttpRetryStrategyOptions
            {
                MaxRetryAttempts = 2,
                Delay = TimeSpan.Zero,
                ShouldRetryAfterHeader = true,
            }));
        return services.BuildServiceProvider().GetRequiredService<RenderScreenshotClient>();
    }

    [Fact]
    public async Task Retries_a_429_then_succeeds()
    {
        var api = new FakeApi(() => Error(HttpStatusCode.TooManyRequests, "too_many_requests", retryAfter: 0), Png);
        await Build(api).TakeAsync(new { url = "https://example.com" });

        Assert.Equal(2, api.Bodies.Count);
    }

    [Fact]
    public async Task A_400_is_not_retried_and_exposes_the_code()
    {
        var api = new FakeApi(() => Error(HttpStatusCode.BadRequest, "invalid_url"));
        var e = await Assert.ThrowsAsync<RenderScreenshotException>(() => Build(api).TakeAsync(new { url = "nope" }));

        Assert.Equal("invalid_url", e.Code);
        Assert.Single(api.Bodies);
    }

    [Fact]
    public void Signed_url_matches_the_reference_vector()
    {
        var url = SignedUrl.Create(new Dictionary<string, string>
        {
            ["url"] = "https://example.com/path?a=1&b=two words",
            ["width"] = "1200", ["height"] = "630", ["format"] = "png", ["full_page"] = "true",
        }, "rs_pub_vector123", "rs_secret_vector456", DateTimeOffset.FromUnixTimeSeconds(1893456000));

        Assert.EndsWith("signature=ff30f019a228e751b71f4bdca8c5341c33eaac0985a18426882525de00ba329a", url);
    }
}

Keep the last test forever: it uses the reference vector from our signing docs, so an encoder change fails in CI, not in production.

API or a browser in .NET?

If you capture only your own pages and can ship Chromium, a browser library works; see PuppeteerSharp vs Playwright for .NET. To render an HTML string instead of a URL, see HTML to image in C#. For the wire format in other languages, read the raw HTTP API guide.

Get a free API key and point RS_API_KEY at it. The free plan includes 150 credits every month.

Frequently asked questions

Is there an official C# or .NET SDK for RenderScreenshot?
Not yet. The API is plain HTTPS with a Bearer token and a JSON body, so a typed HttpClient of about 60 lines covers it. Every listing on this page ran on .NET 10.
Does Microsoft.Extensions.Http.Resilience wait for the Retry-After header?
Yes. HttpRetryStrategyOptions has ShouldRetryAfterHeader, and when it is true a 429 waits for the delay in Retry-After instead of the computed backoff. Our test sent a 429 with Retry-After and saw exactly one retry.
How do I encode the query string for a signed URL in C#?
Use Uri.EscapeDataString on each name and value, then replace %20 with +. That matches the server's form encoding: A-Z, a-z, 0-9 and - _ . ~ stay as they are, a space becomes +, and everything else becomes %XX.
Should I use PuppeteerSharp or Playwright for .NET instead?
Use a browser library if you only capture your own pages and can ship Chromium with your app. Use the API if you capture third-party pages or run on a host where you cannot install a browser and its system libraries.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.