Skip to content

Type an option name like full_page, an error code, or a topic.

Go examples

Runnable Go samples for screenshots, HTML input, JSON answers, retries, signed links and background jobs, using only the standard library.

Save this as main.go and run it with go run main.go. It uses only the standard library.

main.go
package main

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

func main() {
	params := url.Values{}
	params.Set("access_key", "YOUR_ACCESS_KEY")
	params.Set("url", "https://example.com")

	resp, err := http.Get("https://curlshot.com/api/v1/screenshot?" + params.Encode())
	if err != nil {
		log.Fatal(err)
	}
	defer resp.Body.Close()

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		log.Fatal(err)
	}
	if resp.StatusCode != http.StatusOK {
		// On failure the body is a JSON error, not an image.
		log.Fatalf("status %d: %s", resp.StatusCode, body)
	}

	if err := os.WriteFile("example.png", body, 0o644); err != nil {
		log.Fatal(err)
	}
	fmt.Println("Saved example.png")
}
Output
Saved example.png
The example.com home page, captured at 1280 by 1024 pixels
The result: example.com at the default size, 1280 x 1024.

Every sample on this page is a complete program. Put each one in its own folder as main.go.

url.Values encodes the values for you. So you can write the page address as it is, even when it contains & or ?.

#Add options

Each option is one more Set call. Here the access key goes in the X-Access-Key header, which keeps it out of the address.

main.go
package main

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

func main() {
	params := url.Values{}
	params.Set("url", "https://en.wikipedia.org/wiki/Eiffel_Tower")
	params.Set("viewport_device", "iphone_15_pro")
	params.Set("full_page", "true")
	params.Set("block_cookie_banners", "true")
	params.Set("format", "jpeg")
	params.Set("image_quality", "85")

	req, err := http.NewRequest("GET", "https://curlshot.com/api/v1/screenshot?"+params.Encode(), nil)
	if err != nil {
		log.Fatal(err)
	}
	req.Header.Set("X-Access-Key", "YOUR_ACCESS_KEY")

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

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		log.Fatal(err)
	}
	if resp.StatusCode != http.StatusOK {
		log.Fatalf("status %d: %s", resp.StatusCode, body)
	}

	if err := os.WriteFile("eiffel.jpg", body, 0o644); err != nil {
		log.Fatal(err)
	}
	fmt.Println("Saved eiffel.jpg")
}
Output
Saved eiffel.jpg
The Wikipedia article about the Eiffel Tower, rendered at phone width
The result: the article at phone width.

The client's Timeout is how long your own code waits for an answer. The API's own timeout option can be as long as 90 seconds, so give your code more than that.

Option names must match exactly. An unknown name, such as fullpage for full_page, is rejected with invalid_options and a hint. Every option is explained in the options reference.

#Render your own HTML with POST

To turn your own HTML into an image, send the options as a JSON body in a POST request. See HTML and Markdown input.

main.go
package main

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

const html = `
<body style="margin:0;display:grid;place-items:center;height:100vh;
             font:700 64px sans-serif;background:#0f172a;color:#fff">
  Hello from HTML
</body>`

func main() {
	// In a JSON body, booleans and numbers are real values, not text.
	payload, err := json.Marshal(map[string]any{
		"html":            html,
		"viewport_width":  1200,
		"viewport_height": 630,
		"format":          "png",
	})
	if err != nil {
		log.Fatal(err)
	}

	req, err := http.NewRequest("POST", "https://curlshot.com/api/v1/screenshot", bytes.NewReader(payload))
	if err != nil {
		log.Fatal(err)
	}
	req.Header.Set("X-Access-Key", "YOUR_ACCESS_KEY")
	req.Header.Set("Content-Type", "application/json")

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		log.Fatal(err)
	}
	defer resp.Body.Close()

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		log.Fatal(err)
	}
	if resp.StatusCode != http.StatusOK {
		log.Fatalf("status %d: %s", resp.StatusCode, body)
	}

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

You get card.png, 1200 by 630 pixels: white text centered on a dark background.

#Get JSON instead of the file

With response_type=json the answer is a small JSON document: details about the render and a link to the stored file. Decode it into a struct.

main.go
package main

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

type Result struct {
	ID        string `json:"id"`
	URL       string `json:"url"`
	Format    string `json:"format"`
	Width     int    `json:"width"`
	Height    int    `json:"height"`
	Bytes     int    `json:"bytes"`
	RenderMs  int    `json:"render_ms"`
	Cached    bool   `json:"cached"`
	ExpiresAt string `json:"expires_at"`
}

func main() {
	params := url.Values{}
	params.Set("url", "https://news.ycombinator.com")
	params.Set("response_type", "json")

	req, err := http.NewRequest("GET", "https://curlshot.com/api/v1/screenshot?"+params.Encode(), nil)
	if err != nil {
		log.Fatal(err)
	}
	req.Header.Set("X-Access-Key", "YOUR_ACCESS_KEY")

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		log.Fatal(err)
	}
	defer resp.Body.Close()

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		log.Fatal(err)
	}
	if resp.StatusCode != http.StatusOK {
		log.Fatalf("status %d: %s", resp.StatusCode, body)
	}

	var result Result
	if err := json.Unmarshal(body, &result); err != nil {
		log.Fatal(err)
	}
	fmt.Printf("%+v\n", result)
}
Output
{ID:a187741109b34d6c991bdab9b2c5da03 URL:https://curlshot.com/api/v1/files/a187741109b34d6c991bdab9b2c5da03.png?expires=1791155483&token=wr0Y6nTqXC-yLpWGbch_RVMyZqjGaUgnu2Eeq-shclg Format:png Width:1280 Height:1024 Bytes:265066 RenderMs:1464 Cached:false ExpiresAt:2026-10-04T23:11:23.563Z}

The url needs no access key, so you can hand it to a browser or to another service. It stops working at the time in expires_at, so download the file before then if you need to keep it.

#Handle errors and retry

A failed request returns JSON with an error_code and an error_message, not an image. Some codes are temporary and worth a second try. The rest fail the same way until you change the request. This helper retries the temporary ones and waits a little longer each time.

main.go
package main

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

const (
	api       = "https://curlshot.com/api/v1/screenshot"
	accessKey = "YOUR_ACCESS_KEY"
)

// Temporary problems. Everything else needs a change to the request.
var retryable = map[string]bool{
	"renderer_busy":        true,
	"renderer_unavailable": true,
	"service_unavailable":  true,
	"internal_error":       true,
	"timeout":              true,
	"navigation_failed":    true,
	"rate_limited":         true,
	"concurrency_limit":    true,
}

// APIError is the JSON body of a failed request.
type APIError struct {
	Status  int    `json:"-"`
	Code    string `json:"error_code"`
	Message string `json:"error_message"`
}

func (e *APIError) Error() string { return e.Code + ": " + e.Message }

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

// attempt sends one request. It returns the image, or an *APIError and
// the number of seconds the API asked us to wait (0 if it did not say).
func attempt(params url.Values) ([]byte, *APIError, int) {
	req, err := http.NewRequest("GET", api+"?"+params.Encode(), nil)
	if err != nil {
		return nil, &APIError{Code: "invalid_request", Message: err.Error()}, 0
	}
	req.Header.Set("X-Access-Key", accessKey)

	resp, err := client.Do(req)
	if err != nil {
		// The connection itself failed. Treat it as a temporary failure.
		return nil, &APIError{Code: "internal_error", Message: err.Error()}, 0
	}
	defer resp.Body.Close()

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, &APIError{Status: resp.StatusCode, Code: "internal_error", Message: err.Error()}, 0
	}
	if resp.StatusCode == http.StatusOK {
		return body, nil, 0
	}

	apiErr := &APIError{Status: resp.StatusCode}
	if json.Unmarshal(body, apiErr) != nil || apiErr.Code == "" {
		apiErr.Code, apiErr.Message = "internal_error", "Unexpected answer"
	}
	retryAfter, _ := strconv.Atoi(resp.Header.Get("Retry-After"))
	return nil, apiErr, retryAfter
}

func takeScreenshot(params url.Values, tries int) ([]byte, error) {
	for try := 1; ; try++ {
		image, apiErr, retryAfter := attempt(params)
		if apiErr == nil {
			return image, nil
		}
		if !retryable[apiErr.Code] || try == tries {
			return nil, apiErr
		}

		// Use Retry-After when the API sends it. Otherwise wait 1, 2, 4 seconds.
		wait := time.Duration(retryAfter) * time.Second
		if wait == 0 {
			wait = time.Duration(1<<(try-1)) * time.Second
		}
		log.Printf("Attempt %d failed (%s). Waiting %s.", try, apiErr.Code, wait)
		time.Sleep(wait)
	}
}

func main() {
	params := url.Values{}
	params.Set("url", "https://github.com/microsoft/playwright")

	image, err := takeScreenshot(params, 4)
	if err != nil {
		log.Fatal("Gave up: ", err)
	}
	if err := os.WriteFile("playwright.png", image, 0o644); err != nil {
		log.Fatal(err)
	}
	fmt.Println("Saved playwright.png")
}
Output, after one busy moment
2026/10/04 12:00:00 Attempt 1 failed (renderer_busy). Waiting 1s.
Saved playwright.png
Output, when https:// is missing from the address
2026/10/04 12:00:00 Gave up: invalid_options: url: must be a full URL starting with http:// or https://

When the API knows how long to wait, it says so in the Retry-After header, in seconds. The errors page lists every code and says which ones can be retried.

A signed link is a screenshot address with a signature at the end. It is safe to show in a web page, because nobody can change it without your secret key. Build it on your server.

The signature is an HMAC-SHA256, a checksum made with your secret key, of the sorted and encoded parameters.

main.go
package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"net/url"
	"sort"
	"strconv"
	"strings"
	"time"
)

// Strict percent-encoding: QueryEscape writes a space as "+", so turn it into "%20".
func encode(text string) string {
	return strings.ReplaceAll(url.QueryEscape(text), "+", "%20")
}

func signedLink(endpoint string, params map[string]string, secretKey string) string {
	// Every parameter, sorted by name.
	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]))
	}
	canonical := strings.Join(pairs, "&")

	mac := hmac.New(sha256.New, []byte(secretKey))
	mac.Write([]byte(canonical))
	signature := hex.EncodeToString(mac.Sum(nil))

	return endpoint + "?" + canonical + "&signature=" + signature
}

func main() {
	link := signedLink("https://curlshot.com/api/v1/screenshot", map[string]string{
		"access_key":     "YOUR_ACCESS_KEY",
		"url":            "https://en.wikipedia.org/wiki/Eiffel_Tower",
		"format":         "webp",
		"viewport_width": "1280",
		"cache":          "true",
		// Optional: the link stops working at this time, in Unix seconds. Here: one hour from now.
		"expires": strconv.FormatInt(time.Now().Unix()+3600, 10),
	}, "YOUR_SECRET_KEY")

	fmt.Println(link)
}
Output
https://curlshot.com/api/v1/screenshot?access_key=YOUR_ACCESS_KEY&cache=true&expires=1791159083&format=webp&url=https%3A%2F%2Fen.wikipedia.org%2Fwiki%2FEiffel_Tower&viewport_width=1280&signature=6c5f73bd40f701699781ec1faf569d8c164f5936c823d1c2a293fa1ff9170e7e

expires is optional. It is a Unix time, the number of seconds since 1970. The link works until that time and never after: the API then answers 403 request_expired. It is part of the signed text, so nobody can move it. Leave it out for a link that never expires.

To test your signing code, keep the placeholder keys and leave out expires. The signature must then be b565e2249494a979e38a79a4c9556b316ff64f15df96febf44c69b9709a52213.

#Run a render in the background and poll

With async=true the API answers at once with a job, and the render runs in the background. You then poll: ask the job address every two seconds until the job is done or failed. More on this in Async and webhooks.

main.go
package main

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

const accessKey = "YOUR_ACCESS_KEY"

type Job struct {
	JobID      string `json:"job_id"`
	JobURL     string `json:"job_url"`
	Status     string `json:"status"`
	Screenshot *struct {
		URL   string `json:"url"`
		Bytes int    `json:"bytes"`
	} `json:"screenshot"`
	ErrorCode    string `json:"error_code"`
	ErrorMessage string `json:"error_message"`
}

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

// get fetches an address and returns the status and the body.
// The access key is sent only when we talk to the API itself.
func get(address string, withKey bool) (int, []byte) {
	req, err := http.NewRequest("GET", address, nil)
	if err != nil {
		log.Fatal(err)
	}
	if withKey {
		req.Header.Set("X-Access-Key", accessKey)
	}
	resp, err := client.Do(req)
	if err != nil {
		log.Fatal(err)
	}
	defer resp.Body.Close()

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

func main() {
	// 1. Start the job.
	params := url.Values{}
	params.Set("url", "https://en.wikipedia.org/wiki/Eiffel_Tower")
	params.Set("full_page", "true")
	params.Set("async", "true")

	status, body := get("https://curlshot.com/api/v1/screenshot?"+params.Encode(), true)
	if status != http.StatusAccepted {
		log.Fatalf("status %d: %s", status, body)
	}
	var job Job
	if err := json.Unmarshal(body, &job); err != nil {
		log.Fatal(err)
	}
	jobURL := job.JobURL
	fmt.Println("Started", job.JobID)

	// 2. Ask every two seconds until the job is done or has failed. Give up after two minutes.
	for i := 0; i < 60 && job.Status != "done" && job.Status != "failed"; i++ {
		time.Sleep(2 * time.Second)

		status, body = get(jobURL, true)
		if status != http.StatusOK {
			log.Fatalf("status %d: %s", status, body)
		}
		job = Job{}
		if err := json.Unmarshal(body, &job); err != nil {
			log.Fatal(err)
		}
		fmt.Println("Status:", job.Status)
	}

	switch {
	case job.Status == "failed":
		log.Fatalf("%s: %s", job.ErrorCode, job.ErrorMessage)
	case job.Status != "done" || job.Screenshot == nil:
		log.Fatal("The job did not finish in time")
	}

	// 3. Download the finished file from the link in the job.
	status, file := get(job.Screenshot.URL, false)
	if status != http.StatusOK {
		log.Fatalf("could not download the file: status %d", status)
	}
	if err := os.WriteFile("eiffel-full.png", file, 0o644); err != nil {
		log.Fatal(err)
	}
	fmt.Println("Saved eiffel-full.png,", job.Screenshot.Bytes, "bytes")
}
Output
Started job_917e1044a2a8885401dae084
Status: processing
Status: done
Saved eiffel-full.png, 7373357 bytes
The full Wikipedia article about the Eiffel Tower in one tall image
The result: the whole article, top to bottom.

A finished job holds the result in screenshot, which is null until then. A failed job holds an error_code and an error_message instead.

Asking for the status does not use your quota. The file link in screenshot.url needs no access key and works until the job's screenshot.expires_at.

#Where to go next