Skip to content
unzoi docs

Go

No dependencies — net/http and encoding/json.

The client

package unzoi

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"net/http"
	"net/url"
	"strconv"
	"time"
)

const Base = "https://api.unzoi.com"

// ErrQuotaExhausted is returned when the monthly allowance is used up on a key
// that stops at its quota. Unlike the rate limit it shares a status code with,
// retrying does not help until Reset elapses.
type ErrQuotaExhausted struct{ Reset time.Duration }

func (e *ErrQuotaExhausted) Error() string {
	return fmt.Sprintf("monthly quota exhausted; resets in %s", e.Reset)
}

type Client struct {
	Key  string
	HTTP *http.Client
}

func New(key string) *Client {
	return &Client{Key: key, HTTP: &http.Client{Timeout: 30 * time.Second}}
}

func (c *Client) get(ctx context.Context, path string, params url.Values, out any) error {
	endpoint := Base + path
	if len(params) > 0 {
		endpoint += "?" + params.Encode()
	}

	for attempt := 0; attempt < 6; attempt++ {
		request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
		if err != nil {
			return err
		}
		request.Header.Set("x-api-key", c.Key)

		response, err := c.HTTP.Do(request)
		if err != nil {
			return err
		}

		switch {
		case response.StatusCode == http.StatusTooManyRequests:
			response.Body.Close()
			// Two conditions share this status; only one is worth waiting for.
			if response.Header.Get("x-quota-remaining") == "0" {
				secs, _ := strconv.Atoi(response.Header.Get("retry-after"))
				return &ErrQuotaExhausted{Reset: time.Duration(secs) * time.Second}
			}
			// retry-after is 1 and it is accurate: the bucket refills
			// continuously, so backing off exponentially wastes allowance.
			secs, _ := strconv.Atoi(response.Header.Get("retry-after"))
			if secs == 0 {
				secs = 1
			}
			select {
			case <-time.After(time.Duration(secs) * time.Second):
			case <-ctx.Done():
				return ctx.Err()
			}

		case response.StatusCode == http.StatusServiceUnavailable && attempt < 3:
			response.Body.Close()
			// No shard answered. A narrower time range makes this likelier to
			// succeed than a longer wait does.
			select {
			case <-time.After(time.Duration(1<<attempt) * time.Second):
			case <-ctx.Done():
				return ctx.Err()
			}

		case response.StatusCode >= 400:
			defer response.Body.Close()
			return fmt.Errorf("unzoi: %s", response.Status)

		default:
			defer response.Body.Close()
			return json.NewDecoder(response.Body).Decode(out)
		}
	}
	return errors.New("unzoi: giving up after repeated rate limiting")
}

Types and methods

type Article struct {
	ID            string   `json:"id"`
	Title         string   `json:"title"`
	URL           string   `json:"url"`
	Source        string   `json:"source"`
	Language      string   `json:"language"`
	PublishedAt   string   `json:"published_at"`
	StoryID       string   `json:"story_id"`
	Topics        []string `json:"topics"`
	Organizations []string `json:"organizations"`
	Countries     []string `json:"countries"`
}

type Story struct {
	StoryID string   `json:"story_id"`
	Count   int      `json:"count"`
	Outlets int      `json:"outlets"`
	Title   string   `json:"title"`
	URL     string   `json:"url"`
	Sources []string `json:"sources"`
}

// HistoryDays and FromClamped are on every authenticated response: without them
// a plan boundary is indistinguishable from a corpus with no coverage.
type SearchResponse struct {
	Total         int       `json:"total"`
	TotalRelation string    `json:"total_relation"`
	HasMore       bool      `json:"has_more"`
	Results       []Article `json:"results"`
	HistoryDays   *int      `json:"history_days"`
	FromClamped   bool      `json:"from_clamped"`
}

type StoriesResponse struct {
	Total       int     `json:"total"`
	HasMore     bool    `json:"has_more"`
	Stories     []Story `json:"stories"`
	HistoryDays *int    `json:"history_days"`
	FromClamped bool    `json:"from_clamped"`
}

func (c *Client) Search(ctx context.Context, params url.Values) (*SearchResponse, error) {
	var out SearchResponse
	return &out, c.get(ctx, "/search", params, &out)
}

func (c *Client) Stories(ctx context.Context, params url.Values) (*StoriesResponse, error) {
	var out StoriesResponse
	return &out, c.get(ctx, "/stories", params, &out)
}

Using it

client := unzoi.New(os.Getenv("UNZOI_KEY"))

result, err := client.Stories(context.Background(), url.Values{
	"q":     {"semiconductor export controls"},
	"from":  {"2026-08-01"},
	"limit": {"10"},
})
if err != nil {
	log.Fatal(err)
}

if result.FromClamped {
	log.Printf("window narrowed to the last %d days by your plan", *result.HistoryDays)
}

for _, story := range result.Stories {
	fmt.Printf("%3d articles / %2d outlets  %s\n", story.Count, story.Outlets, story.Title)
}

Generating instead

go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@latest \
  -generate types,client -package unzoi https://api.unzoi.com/openapi.json > unzoi.go

Worth it if you want every field typed. The generated client will not have the retry logic above, though — that is the part that decides whether your integration survives a busy minute.