Blog Finding the cause

Query and export logs over HTTP

Sometimes the logs need to go somewhere other than a browser tab. A CI job that fails a deploy when the new release logs errors. A nightly export to your own storage. A script that answers "did this order ever hit the payment error?"

ZipLogger has two HTTP ways to read logs back, and they take different credentials. This post shows both with real requests, and when to use the MCP server instead.

Which one to use

REST searchLoki-compatible endpointMCP server
Path/api/v1/logs/search/grafana/loki/api/v1/query_range/mcp
CredentialSign-in token (JWT)API key with read accessOAuth or read key
Query languageSearch page syntaxA subset of LogQLThe assistant writes it
PagingCursor, any depthNone, up to 5,000 linesUp to 100 lines a call
Best forExports, full recordsCI checks, quick scriptsAsking from an AI assistant

The REST API is what the ZipLogger web app itself calls. It does not accept API keys. The Loki-compatible endpoint is the one Grafana uses, and it is the one to use if you want a plain key in a CI secret.

The REST search API

Get a token

Sign in with an email and password. For a script, add a separate team member with the Viewer role under Team, so the credential can read but can't change anything. It takes one of your plan's user seats.

curl -s -X POST https://app.ziplogger.ai/api/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"[email protected]","password":"YOUR_PASSWORD"}'

The response has accessToken, accessTokenExpires, refreshToken and user. The access token is short-lived. When it expires, post {"refreshToken":"..."} to /api/v1/auth/refresh for a new pair. The old refresh token stops working as soon as you use it, so always keep the newest one. Wrong credentials answer 401 with {"error":"Invalid credentials"}.

curl -sG https://app.ziplogger.ai/api/v1/logs/search \
  -H "Authorization: Bearer $TOKEN" \
  --data-urlencode 'q=timeout AND fields.orderId:83112' \
  --data-urlencode "severity=error" \
  --data-urlencode "from=2026-09-28T00:00:00Z" \
  --data-urlencode "to=2026-09-29T00:00:00Z" \
  --data-urlencode "size=100"
ParameterWhat it does
qQuery in the Search page syntax. Empty matches everything.
from, toISO-8601 time bounds.
sizeLines per page. Default 100, at most 1,000.
afterThe nextAfter cursor from the previous page.
severity, source, release, commitSha, templateHashExact-match filters.

q is the same syntax as the search box: plain words match the message, and every word must match unless you write OR. You can use "quoted phrases", field:value such as source:checkout-api or fields.userId:123, NOT, and wildcards.

The answer is {"total": ..., "hits": [...], "nextAfter": "..."}. Each hit is the full stored line: id, timestamp, severity, message, source, release, commitSha, stackTrace, fields and the rest. Lines come newest first.

Paging: follow the cursor

There is no offset parameter. Pass nextAfter back as after until it comes back null. nextAfter is only set when a page is full, so a short page is the last one.

This Python script exports one day of error logs to NDJSON. It uses only the standard library:

import json, os, urllib.parse, urllib.request

BASE = os.environ.get("ZL_BASE", "https://app.ziplogger.ai")

def call(path, token=None, params=None, body=None):
    url = BASE + path + ("?" + urllib.parse.urlencode(params) if params else "")
    headers = {"Content-Type": "application/json"}
    if token:
        headers["Authorization"] = f"Bearer {token}"
    data = json.dumps(body).encode() if body is not None else None
    with urllib.request.urlopen(urllib.request.Request(url, data=data, headers=headers), timeout=60) as r:
        return json.load(r)

session = call("/api/v1/auth/login", body={"email": os.environ["ZL_EMAIL"], "password": os.environ["ZL_PASSWORD"]})
token = session["accessToken"]

params = {"severity": "error", "from": "2026-09-28T00:00:00Z", "to": "2026-09-29T00:00:00Z", "size": 1000}
written = 0
with open("errors-2026-09-28.ndjson", "w", encoding="utf-8") as out:
    while True:
        page = call("/api/v1/logs/search", token, params)
        for hit in page["hits"]:
            out.write(json.dumps(hit) + "\n")
        written += len(page["hits"])
        if not page.get("nextAfter"):
            break
        params["after"] = page["nextAfter"]

print(f"wrote {written} lines (total reported: {page['total']})")

Against a local stand-in with 2,350 lines that pages the same way (full pages of 1,000, then a short one), it printed wrote 2350 lines (total reported: 2350). For an export that runs longer than the token lasts, catch the 401, call /api/v1/auth/refresh, and retry the same page with the same after.

Other read endpoints in the same group take the same token and parameters: /api/v1/logs/histogram for counts over time and /api/v1/logs/terms?field=source for the top values of a field.

The Loki-compatible endpoint, with a read key

Make a read key

Go to Settings → API keys (Admin role), type a name, tick Can read data and click Create key. The key reads only the environment it is created in, chosen in the Environment menu next to the name. The key is shown once. Keys are ingest-only unless you tick that box, and an ingest-only key is refused here, because ingest keys end up in browser bundles where anyone can read them. Treat a read key like a password.

Query

curl -sG https://app.ziplogger.ai/grafana/loki/api/v1/query_range \
  -H "X-Api-Key: YOUR_READ_KEY" \
  --data-urlencode 'query={service="checkout-api", field_orderId="83112"}' \
  --data-urlencode "start=2026-09-29T08:00:00Z" \
  --data-urlencode "end=2026-09-29T09:00:00Z" \
  --data-urlencode "limit=500"

This is the response from ZipLogger's endpoint code, run in a test host with two log lines stored:

{"status":"success","data":{"resultType":"streams","result":[
  {"stream":{"severity":"error","level":"error","service":"checkout-api","release":"2026.9.29"},
   "values":[["1790669643512000000","Payment provider timeout orderId=83112 provider=card"]]},
  {"stream":{"severity":"warn","level":"warn","service":"checkout-api","release":"2026.9.29"},
   "values":[["1790669638004000000","Retrying charge orderId=83112 attempt=2"]]}]}}

That is Loki's format: lines grouped by label set, each value a nanosecond timestamp and the line. The line is the message followed by its fields in logfmt, plus stack_trace=... when there is one. It does not include the line's ZipLogger id.

What the query can say

The selector takes these labels: service (or source), severity (or level), release, commit_sha, template, and field_<key> for any structured field. LogQL labels can't contain dots, so fields.orderId is written field_orderId. Matchers are =, !=, =~ and !~. After the selector you can add line filters on the message: |= contains, != does not contain, |~ and !~ for regular expressions.

count_over_time({...}[5m]) and rate({...}[1m]) also work, optionally inside sum by (service) (...), and answer a time series instead of lines.

Anything else is refused with a 400 that says what is supported. Parser stages like | json are one example. An unknown label is another:

{"status":"error","errorType":"bad_data","error":"Unknown label \"app\". Available: service, source, severity, level, release, commit_sha, commitSha, template, tenant, or field_<key> for a structured field."}

start and end take RFC 3339 or a Unix timestamp in seconds, milliseconds or nanoseconds. Leave them out and you get the last hour. limit defaults to 1,000 and is capped at 5,000. There is no cursor, so if a window has more lines than that, narrow the window or use the REST search.

Each request, including one refused as a bad query, counts as one API request on your plan. A missing or ingest-only key answers 401 and is not counted.

If you want these same logs on a Grafana dashboard, the Grafana data source post covers that setup.

When to use MCP instead

If the person asking is you, in an editor, the MCP server is less work. Claude Code or Cursor calls the search for you, reads the stack trace, and opens the file it names. It is read-only and returns at most 100 lines per call, so it is for questions, not exports.

Limits

  • No API key on the REST API. It needs a sign-in token, so a script holds an account's password. Use a Viewer account for it, and keep the password in your CI secrets.
  • One workspace per credential, one environment per request. The API reads the environment named in the X-ZipLogger-Environment header (or ?env=), else the workspace default. A read key only ever reads the environment it was created in. There is no cross-workspace or cross-environment query.
  • Retention. You can only read what is still inside your plan's retention.
  • Egress. REST responses of 64 KB or more count toward your plan's egress allowance. The numbers are on the pricing page.
  • Read-only here. Deleting and hiding logs exist in the REST API but need the Editor or Admin role. Neither the read key nor MCP can delete anything.

The endpoints and auth are summarised in the REST API and API keys docs. If you don't have a workspace yet, sign up, send a few logs, and try the search call above.

Use the Sentry SDK with ZipLogger

Keep your Sentry SDK and send its errors, traces and logs to ZipLogger by changing the DSN. Node, browser and Python setups, and what is dropped.

AI log analysis with your own API key

How ZipLogger's AI analysis works with your own Anthropic, OpenAI or Gemini key: what each feature does, exactly what is sent to the model, and the limits.

Centralized Python logging with ZipLogger

Send Python logs to the cloud with the standard logging module and one handler. Setup, Django config, searchable extra fields and tracebacks.

Try it on your own logs

There is a free plan and no sales call. Install an SDK, send a few log lines, and search them.