Using SheetsAPI with Go: A Typed HTTP Client with Caching
Build a production-ready Go client for SheetsAPI - typed structs, context-aware requests, sync.Map TTL cache, and an http.HandleFunc proxy handler using only the standard library.
Google Sheets is a surprisingly capable datastore for internal tools, CMS content, and lightweight CRUD - but the raw Sheets API involves OAuth flows, service accounts, and a fair amount of boilerplate. SheetsAPI wraps your spreadsheet in a clean REST interface: authenticated reads and writes over plain HTTP with a bearer token.
This post builds a fully typed Go client for SheetsAPI using nothing but the standard library. We will cover struct modeling, context propagation, error handling, a TTL cache backed by sync.Map, and a small proxy handler you can drop into any net/http server.
The API Contract
Every request targets a sheet by a user key and sheet name:
GET https://sheetsapi.io/api/spreadsheets/{userKey}/{sheetName}
POST https://sheetsapi.io/api/spreadsheets/{userKey}/{sheetName}
Supported query parameters for GET requests:
| Parameter | Purpose |
|---|---|
limit | Max rows to return |
offset | Pagination offset |
filter[col] | Filter rows where col equals value |
Authentication is a bearer token in every request header:
Authorization: Bearer sk_...
Every GET response looks like this:
{
"data": [...],
"meta": { "total": 120, "limit": 20, "offset": 0 }
}Defining the Types
Start with the response envelope and a generic row type. Sheets rows are key-value maps, so map[string]any handles arbitrary column names cleanly.
package sheetsapi
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"sync"
"time"
)
const baseURL = "https://sheetsapi.io/api/spreadsheets"
// Meta holds pagination metadata returned by the API.
type Meta struct {
Total int `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
// Response is the typed envelope for a list response.
type Response struct {
Data []map[string]any `json:"data"`
Meta Meta `json:"meta"`
}
// QueryOptions configures a GET request.
type QueryOptions struct {
Limit int
Offset int
Filters map[string]string // column name -> value
}The Client
The client holds an http.Client, the bearer token, and the user key. Keeping http.Client as a field rather than using http.DefaultClient lets callers configure timeouts and transports independently.
// Client is a SheetsAPI HTTP client.
type Client struct {
httpClient *http.Client
userKey string
token string
}
// NewClient returns a Client with a 10-second default timeout.
func NewClient(userKey, token string) *Client {
return &Client{
httpClient: &http.Client{Timeout: 10 * time.Second},
userKey: userKey,
token: token,
}
}
func (c *Client) endpoint(sheet string) string {
return fmt.Sprintf("%s/%s/%s", baseURL, c.userKey, sheet)
}
func (c *Client) authorize(req *http.Request) {
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Accept", "application/json")
}Reading Rows
The Get method builds a URL with query parameters, executes the request with the provided context, and decodes the typed response.
// Get fetches rows from a sheet with optional filtering and pagination.
func (c *Client) Get(ctx context.Context, sheet string, opts QueryOptions) (*Response, error) {
u, err := url.Parse(c.endpoint(sheet))
if err != nil {
return nil, fmt.Errorf("sheetsapi: invalid endpoint: %w", err)
}
q := u.Query()
if opts.Limit > 0 {
q.Set("limit", strconv.Itoa(opts.Limit))
}
if opts.Offset > 0 {
q.Set("offset", strconv.Itoa(opts.Offset))
}
for col, val := range opts.Filters {
q.Set("filter["+col+"]", val)
}
u.RawQuery = q.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return nil, fmt.Errorf("sheetsapi: build request: %w", err)
}
c.authorize(req)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("sheetsapi: do request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("sheetsapi: unexpected status %d", resp.StatusCode)
}
var result Response
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("sheetsapi: decode response: %w", err)
}
return &result, nil
}Appending Rows
POST requests send a JSON body. The API accepts a single row object or a slice of objects - here we accept any and let json.Marshal handle the encoding.
// Append adds one or more rows to a sheet.
func (c *Client) Append(ctx context.Context, sheet string, rows any) error {
body, err := json.Marshal(rows)
if err != nil {
return fmt.Errorf("sheetsapi: marshal body: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint(sheet), bytes.NewReader(body))
if err != nil {
return fmt.Errorf("sheetsapi: build request: %w", err)
}
c.authorize(req)
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("sheetsapi: do request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("sheetsapi: append failed with status %d", resp.StatusCode)
}
return nil
}In-Memory TTL Cache with sync.Map
Sheet data that changes infrequently is a good caching candidate. sync.Map gives us concurrent-safe storage without a mutex on every read.
type cacheEntry struct {
response *Response
expiresAt time.Time
}
// Cache wraps a Client and caches GET responses for a fixed TTL.
type Cache struct {
client *Client
ttl time.Duration
store sync.Map
}
// NewCache returns a caching wrapper around a Client.
func NewCache(client *Client, ttl time.Duration) *Cache {
return &Cache{client: client, ttl: ttl}
}
func cacheKey(sheet string, opts QueryOptions) string {
return fmt.Sprintf("%s:%d:%d:%v", sheet, opts.Limit, opts.Offset, opts.Filters)
}
// Get returns a cached response if available, otherwise fetches and stores it.
func (cc *Cache) Get(ctx context.Context, sheet string, opts QueryOptions) (*Response, error) {
key := cacheKey(sheet, opts)
if v, ok := cc.store.Load(key); ok {
entry := v.(cacheEntry)
if time.Now().Before(entry.expiresAt) {
return entry.response, nil
}
cc.store.Delete(key)
}
resp, err := cc.client.Get(ctx, sheet, opts)
if err != nil {
return nil, err
}
cc.store.Store(key, cacheEntry{
response: resp,
expiresAt: time.Now().Add(cc.ttl),
})
return resp, nil
}Proxy Handler
This handler reads sheet, limit, and offset from query parameters and proxies the request through the caching client. It is straightforward to mount in any net/http mux.
// SheetHandler returns an http.HandlerFunc that proxies GET requests to SheetsAPI.
func SheetHandler(cache *Cache) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
sheet := r.URL.Query().Get("sheet")
if sheet == "" {
http.Error(w, "missing sheet parameter", http.StatusBadRequest)
return
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
opts := QueryOptions{Limit: limit, Offset: offset}
result, err := cache.Get(r.Context(), sheet, opts)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(result)
}
}Wire it up:
func main() {
client := NewClient("your-user-key", "sk_...")
cache := NewCache(client, 60*time.Second)
http.HandleFunc("/api/sheet", SheetHandler(cache))
http.ListenAndServe(":8080", nil)
}Wrapping Up
The full client is around 120 lines of standard library code. The patterns here - context propagation through every request, wrapping errors with %w for unwrapping, keeping the http.Client configurable, and a sync.Map cache with explicit TTL expiry - translate directly to any other JSON API you need to call from Go.
If your sheet schema is fixed, you can take this further by decoding data into a concrete struct using a custom UnmarshalJSON on a wrapper type, giving you compile-time field access instead of map lookups.
SheetsAPI handles the Sheets authentication layer so you ship faster. Go handles the rest.