Capture a website screenshot with C#.

HttpClient, one Program.cs, and an image on disk. Nothing to add from NuGet for a single HTTP request.

Then what an application needs around it: errors and retries, a JSON body, a route in an ASP.NET Core minimal API, and a signed URL to put in a page.

What you need

  • The .NET 8 SDK or newer. HttpClient and System.Text.Json are in the base library: no NuGet packages.
  • An API key from the dashboard, in an environment variable rather than in the source.
  • A free account, if you do not have one: 100 screenshots a month, no card.

Create the key on the API keys page. It is shown once, at creation, so store it before closing the dialog, and keep it on the server: a key in a desktop or mobile app is readable by everyone who has the app.

The request

Create a console project:

Bash
dotnet new console -o Screenshot
cd Screenshot

Replace its Program.cs with this. It asks for one page at a 1280 × 800 viewport, at twice the pixel density, as WebP, and writes what comes back to a file:

Program.cs
// Program.cs in a `dotnet new console` project: request one screenshot and save it.
using System.Net.Http.Headers;
using System.Text.Json;

var query = await new FormUrlEncodedContent(
[
    new("url", "https://en.wikipedia.org/wiki/Cartography"),
    new("format", "webp"),
    new("quality", "80"),
    new("viewport_width", "1280"),
    new("viewport_height", "800"),
    new("device_scale_factor", "2"),
]).ReadAsStringAsync();

using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(60) };
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
    "Bearer", Environment.GetEnvironmentVariable("URLSHOT_API_KEY"));

using var response = await client.GetAsync($"https://api.urlshot.io/v1/screenshot?{query}");

// Anything that is not an image is the JSON error envelope.
if (!response.IsSuccessStatusCode)
{
    using var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
    var error = body.RootElement.GetProperty("error");
    throw new InvalidOperationException(
        $"{error.GetProperty("code")}: {error.GetProperty("message")} (request {error.GetProperty("requestId")})");
}

await File.WriteAllBytesAsync("screenshot.webp", await response.Content.ReadAsByteArrayAsync());
Console.WriteLine("Wrote screenshot.webp");

Run it

Bash
# PowerShell
$env:URLSHOT_API_KEY = "sk_your_key_here"
dotnet run

# bash or zsh
export URLSHOT_API_KEY=sk_your_key_here
dotnet run

One render, one credit. The project's implicit usings bring in System.Net.Http, System.IO and LINQ, so the file names only the two namespaces they leave out.

What comes back

On success, ReadAsByteArrayAsync gives you the image itself: no JSON wrapper, no base64. This is the file that program writes:

The Wikipedia article on cartography, captured by the API at 1280 pixels wide
/v1/screenshot?url=https://en.wikipedia.org/wiki/Cartography&format=webp&quality=80&viewport_width=1280&viewport_height=800&device_scale_factor=2

Every response also carries headers that account for the call:

Response headers returned with a screenshot.
X-Request-IDOpaque identifier for this request. Quote it in any support conversation.
X-Urlshot-CacheHIT when the image was served from cache, MISS when it was rendered, BYPASS when cache_ttl was 0.
X-Urlshot-Credits-UsedRender credits consumed by this request. A cache hit consumes zero.
X-RateLimit-LimitRenders your plan may run at once.
X-Urlshot-RendererThe renderer build that produced the image, such as v4:4572cafff9ea; on a cache hit, the build that made the cached image. For support conversations, not for branching on.
X-RateLimit-RemainingRender slots still free after this request. Absent on a cache hit, where it is unknown rather than zero.
Cache-ControlHow long a browser may reuse the image: private, max-age=<seconds> when cache_ttl is set, and on a cache hit no longer than the cached copy has left; no-store when cache_ttl is 0.

They are on response.Headers; Content-Type and the other headers about the body are on response.Content.Headers instead. Names are matched whatever their case:

csharp
// After the request: what it cost, and how to refer to it. Header names are
// matched whatever their case.
var credits = response.Headers.GetValues("X-Urlshot-Credits-Used").First(); // "1", or "0" from cache
var requestId = response.Headers.GetValues("X-Request-ID").First();
Console.WriteLine($"request {requestId} used {credits} credit(s)");

Errors and retries

A failed call returns JSON instead of an image, with a stable code, a message, and the requestId to quote if you ask about it. The programs here read all three with JsonDocument and throw.

These codes are worth retrying, and each comes with a Retry-After header in seconds:

  • concurrency_limit_exceeded, 429: The workspace is already running the maximum number of concurrent renders.
  • rate_limit_exceeded, 429: The request rate limit for this API key has been exceeded.
  • service_unavailable, 503: The screenshot service is temporarily unavailable.

Every other code fails the same way however often you send it, and one that got as far as the browser has already cost a credit. So retry on Retry-After and nothing else:

Program.cs
using System.Net.Http.Headers;
using System.Text.Json;

using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(60) };
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
    "Bearer", Environment.GetEnvironmentVariable("URLSHOT_API_KEY"));

// Requests a screenshot, retrying only when the API says how long to wait.
async Task<byte[]> Screenshot(string query, int attempts = 4)
{
    for (var attempt = 1; ; attempt++)
    {
        using var response = await client.GetAsync($"https://api.urlshot.io/v1/screenshot?{query}");
        if (response.IsSuccessStatusCode) return await response.Content.ReadAsByteArrayAsync();

        // Only a 429 or 503 carries Retry-After, in seconds. Anything else fails
        // the same way however often it is sent.
        var retryAfter = response.Headers.RetryAfter?.Delta;
        if (retryAfter is null || attempt == attempts)
        {
            using var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
            var error = body.RootElement.GetProperty("error");
            throw new InvalidOperationException(
                $"{error.GetProperty("code")}: {error.GetProperty("message")} (request {error.GetProperty("requestId")})");
        }
        await Task.Delay(retryAfter.Value);
    }
}

var query = await new FormUrlEncodedContent(
[
    new("url", "https://en.wikipedia.org/wiki/Cartography"),
    new("format", "webp"),
]).ReadAsStringAsync();

await File.WriteAllBytesAsync("screenshot.webp", await Screenshot(query));
Console.WriteLine("Wrote screenshot.webp");

response.Headers.RetryAfter parses the header for you; Delta is the wait as a TimeSpan. With Polly or Microsoft.Extensions.Http.Resilience in your project, give its retry the same rule: Retry-After, not every 5xx. Every code is in the error reference.

More options

Every option is a query parameter with a default. These change a capture the most:

8 of the 17 options, with their ranges and defaults.
full_pageCapture the full scrollable page instead of only the viewport.boolean
formatOutput image format.png | jpeg | webp
qualityEncoder quality for jpeg and webp. Defaults to 80 for those formats and must be omitted for png.1–100
viewport_widthViewport width in CSS pixels.200–3840
device_scale_factorDevice pixel ratio to emulate.0.5–3
dark_modeEmulate prefers-color-scheme: dark before navigation.boolean
hide_selectorsCSS selector whose matching elements are hidden after navigation. Repeatable.string[]
block_cookie_bannersHide known consent dialogs and restore page scrolling before capturing. Available on every plan.boolean

FormUrlEncodedContent encodes every name and value. Give it a list of pairs, not the Dictionary most examples use: a dictionary cannot hold hide_selectors twice, and a list can, once for each selector:

Program.cs
using System.Net.Http.Headers;
using System.Text.Json;

// A list of pairs, not a Dictionary: a list can hold hide_selectors once for
// each selector, which is how the API takes a list.
var query = await new FormUrlEncodedContent(
[
    new("url", "https://en.wikipedia.org/wiki/Atlas"),
    new("full_page", "true"),
    new("format", "png"),
    new("hide_selectors", ".vector-header-container"),
    new("hide_selectors", ".mw-footer"),
]).ReadAsStringAsync();

using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(60) };
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
    "Bearer", Environment.GetEnvironmentVariable("URLSHOT_API_KEY"));

using var response = await client.GetAsync($"https://api.urlshot.io/v1/screenshot?{query}");

// Anything that is not an image is the JSON error envelope.
if (!response.IsSuccessStatusCode)
{
    using var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
    var error = body.RootElement.GetProperty("error");
    throw new InvalidOperationException(
        $"{error.GetProperty("code")}: {error.GetProperty("message")} (request {error.GetProperty("requestId")})");
}

await File.WriteAllBytesAsync("atlas.png", await response.Content.ReadAsByteArrayAsync());
Console.WriteLine("Wrote atlas.png");

That is the whole Atlas article as a PNG, without Wikipedia's header and footer. The [ … ] is a C# 12 collection expression, which .NET 8 has. All 17 options are in the parameter reference.

POST with a JSON body

The same options can go in a JSON body, which is easier once you send CSS: nothing to encode. PostAsJsonAsync serializes an anonymous object with JSON's own true and arrays — a string "true" is rejected in a body:

Program.cs
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;

using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(60) };
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
    "Bearer", Environment.GetEnvironmentVariable("URLSHOT_API_KEY"));

// Property names as the API spells them; the camelCase policy PostAsJsonAsync
// applies leaves them as they are. JSON types: true rather than "true", and a
// list as an array.
using var response = await client.PostAsJsonAsync("https://api.urlshot.io/v1/screenshot", new
{
    url = "https://en.wikipedia.org/wiki/Atlas",
    full_page = true,
    format = "png",
    hide_selectors = new[] { ".vector-header-container", ".mw-footer" },
    custom_css = "#siteNotice { display: none !important }",
});

// Anything that is not an image is the JSON error envelope.
if (!response.IsSuccessStatusCode)
{
    using var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
    var error = body.RootElement.GetProperty("error");
    throw new InvalidOperationException(
        $"{error.GetProperty("code")}: {error.GetProperty("message")} (request {error.GetProperty("requestId")})");
}

await File.WriteAllBytesAsync("atlas.png", await response.Content.ReadAsByteArrayAsync());

Put every option in the body: a POST that also has query parameters is rejected. The rest of the rules are under POST with a JSON body.

In an ASP.NET Core app

In a web app, call the API from your server and send the image on, so the key never reaches a browser. In a dotnet new web project, this is the whole Program.cs:

Program.cs
// Program.cs in a `dotnet new web` project.
using System.Net.Http.Headers;

var builder = WebApplication.CreateBuilder(args);

// One named client, configured once. IHttpClientFactory pools its connections.
builder.Services.AddHttpClient("urlshot", client =>
{
    client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
        "Bearer", builder.Configuration["URLSHOT_API_KEY"]);
    client.Timeout = TimeSpan.FromSeconds(60);
});

var app = builder.Build();

// The pages this app shows previews of. Visitors pick a name, never an
// address: a route that took any URL would render any page on your credits.
var pages = new Dictionary<string, string>
{
    ["home"] = "https://example.com",
    ["atlas"] = "https://en.wikipedia.org/wiki/Atlas",
};

app.MapGet("/previews/{page}", async (string page, IHttpClientFactory clients, HttpContext context, ILogger<Program> logger) =>
{
    if (!pages.TryGetValue(page, out var url)) return Results.NotFound();

    var query = await new FormUrlEncodedContent(
    [
        new("url", url),
        new("viewport_width", "1280"),
        new("format", "webp"),
        new("cache_ttl", "3600"),
    ]).ReadAsStringAsync();

    using var response = await clients.CreateClient("urlshot").GetAsync($"https://api.urlshot.io/v1/screenshot?{query}");
    if (!response.IsSuccessStatusCode)
    {
        // The body is the JSON error envelope: its code and requestId say what happened.
        logger.LogError("urlshot.io refused a preview: {Body}", await response.Content.ReadAsStringAsync());
        return Results.StatusCode(StatusCodes.Status502BadGateway);
    }

    context.Response.Headers.CacheControl = "public, max-age=3600";
    return Results.File(
        await response.Content.ReadAsByteArrayAsync(),
        response.Content.Headers.ContentType?.MediaType);
});

app.Run();

  • One client, from the factory. IHttpClientFactory pools the connections underneath; a new HttpClient() for every request leaves sockets waiting to close and runs out of them under load.
  • Visitors choose a name, not an address. A route that rendered whatever URL it was given would let anyone spend your credits on pages of their own.
  • Repeat views are free. cache_ttl=3600 serves the same page from cache for an hour at no cost, the longest the free plan allows. See caching and credits.
  • A failure is logged, not passed on. The route logs the envelope and answers 502.

builder.Configuration["URLSHOT_API_KEY"] reads the environment variable, or the same key from user secrets or appsettings.json. Run dotnet run and open /previews/home at the address it prints.

Signed URLs

To put a capture straight into a page's <img>, sign the URL on your server instead. Anyone can load a signed URL, and changing any of its parameters breaks the signature:

Program.cs
// .NET 6 or later: a `dotnet new console` project. No packages.
using System.Security.Cryptography;
using System.Text;

static string SignedUrl(IDictionary<string, string> options)
{
    var parameters = new Dictionary<string, string>
    {
        ["key"] = Environment.GetEnvironmentVariable("URLSHOT_KEY")!,
    };
    foreach (var (name, value) in options) parameters[name] = value;

    var query = string.Join("&", parameters.Select(p =>
        $"{Uri.EscapeDataString(p.Key)}={Uri.EscapeDataString(p.Value)}"));

    var secret = Encoding.UTF8.GetBytes(Environment.GetEnvironmentVariable("URLSHOT_SIGNING_SECRET")!);
    var hash = HMACSHA256.HashData(secret, Encoding.UTF8.GetBytes(query));

    // Lowercase: the signature is compared exactly, and Convert.ToHexString returns uppercase.
    var signature = Convert.ToHexString(hash).ToLowerInvariant();

    return $"https://api.urlshot.io/v1/screenshot?{query}&signature={signature}";
}

var src = SignedUrl(new Dictionary<string, string>
{
    ["url"] = "https://example.com",
    ["viewport_width"] = "1200",
});

URLSHOT_KEY is a publishable key (pk_…) and URLSHOT_SIGNING_SECRET its signing secret, both from the dashboard. Note the ToLowerInvariant(): Convert.ToHexString returns uppercase, and the API compares the signature exactly. A signed URL does not expire, so add a cache_ttl to any you publish; the reference explains why under signed URLs.

Where to go next

  • The API reference — every parameter, response header and error code, generated from the contract the gateway validates against.
  • Website thumbnails — small previews of many sites for a directory, a CRM or a dashboard.
  • Pricing — what a credit costs once the free allowance runs out.

The same examples in other languages.

Or see every code example.

Run it with your own key.

100 screenshots a month on the free plan. No card needed.