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 search | Loki-compatible endpoint | MCP server | |
|---|---|---|---|
| Path | /api/v1/logs/search | /grafana/loki/api/v1/query_range | /mcp |
| Credential | Sign-in token (JWT) | API key with read access | OAuth or read key |
| Query language | Search page syntax | A subset of LogQL | The assistant writes it |
| Paging | Cursor, any depth | None, up to 5,000 lines | Up to 100 lines a call |
| Best for | Exports, full records | CI checks, quick scripts | Asking 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"}.
Search
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"
| Parameter | What it does |
|---|---|
q | Query in the Search page syntax. Empty matches everything. |
from, to | ISO-8601 time bounds. |
size | Lines per page. Default 100, at most 1,000. |
after | The nextAfter cursor from the previous page. |
severity, source, release, commitSha, templateHash | Exact-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-Environmentheader (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.