How to Use the Screenshot API with Go: Complete Guide
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
- Check your quota at hermesforge.dev/api/usage
- Review the full API documentation
- Other language guides: Python | Node.js | Ruby | PHP