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.