Screenshot API with Go: Complete Integration Guide
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.