# 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.

```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")
}
```

```text
Saved example.png
```

![The example.com home page, captured at 1280 by 1024 pixels](https://curlshot.com/docs/examples/first-screenshot.webp)

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.

```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")
}
```

```text
Saved eiffel.jpg
```

![The Wikipedia article about the Eiffel Tower, rendered at phone width](https://curlshot.com/docs/examples/wikipedia-iphone.webp)

The client's `Timeout` is how long your own code waits for an answer. The API's own [`timeout`](https://curlshot.com/docs/options.md#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`](https://curlshot.com/docs/errors.md#invalid_options) and a hint. Every option is explained in the [options reference](https://curlshot.com/docs/options.md).

## 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](https://curlshot.com/docs/html-and-markdown.md).

```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")
}
```

```text
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.

```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)
}
```

```text
{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.

```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")
}
```

```text
2026/10/04 12:00:00 Attempt 1 failed (renderer_busy). Waiting 1s.
Saved playwright.png
```

```text
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](https://curlshot.com/docs/errors.md) lists every code and says which ones can be retried.

## Create a signed link

A [signed link](https://curlshot.com/docs/signed-links.md) 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.

```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)
}
```

```text
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`](https://curlshot.com/docs/errors.md#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`.

> **Note**
>
> `url.Values.Encode()` also sorts by name, but it writes a space as `+`. That is fine for sending a request. For the text you sign, use the `encode` function above.

## 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](https://curlshot.com/docs/async-and-webhooks.md).

```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")
}
```

```text
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](https://curlshot.com/docs/examples/wikipedia-full-page.webp)

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`.

> **Common mistakes**
>
> - **Your client had no timeout.** `http.Get` and `http.DefaultClient` wait forever if a connection hangs. In real code, create a client with a `Timeout`.
> - **You saved the body without checking `resp.StatusCode`.** A failed request returns JSON. Saved as `.png`, it is a file that will not open.
> - **You signed the output of `params.Encode()`.** It writes a space as `+`. The signature needs `%20`.
> - **You read `screenshot` before the job is done.** While a job is running, `screenshot` is `null`. Use a pointer, as in the `Job` type above, and check it for `nil`.

## Where to go next

- [Options reference](https://curlshot.com/docs/options.md): every option you can set.
- [Errors](https://curlshot.com/docs/errors.md): every error code with its cause and fix.
- [Signed links](https://curlshot.com/docs/signed-links.md): the signing steps explained one by one.
- [Async and webhooks](https://curlshot.com/docs/async-and-webhooks.md): get a call when the job ends, with no polling.
- [Ruby examples](https://curlshot.com/docs/examples/ruby.md): the same tasks in Ruby.
