Menu
Sign up

Screenshot API in C# and .NET: HttpClient, full page, signed URLs and webhooks

Website screenshots from C# with HttpClient and no browser: full page, devices, a signed URL for img tags and webhook verification, tested on .NET 10.

Published November 8, 2026 · 5 min read

.NET applications that need screenshots of web pages — reports, link previews, archives, monitoring — often add PuppeteerSharp or Playwright and, with them, a Chromium to ship and keep alive on every server. This page shows the other route: one request per screenshot with HttpClient, a signed URL for <img> tags in Razor, and webhook verification, with nothing beyond the framework. The code below was built and run with .NET 10 (SDK 10.0.403) against our API on 2026-11-08: the screenshot completed, the signed URL answered 302, and the webhook check accepted a correct signature and rejected a changed body.

The code

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

public static class Screenshots
{
    const string Base = "https://shareglint.com";
    static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(60) };

    public record Screenshot(string Id, string Status, string? Url, JsonElement? Error);

    /// <summary>One screenshot, waiting for the finished image.</summary>
    public static async Task<Screenshot> TakeAsync(object options)
    {
        using var req = new HttpRequestMessage(HttpMethod.Post, $"{Base}/v1/screenshots?wait=true")
        {
            Content = JsonContent.Create(options),
        };
        req.Headers.Authorization = new("Bearer", Environment.GetEnvironmentVariable("SHOT_API_KEY"));
        using var res = await Http.SendAsync(req);
        var shot = await res.Content.ReadFromJsonAsync<Screenshot>(new JsonSerializerOptions(JsonSerializerDefaults.Web))
            ?? throw new InvalidOperationException("empty response");
        if (!res.IsSuccessStatusCode) throw new HttpRequestException($"HTTP {(int)res.StatusCode}: {shot.Error}");
        return shot;
    }

    /// <summary>A signed /s/ URL: HMAC over the path and the sorted, RFC 3986-encoded parameters.</summary>
    public static string SignedUrl(string accountId, string secret, string ext, IDictionary<string, string> parameters)
    {
        var query = string.Join("&", parameters.OrderBy(p => p.Key, StringComparer.Ordinal)
            .Select(p => $"{Uri.EscapeDataString(p.Key)}={Uri.EscapeDataString(p.Value)}"));
        var path = $"/s/{accountId}.{ext}";
        var s = HmacHex(secret, $"{path}?{query}")[..32];
        return $"{Base}{path}?{query}&s={s}";
    }

    static string HmacHex(string secret, string data) =>
        Convert.ToHexStringLower(HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), Encoding.UTF8.GetBytes(data)));

    /// <summary>X-Webhook-Signature = hex HMAC-SHA256 of timestamp + "." + raw body.</summary>
    public static bool VerifyWebhook(string rawBody, string? timestamp, string? signature, string secret)
    {
        if (timestamp is null || signature is null || !long.TryParse(timestamp, out var ts)) return false;
        if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - ts) > 300) return false;
        var expected = Encoding.UTF8.GetBytes(HmacHex(secret, $"{timestamp}.{rawBody}"));
        return CryptographicOperations.FixedTimeEquals(expected, Encoding.UTF8.GetBytes(signature));
    }
}

And a few lines that use it (top-level statements in Program.cs):

var shot = await Screenshots.TakeAsync(new { url = "https://example.com/", full_page = true, block_cookie_banners = true });
Console.WriteLine($"{shot.Status} {shot.Url}");
var signed = Screenshots.SignedUrl(Environment.GetEnvironmentVariable("SHOT_ACCOUNT_ID")!, Environment.GetEnvironmentVariable("SHOT_SIGNING_SECRET")!, "png",
    new Dictionary<string, string> { ["url"] = "https://example.com/", ["device"] = "mobile", ["block_cookie_banners"] = "true" });
using var noRedirect = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false });
Console.WriteLine($"signed URL: {(int)(await noRedirect.GetAsync(signed)).StatusCode}");

Set SHOT_API_KEY, and for signed URLs SHOT_ACCOUNT_ID and SHOT_SIGNING_SECRET, in the environment. Convert.ToHexStringLower needs .NET 9 or newer; on older versions use Convert.ToHexString(...).ToLowerInvariant().

One screenshot

TakeAsync posts the options as JSON with ?wait=true; the response's Url is the finished image. Anonymous objects keep the snake_case option names (full_page, block_cookie_banners) exactly as the API expects. Useful options:

Option Example Effect
full_page true The whole scrollable page (2 renders)
lazy_load true Scrolls first so lazy images load
device "iphone-15", "pixel-8" Phone or tablet viewport, pixel ratio and user agent
selector "#pricing" Only that element
wait_for ".chart.ready" Waits for an element before the capture
block_cookie_banners true Accepts or hides consent dialogs
format "jpeg", "webp", "pdf" Output type; PNG by default

HttpClient should be created once and reused, as in the example — a new client per request exhausts sockets under load. Its timeout (60 s here) must be longer than the capture.

Saving the file

The API returns a URL, not the bytes. To keep a copy — in blob storage, on disk, in a database — download it with your HttpClient: var bytes = await http.GetByteArrayAsync(shot.Url);. Our copy stays available for 365 days on paid plans; an archive needs its own. For a PDF of the page instead of an image, send format = "pdf"; for a phone layout, device = "iphone-15". The same TakeAsync works for every variant, since only the options object changes.

A signed URL for Razor views

A page that shows a screenshot as an <img> should not call the API on every view. SignedUrl builds a /s/ link that anyone can open and nobody can change. Two details matter: Uri.EscapeDataString already encodes per RFC 3986 (a space is %20), and the sort must be ordinal (StringComparer.Ordinal), because culture-aware sorting can order names differently from the server. The first request captures and charges; repeats within 24 hours redirect to the cached file.

Queue and webhook

For many pages, send requests without ?wait=true and with a webhook_url; the API answers at once with an id, and we call your endpoint with screenshot.completed or screenshot.failed. In an ASP.NET Core minimal API, read the raw body with await new StreamReader(request.Body).ReadToEndAsync(), pass it with the X-Webhook-Timestamp and X-Webhook-Signature headers to VerifyWebhook, and only then deserialize. CryptographicOperations.FixedTimeEquals compares in constant time. For lists of URLs, see bulk screenshots.

Errors

  • 402 quota_exceeded — no renders left; nothing was charged.
  • 422 — an invalid option or a non-public URL; do not retry.
  • 429 — rate limited; wait for Retry-After.
  • 500 with ?wait=true — the page failed to load; the error says why, and the render is given back.
  • 503 sync_overloaded — still running; poll GET /v1/screenshots/{id}.

Send an Idempotency-Key header when you retry a POST, so a second attempt returns the first result instead of a second capture.

Or run a browser from .NET

If the pages are behind your login or on a private network, drive a browser yourself: Playwright for .NET has FullPage = true built in, and PuppeteerSharp mirrors Puppeteer's API. You then own the browser: the download, fonts in your container, memory per page, crashes. For HTML you render yourself — invoices, cards — see HTML to image in C#. Microsoft's HttpClient guidelines explain the reuse rule above.

FAQ

How do I take a website screenshot in C#?

Either drive a browser with PuppeteerSharp, Playwright for .NET or Selenium, or send one HTTP request to a screenshot API with HttpClient: POST the page URL and options as JSON to /v1/screenshots?wait=true and read the image URL from the response. The second needs no browser on your server.

How do I sign a screenshot URL in C#?

Sort the parameters by name with ordinal comparison, encode each name and value with Uri.EscapeDataString (RFC 3986, a space is %20), join them after the path with ?, and take the first 32 hex characters of HMACSHA256 with your signing secret as s. Our C# method returned a 302 from the server.

How do I verify the webhook signature in C#?

Compute hex HMAC-SHA256 over the X-Webhook-Timestamp header, a dot and the raw request body, with your webhook secret, and compare it to X-Webhook-Signature with CryptographicOperations.FixedTimeEquals. Reject timestamps older than five minutes.

What does a screenshot cost?

A viewport or element screenshot uses 1 render of the monthly quota, a full page 2. The same URL with the same options within 24 hours returns the existing file for free.