Capture a website screenshot with Go.

net/http, one file, and an image on disk. The standard library has everything a screenshot request needs.

Then what a service needs around it: errors and retries, a JSON body, an HTTP handler that streams the image to a visitor, and a signed URL to put in a page.

What you need

  • Go 1.22 or newer. Everything used here is in the standard library: no modules to fetch.
  • An API key from the dashboard, in an environment variable rather than in the source.
  • A free account, if you do not have one: 100 screenshots a month, no card.

Create the key on the API keys page. It is shown once, at creation, so store it before closing the dialog, and keep it on the server: a key in a client binary is readable by everyone who has it.

The request

Save this as screenshot.go. It asks for one page at a 1280 × 800 viewport, at twice the pixel density, as WebP, and writes what comes back to a file:

screenshot.go
// screenshot.go: request a screenshot and save it. Run: go run screenshot.go
package main

import (
	"encoding/json"
	"fmt"
	"io"
	"log"
	"net/http"
	"net/url"
	"os"
	"time"
)

// errorEnvelope is what every failed call returns instead of an image.
type errorEnvelope struct {
	Error struct {
		Code      string `json:"code"`
		Message   string `json:"message"`
		RequestID string `json:"requestId"`
	} `json:"error"`
}

func main() {
	query := url.Values{}
	query.Set("url", "https://en.wikipedia.org/wiki/Cartography")
	query.Set("format", "webp")
	query.Set("quality", "80")
	query.Set("viewport_width", "1280")
	query.Set("viewport_height", "800")
	query.Set("device_scale_factor", "2")

	request, err := http.NewRequest(http.MethodGet, "https://api.urlshot.io/v1/screenshot?"+query.Encode(), nil)
	if err != nil {
		log.Fatal(err)
	}
	request.Header.Set("Authorization", "Bearer "+os.Getenv("URLSHOT_API_KEY"))

	client := &http.Client{Timeout: 60 * time.Second}
	response, err := client.Do(request)
	if err != nil {
		log.Fatal(err)
	}
	defer response.Body.Close()

	body, err := io.ReadAll(response.Body)
	if err != nil {
		log.Fatal(err)
	}

	// Anything that is not an image is the JSON error envelope.
	if response.StatusCode != http.StatusOK {
		var envelope errorEnvelope
		if err := json.Unmarshal(body, &envelope); err != nil {
			log.Fatal(err)
		}
		log.Fatalf("%s: %s (request %s)", envelope.Error.Code, envelope.Error.Message, envelope.Error.RequestID)
	}

	if err := os.WriteFile("screenshot.webp", body, 0o644); err != nil {
		log.Fatal(err)
	}
	fmt.Println("Wrote screenshot.webp")
}

Run it

Bash
export URLSHOT_API_KEY=sk_your_key_here
go run screenshot.go

One render, one credit. The http.Client has a timeout because the default one has none: a connection that stops answering would hold the program for ever.

What comes back

On success, the body is the image itself: no JSON wrapper, no base64. This is the file that program writes:

The Wikipedia article on cartography, captured by the API at 1280 pixels wide
/v1/screenshot?url=https://en.wikipedia.org/wiki/Cartography&format=webp&quality=80&viewport_width=1280&viewport_height=800&device_scale_factor=2

Every response also carries headers that account for the call:

Response headers returned with a screenshot.
X-Request-IDOpaque identifier for this request. Quote it in any support conversation.
X-Urlshot-CacheHIT when the image was served from cache, MISS when it was rendered, BYPASS when cache_ttl was 0.
X-Urlshot-Credits-UsedRender credits consumed by this request. A cache hit consumes zero.
X-RateLimit-LimitRenders your plan may run at once.
X-Urlshot-RendererThe renderer build that produced the image, such as v4:4572cafff9ea; on a cache hit, the build that made the cached image. For support conversations, not for branching on.
X-RateLimit-RemainingRender slots still free after this request. Absent on a cache hit, where it is unknown rather than zero.
Cache-ControlHow long a browser may reuse the image: private, max-age=<seconds> when cache_ttl is set, and on a cache hit no longer than the cached copy has left; no-store when cache_ttl is 0.

Header.Get matches a name whatever its case, and returns an empty string for one that is not there:

go
// After the request: what it cost, and how to refer to it. Header.Get
// matches a name whatever its case.
credits := response.Header.Get("X-Urlshot-Credits-Used") // "1", or "0" from cache
requestID := response.Header.Get("X-Request-ID")
fmt.Printf("request %s used %s credit(s)\n", requestID, credits)

Errors and retries

A failed call returns JSON instead of an image, with a stable code, a message, and the requestId to quote if you ask about it. The programs here decode it into a small struct and report all three.

These codes are worth retrying, and each comes with a Retry-After header in seconds:

  • concurrency_limit_exceeded, 429: The workspace is already running the maximum number of concurrent renders.
  • rate_limit_exceeded, 429: The request rate limit for this API key has been exceeded.
  • service_unavailable, 503: The screenshot service is temporarily unavailable.

Every other code fails the same way however often you send it, and one that got as far as the browser has already cost a credit. So retry on Retry-After and nothing else:

retry.go
// retry.go: request a screenshot, retrying only when the API says when to.
package main

import (
	"encoding/json"
	"fmt"
	"io"
	"log"
	"net/http"
	"net/url"
	"os"
	"strconv"
	"time"
)

// errorEnvelope is what every failed call returns instead of an image.
type errorEnvelope struct {
	Error struct {
		Code      string `json:"code"`
		Message   string `json:"message"`
		RequestID string `json:"requestId"`
	} `json:"error"`
}

// screenshot requests an image, retrying only when the API says how long to wait.
func screenshot(query url.Values, attempts int) ([]byte, error) {
	client := &http.Client{Timeout: 60 * time.Second}
	for attempt := 1; ; attempt++ {
		request, err := http.NewRequest(http.MethodGet, "https://api.urlshot.io/v1/screenshot?"+query.Encode(), nil)
		if err != nil {
			return nil, err
		}
		request.Header.Set("Authorization", "Bearer "+os.Getenv("URLSHOT_API_KEY"))

		response, err := client.Do(request)
		if err != nil {
			return nil, err
		}
		body, err := io.ReadAll(response.Body)
		response.Body.Close()
		if err != nil {
			return nil, err
		}
		if response.StatusCode == http.StatusOK {
			return body, nil
		}

		// Only a 429 or 503 carries Retry-After, in seconds. Anything else fails
		// the same way however often it is sent.
		seconds, err := strconv.Atoi(response.Header.Get("Retry-After"))
		if err != nil || attempt == attempts {
			var envelope errorEnvelope
			if err := json.Unmarshal(body, &envelope); err != nil {
				return nil, err
			}
			return nil, fmt.Errorf("%s: %s (request %s)", envelope.Error.Code, envelope.Error.Message, envelope.Error.RequestID)
		}
		time.Sleep(time.Duration(seconds) * time.Second)
	}
}

func main() {
	query := url.Values{}
	query.Set("url", "https://en.wikipedia.org/wiki/Cartography")
	query.Set("format", "webp")

	image, err := screenshot(query, 4)
	if err != nil {
		log.Fatal(err)
	}
	if err := os.WriteFile("screenshot.webp", image, 0o644); err != nil {
		log.Fatal(err)
	}
	fmt.Println("Wrote screenshot.webp")
}

A missing header makes strconv.Atoi fail, which is the signal to stop rather than wait. Every code is in the error reference.

More options

Every option is a query parameter with a default. These change a capture the most:

8 of the 17 options, with their ranges and defaults.
full_pageCapture the full scrollable page instead of only the viewport.boolean
formatOutput image format.png | jpeg | webp
qualityEncoder quality for jpeg and webp. Defaults to 80 for those formats and must be omitted for png.1–100
viewport_widthViewport width in CSS pixels.200–3840
device_scale_factorDevice pixel ratio to emulate.0.5–3
dark_modeEmulate prefers-color-scheme: dark before navigation.boolean
hide_selectorsCSS selector whose matching elements are hidden after navigation. Repeatable.string[]
block_cookie_bannersHide known consent dialogs and restore page scrolling before capturing. Available on every plan.boolean

url.Values has two ways to put a value in. Set replaces whatever the name held; Add appends. A list such as hide_selectors is the parameter repeated, so it needs Add, once for each selector:

options.go
// options.go: request a screenshot and save it. Run: go run options.go
package main

import (
	"encoding/json"
	"fmt"
	"io"
	"log"
	"net/http"
	"net/url"
	"os"
	"time"
)

// errorEnvelope is what every failed call returns instead of an image.
type errorEnvelope struct {
	Error struct {
		Code      string `json:"code"`
		Message   string `json:"message"`
		RequestID string `json:"requestId"`
	} `json:"error"`
}

func main() {
	query := url.Values{}
	// Add, not Set, for a list: Set replaces the value, and a list is the
	// parameter repeated, once for each selector.
	query.Set("url", "https://en.wikipedia.org/wiki/Atlas")
	query.Set("full_page", "true")
	query.Set("format", "png")
	query.Add("hide_selectors", ".vector-header-container")
	query.Add("hide_selectors", ".mw-footer")

	request, err := http.NewRequest(http.MethodGet, "https://api.urlshot.io/v1/screenshot?"+query.Encode(), nil)
	if err != nil {
		log.Fatal(err)
	}
	request.Header.Set("Authorization", "Bearer "+os.Getenv("URLSHOT_API_KEY"))

	client := &http.Client{Timeout: 60 * time.Second}
	response, err := client.Do(request)
	if err != nil {
		log.Fatal(err)
	}
	defer response.Body.Close()

	body, err := io.ReadAll(response.Body)
	if err != nil {
		log.Fatal(err)
	}

	// Anything that is not an image is the JSON error envelope.
	if response.StatusCode != http.StatusOK {
		var envelope errorEnvelope
		if err := json.Unmarshal(body, &envelope); err != nil {
			log.Fatal(err)
		}
		log.Fatalf("%s: %s (request %s)", envelope.Error.Code, envelope.Error.Message, envelope.Error.RequestID)
	}

	if err := os.WriteFile("atlas.png", body, 0o644); err != nil {
		log.Fatal(err)
	}
	fmt.Println("Wrote atlas.png")
}

That is the whole Atlas article as a PNG, without Wikipedia's header and footer. Encode sorts the parameters by name, which makes no difference to the API. All 17 options are in the parameter reference.

POST with a JSON body

The same options can go in a JSON body, which is easier once you send CSS: nothing to encode. json.Marshal writes Go's true and slices as JSON's, which a body needs; the string "true" is rejected there:

post.go
// post.go: send the options as a JSON body. Run: go run post.go
package main

import (
	"bytes"
	"encoding/json"
	"io"
	"log"
	"net/http"
	"os"
	"time"
)

// errorEnvelope is what every failed call returns instead of an image.
type errorEnvelope struct {
	Error struct {
		Code      string `json:"code"`
		Message   string `json:"message"`
		RequestID string `json:"requestId"`
	} `json:"error"`
}

func main() {
	// JSON's own types: true rather than "true", and a list as an array.
	payload, err := json.Marshal(map[string]any{
		"url": "https://en.wikipedia.org/wiki/Atlas",
		"full_page": true,
		"format": "png",
		"hide_selectors": []string{".vector-header-container", ".mw-footer"},
		"custom_css": "#siteNotice { display: none !important }",
	})
	if err != nil {
		log.Fatal(err)
	}

	request, err := http.NewRequest(http.MethodPost, "https://api.urlshot.io/v1/screenshot", bytes.NewReader(payload))
	if err != nil {
		log.Fatal(err)
	}
	request.Header.Set("Content-Type", "application/json")
	request.Header.Set("Authorization", "Bearer "+os.Getenv("URLSHOT_API_KEY"))

	client := &http.Client{Timeout: 60 * time.Second}
	response, err := client.Do(request)
	if err != nil {
		log.Fatal(err)
	}
	defer response.Body.Close()

	body, err := io.ReadAll(response.Body)
	if err != nil {
		log.Fatal(err)
	}

	// Anything that is not an image is the JSON error envelope.
	if response.StatusCode != http.StatusOK {
		var envelope errorEnvelope
		if err := json.Unmarshal(body, &envelope); err != nil {
			log.Fatal(err)
		}
		log.Fatalf("%s: %s (request %s)", envelope.Error.Code, envelope.Error.Message, envelope.Error.RequestID)
	}

	if err := os.WriteFile("atlas.png", body, 0o644); err != nil {
		log.Fatal(err)
	}
}

A map[string]any keeps the example short; in your own code a struct with json tags gives the same body with types checked by the compiler. Put every option in the body: a POST that also has query parameters is rejected. The rest of the rules are under POST with a JSON body.

In an HTTP server

In a service, call the API from your server and send the image on, so the key never reaches a browser. Since Go 1.22 the standard library's router matches a method and a path variable, so this needs no third-party router:

server.go
// server.go: serve previews of chosen pages. Run: go run server.go
package main

import (
	"encoding/json"
	"io"
	"log"
	"net/http"
	"net/url"
	"os"
	"time"
)

// errorEnvelope is what every failed call returns instead of an image.
type errorEnvelope struct {
	Error struct {
		Code      string `json:"code"`
		Message   string `json:"message"`
		RequestID string `json:"requestId"`
	} `json:"error"`
}

// The pages this app shows previews of. Visitors pick a name, never an
// address: a route that took any URL would render any page on your credits.
var pages = map[string]string{
	"home": "https://example.com",
	"atlas": "https://en.wikipedia.org/wiki/Atlas",
}

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

func preview(w http.ResponseWriter, r *http.Request) {
	target, ok := pages[r.PathValue("page")]
	if !ok {
		http.NotFound(w, r)
		return
	}

	query := url.Values{}
	query.Set("url", target)
	query.Set("viewport_width", "1280")
	query.Set("format", "webp")
	query.Set("cache_ttl", "3600")

	request, err := http.NewRequestWithContext(r.Context(), http.MethodGet, "https://api.urlshot.io/v1/screenshot?"+query.Encode(), nil)
	if err != nil {
		http.Error(w, "preview unavailable", http.StatusInternalServerError)
		return
	}
	request.Header.Set("Authorization", "Bearer "+os.Getenv("URLSHOT_API_KEY"))

	response, err := client.Do(request)
	if err != nil {
		log.Print(err)
		http.Error(w, "preview unavailable", http.StatusBadGateway)
		return
	}
	defer response.Body.Close()

	if response.StatusCode != http.StatusOK {
		var envelope errorEnvelope
		if err := json.NewDecoder(response.Body).Decode(&envelope); err == nil {
			log.Printf("%s: %s (request %s)", envelope.Error.Code, envelope.Error.Message, envelope.Error.RequestID)
		}
		http.Error(w, "preview unavailable", http.StatusBadGateway)
		return
	}

	// Streamed straight through: the image is never held in memory whole.
	w.Header().Set("Content-Type", response.Header.Get("Content-Type"))
	w.Header().Set("Cache-Control", "public, max-age=3600")
	if _, err := io.Copy(w, response.Body); err != nil {
		log.Print(err)
	}
}

func main() {
	http.HandleFunc("GET /previews/{page}", preview)
	log.Fatal(http.ListenAndServe(":8080", nil))
}

  • Visitors choose a name, not an address. A route that rendered whatever URL it was given would let anyone spend your credits on pages of their own.
  • The visitor's context goes with the call. NewRequestWithContext(r.Context(), …) cancels the request to the API when the visitor goes away.
  • The image is streamed. io.Copy passes it on as it arrives, so the server never holds a whole image in memory.
  • Repeat views are free. cache_ttl=3600 serves the same page from cache for an hour at no cost, the longest the free plan allows. See caching and credits.

Run go run server.go and open /previews/home on port 8080.

Signed URLs

To put a capture straight into a page's <img>, sign the URL on your server instead. Anyone can load a signed URL, and changing any of its parameters breaks the signature:

sign.go
package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"net/url"
	"os"
)

func signedURL(options map[string]string) string {
	query := url.Values{}
	query.Set("key", os.Getenv("URLSHOT_KEY"))
	for name, value := range options {
		query.Set(name, value)
	}
	// Encode sorts by key. That is fine: what matters is signing exactly the string you send.
	encoded := query.Encode()

	mac := hmac.New(sha256.New, []byte(os.Getenv("URLSHOT_SIGNING_SECRET")))
	mac.Write([]byte(encoded))
	signature := hex.EncodeToString(mac.Sum(nil))

	return "https://api.urlshot.io/v1/screenshot?" + encoded + "&signature=" + signature
}

func main() {
	fmt.Println(signedURL(map[string]string{"url": "https://example.com", "viewport_width": "1200"}))
}

URLSHOT_KEY is a publishable key (pk_…) and URLSHOT_SIGNING_SECRET its signing secret, both from the dashboard. The function signs the string Encode returns and sends that same string, so the sorting it does is harmless. A signed URL does not expire, so add a cache_ttl to any you publish; the reference explains why under signed URLs.

Where to go next

  • The API reference — every parameter, response header and error code, generated from the contract the gateway validates against.
  • Website monitoring — the same requests on a schedule, with every capture kept, which a small Go service does well.
  • Pricing — what a credit costs once the free allowance runs out.

The same examples in other languages.

Or see every code example.

Run it with your own key.

100 screenshots a month on the free plan. No card needed.