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; 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 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 ==.