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: 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
export URLSHOT_API_KEY=sk_your_key_here
go run screenshot.goOne 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:

Every response also carries headers that account for the call:
| X-Request-ID | Opaque identifier for this request. Quote it in any support conversation. |
|---|---|
| X-Urlshot-Cache | HIT when the image was served from cache, MISS when it was rendered, BYPASS when cache_ttl was 0. |
| X-Urlshot-Credits-Used | Render credits consumed by this request. A cache hit consumes zero. |
| X-RateLimit-Limit | Renders your plan may run at once. |
| X-Urlshot-Renderer | The 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-Remaining | Render slots still free after this request. Absent on a cache hit, where it is unknown rather than zero. |
| Cache-Control | How 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:
// 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: 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:
| full_page | Capture the full scrollable page instead of only the viewport. | boolean |
|---|---|---|
| format | Output image format. | png | jpeg | webp |
| quality | Encoder quality for jpeg and webp. Defaults to 80 for those formats and must be omitted for png. | 1–100 |
| viewport_width | Viewport width in CSS pixels. | 200–3840 |
| device_scale_factor | Device pixel ratio to emulate. | 0.5–3 |
| dark_mode | Emulate prefers-color-scheme: dark before navigation. | boolean |
| hide_selectors | CSS selector whose matching elements are hidden after navigation. Repeatable. | string[] |
| block_cookie_banners | Hide 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: 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: 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: 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.Copypasses it on as it arrives, so the server never holds a whole image in memory. - Repeat views are free.
cache_ttl=3600serves 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:
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.