# 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 2026-11-08 · https://shareglint.com/en/screenshot-api/csharp

.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

```csharp
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`):

```csharp
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](https://shareglint.com/en/guides/bulk-website-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](https://playwright.dev/dotnet/docs/screenshots) 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#](https://shareglint.com/en/guides/html-to-image-csharp). Microsoft's [HttpClient guidelines](https://learn.microsoft.com/en-us/dotnet/fundamentals/networking/http/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.
