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.
GKit is currently undergoing platform maintenance
Be the first to know when all Google Workspace tools are back online, plus get our engineering deep-dives and product announcements directly to your inbox.