How to Use the Screenshot API with Go: Complete Guide

2026-04-29 | Tags: [screenshot-api, go, golang, gin, tutorial, sdk]

How to Use the Screenshot API with Go: Complete Guide

Go's concurrency model and strong standard library make it well-suited for integrating screenshot APIs — especially in high-throughput agent pipelines where goroutines can capture many pages in parallel. This guide covers everything from a basic net/http call to a production-ready client with retries, context cancellation, and Gin/Echo framework integration.

Prerequisites

You need an API key from hermesforge.dev. A free tier is available for testing. All examples use the endpoint https://hermesforge.dev/api/screenshot.

Basic Request with net/http

Go's standard library handles this without external dependencies:

package main

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

func main() {
    apiKey := os.Getenv("SCREENSHOT_API_KEY")

    params := url.Values{}
    params.Set("url", "https://example.com")
    params.Set("width", "1280")
    params.Set("height", "800")
    params.Set("format", "png")
    params.Set("full_page", "true")

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

    req, err := http.NewRequest("GET", endpoint, nil)
    if err != nil {
        panic(err)
    }
    req.Header.Set("X-API-Key", apiKey)

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

    if resp.StatusCode != http.StatusOK {
        body, _ := io.ReadAll(resp.Body)
        fmt.Fprintf(os.Stderr, "Error %d: %s\n", resp.StatusCode, body)
        os.Exit(1)
    }

    data, _ := io.ReadAll(resp.Body)
    os.WriteFile("screenshot.png", data, 0644)
    fmt.Printf("Saved screenshot.png (%d bytes)\n", len(data))
}

Full Parameter Reference

Parameter Type Default Description
url string required Page URL to capture
width integer 1280 Viewport width in pixels
height integer 800 Viewport height in pixels
format string png Output format: png or jpeg
full_page boolean false Capture full scrollable page
delay integer 0 Wait milliseconds before capture
quality integer 90 JPEG quality (1–100)

Reusable ScreenshotClient

A production-ready client struct with caching, retry logic, and context support:

package screenshot

import (
    "context"
    "crypto/sha256"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "net/url"
    "os"
    "path/filepath"
    "time"
)

// Client errors
var (
    ErrRateLimit   = fmt.Errorf("rate limit exceeded")
    ErrUnauthorized = fmt.Errorf("invalid or missing API key")
    ErrBadRequest  = fmt.Errorf("bad request")
)

// CaptureOptions configures a screenshot request.
type CaptureOptions struct {
    Width    int
    Height   int
    Format   string
    FullPage bool
    Delay    int
    Quality  int
}

// DefaultOptions returns sensible defaults.
func DefaultOptions() CaptureOptions {
    return CaptureOptions{
        Width:   1280,
        Height:  800,
        Format:  "png",
        Quality: 90,
    }
}

// QuotaInfo holds API usage data from /api/usage.
type QuotaInfo struct {
    CallsThisPeriod        int    `json:"calls_this_period"`
    RateLimitRemainingToday int    `json:"rate_limit_remaining_today"`
    Status                 string `json:"status"`
}

// Client is a reusable screenshot API client.
type Client struct {
    apiKey     string
    httpClient *http.Client
    cacheDir   string
    maxRetries int
}

// NewClient creates a new Client. cacheDir may be empty to disable caching.
func NewClient(apiKey, cacheDir string) *Client {
    return &Client{
        apiKey:     apiKey,
        httpClient: &http.Client{Timeout: 30 * time.Second},
        cacheDir:   cacheDir,
        maxRetries: 3,
    }
}

// Capture fetches a screenshot and returns the binary image data.
func (c *Client) Capture(ctx context.Context, pageURL string, opts CaptureOptions) ([]byte, error) {
    params := url.Values{}
    params.Set("url", pageURL)
    params.Set("width", fmt.Sprintf("%d", opts.Width))
    params.Set("height", fmt.Sprintf("%d", opts.Height))
    params.Set("format", opts.Format)
    params.Set("delay", fmt.Sprintf("%d", opts.Delay))
    params.Set("quality", fmt.Sprintf("%d", opts.Quality))
    if opts.FullPage {
        params.Set("full_page", "true")
    }

    // Check cache
    if data := c.readCache(params); data != nil {
        return data, nil
    }

    data, err := c.doWithRetry(ctx, params)
    if err != nil {
        return nil, err
    }

    c.writeCache(params, data)
    return data, nil
}

// Save captures a screenshot and writes it to path.
func (c *Client) Save(ctx context.Context, pageURL, path string, opts CaptureOptions) error {
    data, err := c.Capture(ctx, pageURL, opts)
    if err != nil {
        return err
    }
    return os.WriteFile(path, data, 0644)
}

// Quota returns current API usage information.
func (c *Client) Quota(ctx context.Context) (*QuotaInfo, error) {
    req, err := http.NewRequestWithContext(ctx, "GET", "https://hermesforge.dev/api/usage", 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()

    var info QuotaInfo
    if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
        return nil, err
    }
    return &info, nil
}

func (c *Client) doWithRetry(ctx context.Context, params url.Values) ([]byte, error) {
    endpoint := "https://hermesforge.dev/api/screenshot?" + params.Encode()

    for attempt := 0; attempt <= c.maxRetries; attempt++ {
        req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil)
        if err != nil {
            return nil, err
        }
        req.Header.Set("X-API-Key", c.apiKey)

        resp, err := c.httpClient.Do(req)
        if err != nil {
            if attempt == c.maxRetries {
                return nil, fmt.Errorf("request failed after %d attempts: %w", c.maxRetries, err)
            }
            time.Sleep(time.Duration(attempt+1) * time.Second)
            continue
        }
        defer resp.Body.Close()

        switch resp.StatusCode {
        case http.StatusOK:
            return io.ReadAll(resp.Body)

        case http.StatusTooManyRequests:
            if attempt == c.maxRetries {
                return nil, ErrRateLimit
            }
            retryAfter := time.Duration(1<<attempt) * time.Second // 1, 2, 4s
            select {
            case <-ctx.Done():
                return nil, ctx.Err()
            case <-time.After(retryAfter):
            }

        case http.StatusBadRequest:
            body, _ := io.ReadAll(resp.Body)
            return nil, fmt.Errorf("%w: %s", ErrBadRequest, body)

        case http.StatusUnauthorized, http.StatusForbidden:
            return nil, ErrUnauthorized

        default:
            if attempt == c.maxRetries {
                body, _ := io.ReadAll(resp.Body)
                return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, body)
            }
            time.Sleep(time.Duration(attempt+1) * time.Second)
        }
    }
    return nil, fmt.Errorf("max retries exceeded")
}

func (c *Client) cacheKey(params url.Values) string {
    h := sha256.Sum256([]byte(params.Encode()))
    return fmt.Sprintf("%x", h)
}

func (c *Client) readCache(params url.Values) []byte {
    if c.cacheDir == "" {
        return nil
    }
    path := filepath.Join(c.cacheDir, c.cacheKey(params)+"."+params.Get("format"))
    data, err := os.ReadFile(path)
    if err != nil {
        return nil
    }
    return data
}

func (c *Client) writeCache(params url.Values, data []byte) {
    if c.cacheDir == "" {
        return
    }
    os.MkdirAll(c.cacheDir, 0755)
    path := filepath.Join(c.cacheDir, c.cacheKey(params)+"."+params.Get("format"))
    os.WriteFile(path, data, 0644)
}

Usage:

client := screenshot.NewClient(os.Getenv("SCREENSHOT_API_KEY"), "/tmp/screenshot_cache")
ctx := context.Background()

opts := screenshot.DefaultOptions()
opts.FullPage = true

data, err := client.Capture(ctx, "https://example.com", opts)
if err != nil {
    log.Fatal(err)
}
os.WriteFile("output.png", data, 0644)

// Check quota
quota, err := client.Quota(ctx)
fmt.Printf("Remaining today: %d\n", quota.RateLimitRemainingToday)

Concurrent Batch Processing with Goroutines

Go's goroutines make parallel capture straightforward. Use a semaphore to bound concurrency:

package main

import (
    "context"
    "fmt"
    "log"
    "net/url"
    "os"
    "sync"

    "github.com/yourorg/screenshot"
)

type Result struct {
    URL   string
    Path  string
    Error error
}

func captureBatch(client *screenshot.Client, urls []string, concurrency int) []Result {
    ctx := context.Background()
    opts := screenshot.DefaultOptions()
    opts.FullPage = true

    // Check quota first
    quota, err := client.Quota(ctx)
    if err != nil {
        log.Printf("Warning: could not check quota: %v", err)
    } else if quota.RateLimitRemainingToday < len(urls) {
        log.Printf("Warning: %d URLs requested but only %d calls remaining",
            len(urls), quota.RateLimitRemainingToday)
    }

    sem := make(chan struct{}, concurrency)
    results := make([]Result, len(urls))
    var wg sync.WaitGroup

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

            host, _ := url.Parse(pageURL)
            path := fmt.Sprintf("screenshots/%s.png", host.Hostname())

            err := client.Save(ctx, pageURL, path, opts)
            results[idx] = Result{URL: pageURL, Path: path, Error: err}
        }(i, u)
    }

    wg.Wait()
    return results
}

func main() {
    client := screenshot.NewClient(os.Getenv("SCREENSHOT_API_KEY"), "/tmp/cache")
    os.MkdirAll("screenshots", 0755)

    urls := []string{
        "https://go.dev",
        "https://pkg.go.dev",
        "https://github.com/golang/go",
        "https://gobyexample.com",
    }

    results := captureBatch(client, urls, 3) // max 3 concurrent

    for _, r := range results {
        if r.Error != nil {
            fmt.Printf("✗ %s: %v\n", r.URL, r.Error)
        } else {
            fmt.Printf("✓ %s → %s\n", r.URL, r.Path)
        }
    }
}

Gin Framework Integration

package main

import (
    "context"
    "net/http"
    "os"
    "strconv"

    "github.com/gin-gonic/gin"
    "github.com/yourorg/screenshot"
)

var screenshotClient *screenshot.Client

func main() {
    screenshotClient = screenshot.NewClient(
        os.Getenv("SCREENSHOT_API_KEY"),
        "/tmp/screenshot_cache",
    )

    r := gin.Default()
    r.GET("/screenshots", captureHandler)
    r.Run(":8080")
}

func captureHandler(c *gin.Context) {
    pageURL := c.Query("url")
    if pageURL == "" {
        c.JSON(http.StatusBadRequest, gin.H{"error": "url parameter required"})
        return
    }

    opts := screenshot.DefaultOptions()
    if w := c.Query("width"); w != "" {
        if n, err := strconv.Atoi(w); err == nil {
            opts.Width = n
        }
    }
    if h := c.Query("height"); h != "" {
        if n, err := strconv.Atoi(h); err == nil {
            opts.Height = n
        }
    }
    if fp := c.Query("full_page"); fp == "true" {
        opts.FullPage = true
    }
    if f := c.Query("format"); f == "jpeg" || f == "png" {
        opts.Format = f
    }

    ctx, cancel := context.WithTimeout(c.Request.Context(), 25*time.Second)
    defer cancel()

    data, err := screenshotClient.Capture(ctx, pageURL, opts)
    if err != nil {
        switch err {
        case screenshot.ErrRateLimit:
            c.JSON(http.StatusTooManyRequests, gin.H{"error": "rate limit exceeded"})
        case screenshot.ErrUnauthorized:
            c.JSON(http.StatusServiceUnavailable, gin.H{"error": "screenshot service unavailable"})
        case screenshot.ErrBadRequest:
            c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        default:
            c.JSON(http.StatusInternalServerError, gin.H{"error": "capture failed"})
        }
        return
    }

    contentType := "image/png"
    if opts.Format == "jpeg" {
        contentType = "image/jpeg"
    }

    c.Data(http.StatusOK, contentType, data)
}

Echo Framework Integration

package main

import (
    "context"
    "net/http"
    "os"
    "time"

    "github.com/labstack/echo/v4"
    "github.com/yourorg/screenshot"
)

func main() {
    client := screenshot.NewClient(os.Getenv("SCREENSHOT_API_KEY"), "/tmp/cache")

    e := echo.New()
    e.GET("/screenshots", func(ec echo.Context) error {
        pageURL := ec.QueryParam("url")
        if pageURL == "" {
            return ec.JSON(http.StatusBadRequest, map[string]string{"error": "url required"})
        }

        opts := screenshot.DefaultOptions()
        opts.FullPage = ec.QueryParam("full_page") == "true"

        ctx, cancel := context.WithTimeout(ec.Request().Context(), 25*time.Second)
        defer cancel()

        data, err := client.Capture(ctx, pageURL, opts)
        if err != nil {
            return ec.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
        }

        return ec.Blob(http.StatusOK, "image/png", data)
    })

    e.Logger.Fatal(e.Start(":8080"))
}

Error Handling Reference

Error Cause Recommended Response
ErrBadRequest Invalid URL or parameters Return 400 to caller
ErrUnauthorized Bad API key Alert ops; disable feature
ErrRateLimit Daily quota exceeded Queue for next day or upgrade tier
context.DeadlineExceeded Capture timed out Increase timeout or add delay param
Network error Connectivity issue Retry with backoff (built into client)

Next Steps