DEV Community

Custodia-Admin
Custodia-Admin

Posted on • Originally published at pagebolt.dev

How to Take Screenshots in Go with the PageBolt API

How to Take Screenshots in Go with the PageBolt API

You're building a Go service that needs screenshots. You reach for json.Unmarshal and... nothing happens. The response is binary PNG data, not JSON.

This is the most common mistake when integrating screenshot APIs in Go.

Here's the right way: treat the response as raw bytes, not JSON.

The Basic Pattern: Binary Response Handling

package main

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

// ScreenshotRequest defines the request payload
type ScreenshotRequest struct {
    URL         string `json:"url"`
    Format      string `json:"format"` // png, jpeg, webp
    Width       int    `json:"width,omitempty"`
    Height      int    `json:"height,omitempty"`
    FullPage    bool   `json:"fullPage,omitempty"`
    BlockBanners bool   `json:"blockBanners,omitempty"`
}

func takeScreenshot(url, outputPath, apiKey string) error {
    // Build request
    reqBody := ScreenshotRequest{
        URL:         url,
        Format:      "png",
        Width:       1280,
        Height:      720,
        FullPage:    true,
        BlockBanners: true,
    }

    // Marshal to JSON
    body, err := json.Marshal(reqBody)
    if err != nil {
        return fmt.Errorf("marshal request: %w", err)
    }

    // Create HTTP request
    req, err := http.NewRequest(
        http.MethodPost,
        "https://api.pagebolt.dev/v1/screenshot",
        bytes.NewReader(body),
    )
    if err != nil {
        return fmt.Errorf("create request: %w", err)
    }

    // Set headers
    req.Header.Set("Authorization", "Bearer "+apiKey)
    req.Header.Set("Content-Type", "application/json")

    // Execute request
    client := &http.Client{}
    resp, err := client.Do(req)
    if err != nil {
        return fmt.Errorf("do request: %w", err)
    }
    defer resp.Body.Close()

    // Check status
    if resp.StatusCode != http.StatusOK {
        return fmt.Errorf("api error: %d", resp.StatusCode)
    }

    // Read binary response (NOT JSON)
    screenshotData, err := io.ReadAll(resp.Body)
    if err != nil {
        return fmt.Errorf("read response: %w", err)
    }

    // Write to file
    if err := os.WriteFile(outputPath, screenshotData, 0644); err != nil {
        return fmt.Errorf("write file: %w", err)
    }

    return nil
}

func main() {
    if err := takeScreenshot(
        "https://example.com",
        "screenshot.png",
        os.Getenv("PAGEBOLT_API_KEY"),
    ); err != nil {
        fmt.Fprintf(os.Stderr, "Error: %v\n", err)
        os.Exit(1)
    }

    fmt.Println("Screenshot saved to screenshot.png")
}
Enter fullscreen mode Exit fullscreen mode

Key points:

  • Request is JSON: json.Marshal() the struct
  • Response is binary PNG: use io.ReadAll() to read raw bytes
  • Do NOT use json.Unmarshal() on the response
  • Write with os.WriteFile()

Structured Client: Reusable Approach

For larger projects, create a client struct:

package screenshot

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

// Client wraps the PageBolt API
type Client struct {
    apiKey string
    client *http.Client
}

// NewClient creates a new screenshot client
func NewClient(apiKey string) *Client {
    return &Client{
        apiKey: apiKey,
        client: &http.Client{},
    }
}

// ScreenshotOptions defines screenshot parameters
type ScreenshotOptions struct {
    URL          string
    Format       string // png, jpeg, webp
    Width        int
    Height       int
    FullPage     bool
    BlockBanners bool
    BlockAds     bool
    DarkMode     bool
}

// Request payload
type requestPayload struct {
    URL          string `json:"url"`
    Format       string `json:"format"`
    Width        int    `json:"width,omitempty"`
    Height       int    `json:"height,omitempty"`
    FullPage     bool   `json:"fullPage,omitempty"`
    BlockBanners bool   `json:"blockBanners,omitempty"`
    BlockAds     bool   `json:"blockAds,omitempty"`
    DarkMode     bool   `json:"darkMode,omitempty"`
}

// Take captures a screenshot and writes to file
func (c *Client) Take(opts ScreenshotOptions, outputPath string) error {
    payload := requestPayload{
        URL:          opts.URL,
        Format:       opts.Format,
        Width:        opts.Width,
        Height:       opts.Height,
        FullPage:     opts.FullPage,
        BlockBanners: opts.BlockBanners,
        BlockAds:     opts.BlockAds,
        DarkMode:     opts.DarkMode,
    }

    // Default values
    if payload.Format == "" {
        payload.Format = "png"
    }
    if payload.Width == 0 {
        payload.Width = 1280
    }
    if payload.Height == 0 {
        payload.Height = 720
    }

    // Marshal request
    body, err := json.Marshal(payload)
    if err != nil {
        return fmt.Errorf("marshal: %w", err)
    }

    // Create request
    req, err := http.NewRequest(
        http.MethodPost,
        "https://api.pagebolt.dev/v1/screenshot",
        bytes.NewReader(body),
    )
    if err != nil {
        return fmt.Errorf("new request: %w", err)
    }

    req.Header.Set("Authorization", "Bearer "+c.apiKey)
    req.Header.Set("Content-Type", "application/json")

    // Execute
    resp, err := c.client.Do(req)
    if err != nil {
        return fmt.Errorf("do: %w", err)
    }
    defer resp.Body.Close()

    // Check status
    if resp.StatusCode != http.StatusOK {
        data, _ := io.ReadAll(resp.Body)
        return fmt.Errorf("api error %d: %s", resp.StatusCode, string(data))
    }

    // Read binary response
    data, err := io.ReadAll(resp.Body)
    if err != nil {
        return fmt.Errorf("read: %w", err)
    }

    // Write file
    if err := os.WriteFile(outputPath, data, 0644); err != nil {
        return fmt.Errorf("write: %w", err)
    }

    return nil
}

// TakeBytes captures a screenshot and returns the PNG bytes
func (c *Client) TakeBytes(opts ScreenshotOptions) ([]byte, error) {
    payload := requestPayload{
        URL:          opts.URL,
        Format:       opts.Format,
        Width:        opts.Width,
        Height:       opts.Height,
        FullPage:     opts.FullPage,
        BlockBanners: opts.BlockBanners,
        BlockAds:     opts.BlockAds,
        DarkMode:     opts.DarkMode,
    }

    if payload.Format == "" {
        payload.Format = "png"
    }
    if payload.Width == 0 {
        payload.Width = 1280
    }
    if payload.Height == 0 {
        payload.Height = 720
    }

    body, err := json.Marshal(payload)
    if err != nil {
        return nil, err
    }

    req, err := http.NewRequest(
        http.MethodPost,
        "https://api.pagebolt.dev/v1/screenshot",
        bytes.NewReader(body),
    )
    if err != nil {
        return nil, err
    }

    req.Header.Set("Authorization", "Bearer "+c.apiKey)
    req.Header.Set("Content-Type", "application/json")

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

    if resp.StatusCode != http.StatusOK {
        return nil, fmt.Errorf("api error: %d", resp.StatusCode)
    }

    return io.ReadAll(resp.Body)
}
Enter fullscreen mode Exit fullscreen mode

Usage:

package main

import (
    "fmt"
    "os"

    "yourmodule/screenshot"
)

func main() {
    client := screenshot.NewClient(os.Getenv("PAGEBOLT_API_KEY"))

    // Single screenshot
    err := client.Take(
        screenshot.ScreenshotOptions{
            URL:      "https://example.com",
            FullPage: true,
        },
        "homepage.png",
    )
    if err != nil {
        fmt.Fprintf(os.Stderr, "Error: %v\n", err)
        os.Exit(1)
    }

    // Get bytes instead of writing
    data, err := client.TakeBytes(screenshot.ScreenshotOptions{
        URL: "https://example.com/about",
    })
    if err != nil {
        fmt.Fprintf(os.Stderr, "Error: %v\n", err)
        os.Exit(1)
    }

    fmt.Printf("Screenshot: %d bytes\n", len(data))
}
Enter fullscreen mode Exit fullscreen mode

Concurrent Screenshots: goroutines

Capture multiple URLs in parallel:

package main

import (
    "fmt"
    "os"
    "sync"

    "yourmodule/screenshot"
)

func captureScreenshots(urls []string, apiKey string) error {
    client := screenshot.NewClient(apiKey)
    var wg sync.WaitGroup
    errors := make(chan error, len(urls))

    for i, url := range urls {
        wg.Add(1)
        go func(index int, u string) {
            defer wg.Done()

            filename := fmt.Sprintf("screenshot-%d.png", index)
            err := client.Take(
                screenshot.ScreenshotOptions{
                    URL:      u,
                    FullPage: true,
                },
                filename,
            )
            if err != nil {
                errors <- fmt.Errorf("%s: %w", u, err)
            }
        }(i, url)
    }

    wg.Wait()
    close(errors)

    // Check for errors
    for err := range errors {
        fmt.Fprintf(os.Stderr, "Error: %v\n", err)
    }

    return nil
}

func main() {
    urls := []string{
        "https://example.com",
        "https://example.com/about",
        "https://example.com/pricing",
    }

    if err := captureScreenshots(urls, os.Getenv("PAGEBOLT_API_KEY")); err != nil {
        os.Exit(1)
    }

    fmt.Println("All screenshots complete")
}
Enter fullscreen mode Exit fullscreen mode

Real Use Case: Web Monitoring Service

Monitor website changes:

package main

import (
    "crypto/md5"
    "fmt"
    "io"
    "os"
    "time"

    "yourmodule/screenshot"
)

// MonitorSite checks if a website has changed
func monitorSite(url string, apiKey string) error {
    client := screenshot.NewClient(apiKey)
    baselineFile := "baseline.png"
    currentFile := "current.png"

    // First run: create baseline
    if _, err := os.Stat(baselineFile); os.IsNotExist(err) {
        fmt.Println("Creating baseline...")
        return client.Take(
            screenshot.ScreenshotOptions{URL: url, FullPage: true},
            baselineFile,
        )
    }

    // Subsequent runs: compare
    err := client.Take(
        screenshot.ScreenshotOptions{URL: url, FullPage: true},
        currentFile,
    )
    if err != nil {
        return err
    }

    baseline, _ := os.ReadFile(baselineFile)
    current, _ := os.ReadFile(currentFile)

    baselineHash := fmt.Sprintf("%x", md5.Sum(baseline))
    currentHash := fmt.Sprintf("%x", md5.Sum(current))

    if baselineHash != currentHash {
        fmt.Printf("CHANGED: %s has been modified\n", url)
        fmt.Printf("Baseline: %s\n", baselineHash)
        fmt.Printf("Current:  %s\n", currentHash)
        return nil
    }

    fmt.Println("No changes detected")
    return nil
}

func main() {
    // Run every hour
    ticker := time.NewTicker(time.Hour)
    defer ticker.Stop()

    for {
        if err := monitorSite("https://example.com", os.Getenv("PAGEBOLT_API_KEY")); err != nil {
            fmt.Fprintf(os.Stderr, "Error: %v\n", err)
        }

        <-ticker.C
    }
}
Enter fullscreen mode Exit fullscreen mode

Testing: Mock the API

package screenshot

import (
    "bytes"
    "io"
    "net/http"
    "testing"
)

// MockTransport mocks HTTP responses
type MockTransport struct {
    response []byte
    status   int
}

func (m *MockTransport) RoundTrip(req *http.Request) (*http.Response, error) {
    return &http.Response{
        StatusCode: m.status,
        Body:       io.NopCloser(bytes.NewReader(m.response)),
        Header:     make(http.Header),
    }, nil
}

func TestTakeScreenshot(t *testing.T) {
    mockData := []byte{137, 80, 78, 71} // PNG magic bytes

    client := &Client{
        apiKey: "test-key",
        client: &http.Client{
            Transport: &MockTransport{
                response: mockData,
                status:   http.StatusOK,
            },
        },
    }

    data, err := client.TakeBytes(ScreenshotOptions{
        URL: "https://example.com",
    })
    if err != nil {
        t.Fatalf("TakeBytes failed: %v", err)
    }

    if !bytes.Equal(data, mockData) {
        t.Errorf("Got %v, want %v", data, mockData)
    }
}
Enter fullscreen mode Exit fullscreen mode

Common Mistakes

❌ Wrong ✅ Right
json.Unmarshal(body, &var) io.ReadAll(resp.Body)
ioutil.ReadAll() (deprecated) io.ReadAll()
Assuming Content-Type: application/json Binary response = PNG bytes
err == nil without checking status resp.StatusCode == http.StatusOK
Closing body in defer after error Defer before checking status

Pricing

Plan Requests/Month Cost Best For
Free 100 $0 Learning, low-volume projects
Starter 5,000 $29 Small teams, moderate use
Growth 25,000 $79 Production apps, frequent calls
Scale 100,000 $199 High-volume automation

Summary

  • ✅ Idiomatic Go with http.NewRequest and proper headers
  • ✅ Binary response handling with io.ReadAll() (not JSON)
  • ✅ File writing with os.WriteFile()
  • ✅ Concurrent requests with goroutines
  • ✅ Structured client for reuse
  • ✅ Easy testing with mock HTTP transport
  • ✅ No external dependencies

Get started free: pagebolt.dev — 100 requests/month, no credit card required.

Top comments (0)