.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; pollGET /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.