Skip to content
unzoi docs
Search and navigation
Start here
REST API
MCP
Limits and plans
Agent clients
SDKs
Guides

curl

export UNZOI_KEY=nai_...
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-urlencode entirely; always pass the raw value to --data-urlencode and let it encode.
  • Parsing headers from stdout. -D - writes headers to stdout interleaved with a redirected body, which breaks the moment you pipe to jq. 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 /search response can age out of the archive window before you fetch it — check /limits/archive-depth rather 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.