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

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.

```go
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:

```go
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](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 Chrome from Go

If the pages are behind your login or on a private network, an API cannot reach them; drive Chrome yourself. [chromedp](https://github.com/chromedp/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](https://shareglint.com/en/docs/api); pricing per plan is on the [pricing page](https://shareglint.com/en/pricing). Go's own [`crypto/hmac`](https://pkg.go.dev/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.
