Menu
Sign up

Screenshot API in Go: net/http, full page, signed URLs and webhooks

Website screenshots from Go with net/http and no browser: full page, devices, cookie banners, a signed URL for img tags and webhook checks, all tested.

Published November 8, 2026 · 5 min read

Go is a common choice for the services that need website screenshots — link previews, crawlers, monitoring, report generators — and also a language where running a headless Chrome is real work: a browser binary in the container, fonts, memory per tab, timeouts. This page shows the other route: one HTTP request per screenshot from the standard library, with a signed URL for <img> tags and webhook verification. Every function below was compiled and run with Go 1.25.14 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

No dependencies beyond the standard library. Set SHOT_API_KEY (and, for signed URLs, SHOT_ACCOUNT_ID and SHOT_SIGNING_SECRET) in the environment.

package main

import (
	"bytes"
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"os"
	"sort"
	"strings"
	"time"
)

const api = "https://shareglint.com/v1"

type Screenshot struct {
	ID     string `json:"id"`
	Status string `json:"status"`
	URL    string `json:"url"`
	Error  any    `json:"error"`
}

var client = &http.Client{Timeout: 60 * time.Second}

// Take sends one screenshot request and waits for the finished image.
func Take(options map[string]any) (*Screenshot, error) {
	body, _ := json.Marshal(options)
	req, _ := http.NewRequest("POST", api+"/screenshots?wait=true", bytes.NewReader(body))
	req.Header.Set("Authorization", "Bearer "+os.Getenv("SHOT_API_KEY"))
	req.Header.Set("Content-Type", "application/json")
	res, err := client.Do(req)
	if err != nil {
		return nil, err
	}
	defer res.Body.Close()
	var shot Screenshot
	if err := json.NewDecoder(res.Body).Decode(&shot); err != nil {
		return nil, err
	}
	if res.StatusCode >= 300 {
		return &shot, fmt.Errorf("screenshot failed: HTTP %d, %v", res.StatusCode, shot.Error)
	}
	return &shot, nil
}

// SignedURL builds a /s/ URL: the HMAC covers the path and every parameter,
// sorted by name and percent-encoded (RFC 3986: a space is %20, not +).
func SignedURL(accountID, secret, ext string, params map[string]string) string {
	names := make([]string, 0, len(params))
	for name := range params {
		names = append(names, name)
	}
	sort.Strings(names)
	pairs := make([]string, 0, len(names))
	for _, name := range names {
		pairs = append(pairs, encode(name)+"="+encode(params[name]))
	}
	path := "/s/" + accountID + "." + ext
	query := strings.Join(pairs, "&")
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(path + "?" + query))
	s := hex.EncodeToString(mac.Sum(nil))[:32]
	return strings.TrimSuffix(api, "/v1") + path + "?" + query + "&s=" + s
}

// encode is url.QueryEscape with %20 for spaces, which RFC 3986 requires.
func encode(v string) string { return strings.ReplaceAll(url.QueryEscape(v), "+", "%20") }

// VerifyWebhook checks X-Webhook-Signature: hex HMAC-SHA256 of timestamp + "." + raw body.
func VerifyWebhook(rawBody []byte, timestamp, signature, secret string) bool {
	if timestamp == "" || signature == "" {
		return false
	}
	var ts int64
	if _, err := fmt.Sscan(timestamp, &ts); err != nil || abs(time.Now().Unix()-ts) > 300 {
		return false
	}
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(timestamp + "."))
	mac.Write(rawBody)
	return hmac.Equal([]byte(hex.EncodeToString(mac.Sum(nil))), []byte(signature))
}

func abs(n int64) int64 {
	if n < 0 {
		return -n
	}
	return n
}

And a main that uses all three:

func main() {
	shot, err := Take(map[string]any{"url": "https://example.com/", "full_page": true, "block_cookie_banners": true})
	if err != nil {
		fmt.Println(err)
		os.Exit(1)
	}
	fmt.Println(shot.Status, shot.URL)

	signed := SignedURL(os.Getenv("SHOT_ACCOUNT_ID"), os.Getenv("SHOT_SIGNING_SECRET"), "png",
		map[string]string{"url": "https://example.com/", "device": "mobile", "block_cookie_banners": "true"})
	noRedirect := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, Timeout: 60 * time.Second}
	res, err := noRedirect.Get(signed)
	if err == nil {
		io.Copy(io.Discard, res.Body)
		fmt.Println("signed URL:", res.StatusCode)
	}
}

gofmt leaves both blocks unchanged.

One screenshot

Take posts the options as JSON and waits for the image (?wait=true). The response's URL is the finished file; download it with http.Get if you need the bytes. 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

Give the http.Client a timeout longer than the capture: full pages of heavy sites can take tens of seconds.

A signed URL for templates

A Go html/template that shows a screenshot as an <img> should not call the API on every page view. SignedURL builds a /s/ link that anyone can open and nobody can change: the HMAC covers the path and every parameter, sorted and encoded per RFC 3986. The one Go-specific trap is url.QueryEscape, which writes a space as +; the signature needs %20, so encode replaces it. The first request captures the page and charges it; 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 your handler, read the raw body first (io.ReadAll(r.Body)), pass it to VerifyWebhook with the X-Webhook-Timestamp and X-Webhook-Signature headers, and only then decode the JSON. hmac.Equal 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 Chrome from Go

If the pages are behind your login or on a private network, an API cannot reach them; drive Chrome yourself. chromedp is the common pure-Go option, speaking the DevTools protocol directly, with FullScreenshot for full pages. You then own the browser: a container image with Chrome and fonts, enough memory per concurrent tab, and timeouts for pages that never finish loading. For public pages, the HTTP call above is less to run.

The API's full parameter list is in the documentation; pricing per plan is on the pricing page. Go's own crypto/hmac docs explain why hmac.Equal instead of ==.

FAQ

How do I take a website screenshot in Go?

Either drive a headless Chrome from Go with a library such as chromedp, or send one HTTP request to a screenshot API with net/http: 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 Go?

Sort the parameters by name, percent-encode names and values per RFC 3986 (url.QueryEscape writes a space as +, so replace it with %20), join them after the path with ?, and take the first 32 hex characters of HMAC-SHA256 with your signing secret as s. Our Go function returned a 302 from the server.

How do I verify the webhook signature in Go?

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 hmac.Equal. 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.