curl
export UNZOI_KEY=nai_... Search
curl -s -G "https://api.unzoi.com/search" \
-H "x-api-key: $UNZOI_KEY" \
--data-urlencode "q=semiconductor export controls" \
--data-urlencode "mode=hybrid" \
--data-urlencode "limit=10" Stories
curl -s -G "https://api.unzoi.com/stories" \
-H "x-api-key: $UNZOI_KEY" \
--data-urlencode "q=port congestion" \
--data-urlencode "limit=5" | jq '.stories[] | {count, outlets, title}' Expand a story
curl -s "https://api.unzoi.com/stories/s-9f2c?limit=5" -H "x-api-key: $UNZOI_KEY" \
| jq '{count, outlets, first_seen, last_seen, top_organizations, partial, coverage}' Resolve a name before filtering on it
curl -s -G "https://api.unzoi.com/entities" -H "x-api-key: $UNZOI_KEY" \
--data-urlencode "type=organization" --data-urlencode "q=ASML Holding NV" \
| jq '.entities[] | {canonical, articles, confidence}' Reading the limit headers
curl -sD /dev/stderr -o /dev/null -G "https://api.unzoi.com/search" \
-H "x-api-key: $UNZOI_KEY" \
--data-urlencode "q=inflation" 2>&1 | grep -i '^x-' x-credits-charged: 1
x-credits-per-minute: 600
x-credits-per-minute-remaining: 599
x-credits-per-minute-reset: 60
x-credits-limit: 50000
x-credits-used: 12484
x-credits-remaining: 37516
x-credits-reset: 394200
x-plan-history-days: 365 Price a call before running it
curl -s -G "https://api.unzoi.com/search" -H "x-api-key: $UNZOI_KEY" \
--data-urlencode "q=inflation" --data-urlencode "mode=hybrid" \
--data-urlencode "estimate=true" | jq '{credits, would_run}'
A preview runs nothing and costs 0 credits. Pass max_credits instead to have a call refused when it could
cost more. Credits and overage.
Check your window before a long query
curl -s "https://api.unzoi.com/account" -H "x-api-key: $UNZOI_KEY" | jq '{plan, history_days, credits_remaining}' One MCP tool call
curl -s -X POST https://api.unzoi.com/mcp \
-H "x-api-key: $UNZOI_KEY" \
-H "content-type: application/json" \
-H "accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"list_stories","arguments":{"q":"grid outage","limit":5}}}' \
| jq '.result.structuredContent.stories[] | {count, outlets, title}'
One jq pass: structuredContent is the payload as a JSON object, in the shape the tool's
outputSchema declares. The result also carries one text content block holding the same JSON, for
clients that predate structuredContent; from the shell that is
jq -r '.result.content[0].text' | jq ..., two passes, and you would use one or the other, never both.
More on the shape.
Paging a whole result set
offset=0
while :; do
page=$(curl -s -G "https://api.unzoi.com/search" -H "x-api-key: $UNZOI_KEY" \
--data-urlencode "q=lithium supply" \
--data-urlencode "from=2026-08-01" \
--data-urlencode "limit=100" --data-urlencode "offset=$offset")
echo "$page" | jq -c '.results[] | {id, source, title}'
[ "$(echo "$page" | jq -r '.has_more')" = "true" ] || break
offset=$((offset + 100))
sleep 0.2 # stay under the per-minute rate
done
Stop on has_more rather than by comparing offset to total — for ranked
queries the total is a candidate count, not a corpus count.
Why.
Reading an error
curl -s -G "https://api.unzoi.com/search" -H "x-api-key: $UNZOI_KEY" \
--data-urlencode "q=inflation" --data-urlencode "langauge=eng" | jq . {
"error": "invalid request",
"code": "unknown_parameter",
"detail": "Unknown parameter 'langauge'. Did you mean 'language'?"
}
Every 4xx and 5xx is that shape, 401 and 429 included; switch
on code. Error bodies.
Retrying by hand
None of the examples above retry, on purpose: a one-shot curl call is usually exploration at a
terminal, and the person running it is the retry loop. The moment the same call runs unattended — a cron job, a
shell script triggered by CI — it needs the same two decisions every client on this site's language pages makes in
code: which 429 is worth waiting for, and how long. The body's code says which one it
is — insufficient_credits is the one not to retry.
for attempt in 1 2 3 4 5 6; do
response=$(curl -s -o /tmp/unzoi-body -w '%{http_code} %{header_json}' -G "https://api.unzoi.com/search" \
-H "x-api-key: $UNZOI_KEY" --data-urlencode "q=port congestion")
status=${response%% *}
headers=${response#* }
[ "$status" = "200" ] && { cat /tmp/unzoi-body; break; }
if [ "$status" = "429" ]; then
code=$(jq -r '.code // ""' /tmp/unzoi-body)
retry_after=$(echo "$headers" | jq -r '.["retry-after"][0] // "1"')
if [ "$code" = "insufficient_credits" ]; then
echo "out of credits for the month, resets in ${retry_after}s" >&2
exit 1
fi
sleep "$retry_after" # credits refill continuously; retry-after is the honest wait
elif [ "$status" -ge 500 ]; then
sleep $((2 ** attempt)) # no shard answered — this one is worth backing off
else
echo "unzoi: http $status" >&2
exit 1
fi
done -w '%{http_code} %{header_json}' is the part worth remembering: it is the only way to read both the
status and the response headers from one curl invocation without a second request, and every retry
decision above depends on a header, not on the body.
Common mistakes with this client
- Skipping
-G. Building the query string by hand with&works until a query contains one — a company name with an ampersand in it silently truncates the parameter after it, and the request still returns 200 with the wrong results. - Reusing a shell variable across requests without re-encoding. A quoted variable interpolated
straight into
-G's URL bypasses--data-urlencodeentirely; always pass the raw value to--data-urlencodeand let it encode. - Parsing headers from stdout.
-D -writes headers to stdout interleaved with a redirected body, which breaks the moment you pipe tojq. Send headers to stderr or a temp file, as above, and keep stdout as pure JSON. - Treating a 404 on
/doc/{id}as a bug. An id from an old/searchresponse can age out of the archive window before you fetch it — check/limits/archive-depthrather than assuming the id is malformed.
Debugging a query that returns nothing
Before concluding a subject has no coverage, facet the query itself — an unrecognized filter value and a genuinely
empty corpus return the identical shape, an empty results array with total: 0, and
nothing in that response tells you which one happened.
curl -s -G "https://api.unzoi.com/search" -H "x-api-key: $UNZOI_KEY" \
--data-urlencode "q=port congestion" \
--data-urlencode "organization=maersk" \
--data-urlencode "facets=organization" \
--data-urlencode "facet_limit=10" \
--data-urlencode "limit=0" | jq '.facets.organization' limit=0 asks for no articles at all — only the facet counts over the whole match set — which makes
this cheap to run before every filtered query in a script you are still debugging. If maersk is not
among the values that come back, the filter value is wrong, not the coverage; the canonical spelling is whatever
this facet actually returns, not a guess at how the organization's name is usually written. Run the same query with
the organization filter dropped to see the count it would have matched, and compare — the gap between
the two numbers is what the filter is costing you, which is worth knowing even when it returns something.
The same trick works for a language or country filter that returns nothing — facet on language or
country instead of organization, with everything else unchanged, and read the canonical
codes back rather than guessing at ISO-639-3 or a two-letter country identifier from memory.