Screenshot API with Go: Complete Integration Guide

2026-05-19 | Tags: [screenshot-api, golang, go, tutorial]

Screenshot API with Go: Complete Integration Guide

Go is the language of cloud-native infrastructure. Kubernetes operators, CLI tools, monitoring agents, data pipelines — if it runs at scale in production, there's a good chance it's written in Go. If your Go service needs screenshots for dashboards, visual monitoring, report generation, or archiving, here's how to integrate cleanly.

Basic Request with net/http

Go's standard library is production-ready for HTTP calls:

package main

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

func Screenshot(targetURL, apiKey string) ([]byte, error) {
    params := url.Values{}
    params.Set("url", targetURL)
    params.Set("width", "1280")
    params.Set("format", "png")

    reqURL := "https://hermesforge.dev/api/screenshot?" + params.Encode()

    client := &http.Client{Timeout: 45 * time.Second}
    req, err := http.NewRequest("GET", reqURL, nil)
    if err != nil {
        return nil, err
    }
    req.Header.Set("X-API-Key", apiKey)

    resp, err := client.Do(req)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusOK {
        body, _ := io.ReadAll(resp.Body)
        return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, body)
    }

    return io.ReadAll(resp.Body)
}

func main() {
    data, err := Screenshot("https://example.com", os.Getenv("SCREENSHOT_API_KEY"))
    if err != nil {
        fmt.Fprintf(os.Stderr, "error: %v\n", err)
        os.Exit(1)
    }
    if err := os.WriteFile("screenshot.png", data, 0644); err != nil {
        fmt.Fprintf(os.Stderr, "write error: %v\n", err)
        os.Exit(1)
    }
    fmt.Printf("Saved %d bytes\n", len(data))
}

Production Client with Retry Logic

A reusable client with exponential backoff, proper error types, and context support:

package screenshot

import (
    "context"
    "fmt"
    "io"
    "math/rand"
    "net/http"
    "net/url"
    "strconv"
    "time"
)

type Client struct {
    apiKey     string
    baseURL    string
    httpClient *http.Client
    maxRetries int
}

type Options struct {
    Width    int
    Height   int
    Format   string
    FullPage bool
    Delay    int
}

type APIError struct {
    StatusCode int
    Body       string
}

func (e *APIError) Error() string {
    return fmt.Sprintf("screenshot API error %d: %s", e.StatusCode, e.Body)
}

func (e *APIError) Retryable() bool {
    return e.StatusCode == 429 || e.StatusCode >= 500
}

func NewClient(apiKey string) *Client {
    return &Client{
        apiKey:  apiKey,
        baseURL: "https://hermesforge.dev",
        httpClient: &http.Client{
            Timeout: 45 * time.Second,
        },
        maxRetries: 3,
    }
}

func (c *Client) Capture(ctx context.Context, targetURL string, opts Options) ([]byte, error) {
    params := url.Values{}
    params.Set("url", targetURL)
    if opts.Width > 0 {
        params.Set("width", strconv.Itoa(opts.Width))
    }
    if opts.Height > 0 {
        params.Set("height", strconv.Itoa(opts.Height))
    }
    if opts.Format != "" {
        params.Set("format", opts.Format)
    }
    if opts.FullPage {
        params.Set("full_page", "true")
    }
    if opts.Delay > 0 {
        params.Set("delay", strconv.Itoa(opts.Delay))
    }

    reqURL := c.baseURL + "/api/screenshot?" + params.Encode()

    var lastErr error
    for attempt := 0; attempt <= c.maxRetries; attempt++ {
        if attempt > 0 {
            // Exponential backoff with jitter
            base := time.Duration(1<<uint(attempt-1)) * time.Second
            jitter := time.Duration(rand.Int63n(int64(500 * time.Millisecond)))
            select {
            case <-ctx.Done():
                return nil, ctx.Err()
            case <-time.After(base + jitter):
            }
        }

        data, err := c.doRequest(ctx, reqURL)
        if err == nil {
            return data, nil
        }

        apiErr, ok := err.(*APIError)
        if !ok || !apiErr.Retryable() {
            return nil, err
        }
        lastErr = err
    }
    return nil, fmt.Errorf("max retries exceeded: %w", lastErr)
}

func (c *Client) doRequest(ctx context.Context, reqURL string) ([]byte, error) {
    req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil)
    if err != nil {
        return nil, err
    }
    req.Header.Set("X-API-Key", c.apiKey)

    resp, err := c.httpClient.Do(req)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()

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

    if resp.StatusCode != http.StatusOK {
        return nil, &APIError{StatusCode: resp.StatusCode, Body: string(body)}
    }
    return body, nil
}

Concurrent Batch Processing with Goroutines

Go's goroutines and channels make concurrent batch processing clean and efficient:

package main

import (
    "context"
    "fmt"
    "sync"
)

type BatchResult struct {
    URL   string
    Data  []byte
    Error error
}

func BatchCapture(ctx context.Context, client *Client, urls []string, concurrency int) []BatchResult {
    results := make([]BatchResult, len(urls))
    sem := make(chan struct{}, concurrency) // semaphore to limit concurrency
    var wg sync.WaitGroup

    for i, u := range urls {
        wg.Add(1)
        go func(idx int, url string) {
            defer wg.Done()
            sem <- struct{}{}        // acquire
            defer func() { <-sem }() // release

            data, err := client.Capture(ctx, url, Options{
                Width:    1440,
                FullPage: true,
                Format:   "png",
            })
            results[idx] = BatchResult{URL: url, Data: data, Error: err}
        }(i, u)
    }

    wg.Wait()
    return results
}

func main() {
    client := NewClient(os.Getenv("SCREENSHOT_API_KEY"))
    ctx := context.Background()

    urls := []string{
        "https://example.com",
        "https://example.org",
        "https://example.net",
    }

    results := BatchCapture(ctx, client, urls, 3)

    for _, r := range results {
        if r.Error != nil {
            fmt.Printf("FAIL %s: %v\n", r.URL, r.Error)
            continue
        }
        host, _ := url.Parse(r.URL)
        filename := host.Hostname() + ".png"
        os.WriteFile(filename, r.Data, 0644)
        fmt.Printf("OK   %s → %s (%d bytes)\n", r.URL, filename, len(r.Data))
    }
}

HTTP Handler for a Screenshot Service

Expose screenshot capture as an HTTP endpoint in your Go service:

package main

import (
    "encoding/json"
    "log"
    "net/http"
    "os"
)

type screenshotHandler struct {
    client *Client
}

func (h *screenshotHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    targetURL := r.URL.Query().Get("url")
    if targetURL == "" {
        http.Error(w, `{"error":"url parameter required"}`, http.StatusBadRequest)
        return
    }

    // Validate URL scheme
    parsed, err := url.Parse(targetURL)
    if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {
        http.Error(w, `{"error":"invalid URL"}`, http.StatusUnprocessableEntity)
        return
    }

    format := r.URL.Query().Get("format")
    if format == "" {
        format = "png"
    }

    data, err := h.client.Capture(r.Context(), targetURL, Options{
        Width:    1280,
        Format:   format,
        FullPage: r.URL.Query().Get("full_page") == "true",
    })
    if err != nil {
        log.Printf("screenshot error for %s: %v", targetURL, err)
        w.Header().Set("Content-Type", "application/json")
        w.WriteHeader(http.StatusBadGateway)
        json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
        return
    }

    contentType := "image/png"
    if format == "webp" {
        contentType = "image/webp"
    } else if format == "jpeg" {
        contentType = "image/jpeg"
    }

    w.Header().Set("Content-Type", contentType)
    w.Header().Set("Cache-Control", "public, max-age=3600")
    w.Write(data)
}

func main() {
    client := NewClient(os.Getenv("SCREENSHOT_API_KEY"))
    mux := http.NewServeMux()
    mux.Handle("/screenshot", &screenshotHandler{client: client})

    log.Println("Listening on :8080")
    log.Fatal(http.ListenAndServe(":8080", mux))
}

Monitoring Agent Pattern

A common Go use case: periodic visual monitoring of a set of URLs, storing captures and alerting on changes:

package main

import (
    "crypto/sha256"
    "fmt"
    "log"
    "os"
    "path/filepath"
    "time"
)

type MonitorAgent struct {
    client    *Client
    urls      []string
    outputDir string
    baselines map[string][32]byte
}

func NewMonitorAgent(client *Client, urls []string, outputDir string) *MonitorAgent {
    os.MkdirAll(outputDir, 0755)
    return &MonitorAgent{
        client:    client,
        urls:      urls,
        outputDir: outputDir,
        baselines: make(map[string][32]byte),
    }
}

func (m *MonitorAgent) Check(ctx context.Context) {
    for _, u := range m.urls {
        data, err := m.client.Capture(ctx, u, Options{Width: 1440, FullPage: true})
        if err != nil {
            log.Printf("FAIL %s: %v", u, err)
            continue
        }

        hash := sha256.Sum256(data)
        prev, exists := m.baselines[u]

        if exists && hash != prev {
            log.Printf("CHANGED %s (hash %x → %x)", u, prev[:8], hash[:8])
            // Save the changed version for review
            ts := time.Now().UTC().Format("20060102T150405Z")
            host, _ := url.Parse(u)
            fname := filepath.Join(m.outputDir, fmt.Sprintf("%s-changed-%s.png", host.Hostname(), ts))
            os.WriteFile(fname, data, 0644)
            // TODO: send alert (webhook, email, PagerDuty)
        } else if !exists {
            log.Printf("BASELINE set for %s", u)
        }

        m.baselines[u] = hash
    }
}

func (m *MonitorAgent) Run(ctx context.Context, interval time.Duration) {
    ticker := time.NewTicker(interval)
    defer ticker.Stop()

    m.Check(ctx) // immediate first check
    for {
        select {
        case <-ticker.C:
            m.Check(ctx)
        case <-ctx.Done():
            return
        }
    }
}

func main() {
    client := NewClient(os.Getenv("SCREENSHOT_API_KEY"))
    agent := NewMonitorAgent(client, []string{
        "https://example.com",
        "https://status.example.com",
    }, "/var/screenshots")

    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()

    agent.Run(ctx, 5*time.Minute)
}

CLI Tool with cobra

For a standalone screenshot CLI:

package main

import (
    "context"
    "fmt"
    "os"

    "github.com/spf13/cobra"
)

var rootCmd = &cobra.Command{
    Use:   "screenshot [url]",
    Short: "Capture a screenshot of a URL",
    Args:  cobra.ExactArgs(1),
    RunE: func(cmd *cobra.Command, args []string) error {
        apiKey := os.Getenv("SCREENSHOT_API_KEY")
        if apiKey == "" {
            return fmt.Errorf("SCREENSHOT_API_KEY not set")
        }

        width, _ := cmd.Flags().GetInt("width")
        format, _ := cmd.Flags().GetString("format")
        fullPage, _ := cmd.Flags().GetBool("full-page")
        output, _ := cmd.Flags().GetString("output")

        client := NewClient(apiKey)
        data, err := client.Capture(context.Background(), args[0], Options{
            Width:    width,
            Format:   format,
            FullPage: fullPage,
        })
        if err != nil {
            return err
        }

        if output == "" {
            output = fmt.Sprintf("screenshot.%s", format)
        }
        if err := os.WriteFile(output, data, 0644); err != nil {
            return err
        }
        fmt.Printf("Saved %d bytes to %s\n", len(data), output)
        return nil
    },
}

func init() {
    rootCmd.Flags().Int("width", 1280, "Viewport width in pixels")
    rootCmd.Flags().String("format", "png", "Output format: png, webp, or jpeg")
    rootCmd.Flags().Bool("full-page", false, "Capture full page height")
    rootCmd.Flags().StringP("output", "o", "", "Output filename")
}

func main() {
    if err := rootCmd.Execute(); err != nil {
        os.Exit(1)
    }
}

Testing with httptest

package screenshot_test

import (
    "context"
    "net/http"
    "net/http/httptest"
    "testing"
)

func TestClientCapture(t *testing.T) {
    // Mock server
    server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if r.Header.Get("X-API-Key") != "test-key" {
            http.Error(w, "unauthorized", http.StatusUnauthorized)
            return
        }
        w.Header().Set("Content-Type", "image/png")
        w.Write([]byte("fake-png-data"))
    }))
    defer server.Close()

    client := &Client{
        apiKey:     "test-key",
        baseURL:    server.URL,
        httpClient: server.Client(),
        maxRetries: 1,
    }

    data, err := client.Capture(context.Background(), "https://example.com", Options{})
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    if string(data) != "fake-png-data" {
        t.Errorf("unexpected response: %s", data)
    }
}

func TestClientRetryOn503(t *testing.T) {
    attempts := 0
    server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        attempts++
        if attempts < 3 {
            http.Error(w, "service unavailable", http.StatusServiceUnavailable)
            return
        }
        w.Write([]byte("ok"))
    }))
    defer server.Close()

    client := &Client{
        apiKey:     "test-key",
        baseURL:    server.URL,
        httpClient: server.Client(),
        maxRetries: 3,
    }

    _, err := client.Capture(context.Background(), "https://example.com", Options{})
    if err != nil {
        t.Fatalf("expected success after retries, got: %v", err)
    }
    if attempts != 3 {
        t.Errorf("expected 3 attempts, got %d", attempts)
    }
}

The screenshot API works with any HTTP client. The Go client pattern above is production-ready — context propagation, retry logic, and concurrent batch processing included. First 100 requests free.