Send logs over HTTP with curl
Not everything that should log has an SDK. A backup script, a CI step, a database job or a service in a language we do not cover can still send logs if it can make an HTTP request. This post is the whole ingestion API: one endpoint, one header, and the rules the server actually applies.
The first request
You need an API key. In the app, go to Settings → API keys and click Create key. The key starts with zk_ and is shown once. A new key can only send data, not read it, so it is safe to put in a CI secret.
curl -i -X POST https://app.ziplogger.ai/ingest/v1/logs \
-H "X-Api-Key: $ZIPLOGGER_API_KEY" \
-H "Content-Type: application/json" \
-d '{"message":"Deploy finished","severity":"info","source":"ci","release":"2026.09.29","fields":{"pipeline":"main","durationMs":84210}}'
The answer:
HTTP/1.1 202 Accepted
Content-Type: application/json
{"accepted":1,"rejected":0,"quota":{"dailyLimit":…,"usedToday":…,"monthlyLimit":…,"usedThisMonth":…},"error":null}
accepted is how many lines were taken. The quota numbers, cut here, are your plan's counters. 202 means the lines are in the ingestion pipeline. They show up in Search a moment later, not in the same instant.
The key goes in X-Api-Key. Authorization: Bearer does not work here; the server answers 401 {"error":"Missing X-Api-Key"}.
Three body formats
The server accepts a single object, a JSON array, or NDJSON (one object per line). It does not look at Content-Type. It looks at the body: if it starts with [, it is an array. If it starts with { and has no newline, it is one object. Anything else is read as NDJSON.
An array:
curl -X POST https://app.ziplogger.ai/ingest/v1/logs \
-H "X-Api-Key: $ZIPLOGGER_API_KEY" -H "Content-Type: application/json" \
-d '[{"message":"Backup started","source":"nightly-backup"},
{"message":"Backup failed","severity":"error","source":"nightly-backup","fields":{"exitCode":2}}]'
NDJSON from a file. Use --data-binary, not -d:
curl -X POST https://app.ziplogger.ai/ingest/v1/logs \
-H "X-Api-Key: $ZIPLOGGER_API_KEY" -H "Content-Type: application/x-ndjson" \
--data-binary @batch.ndjson
-d @file strips the newlines, so the lines run together and the server answers 400.
A pretty-printed single object starts with { and contains newlines, so it is read as NDJSON, one line at a time, and fails:
HTTP/1.1 400 Bad Request
{"error":"Invalid JSON","detail":"Expected depth to be zero at the end of the JSON payload. There is an open JSON object or array that should be closed. Path: $ | LineNumber: 0 | BytePositionInLine: 1."}
Send one object on one line, or wrap it in [ ]. An array is parsed as JSON whatever the whitespace.
The fields of a line
| Field | Type | Notes |
|---|---|---|
message | string | The log line. |
severity | string | debug, info, warn, error, fatal. Defaults to info. |
timestamp | ISO-8601 string | Defaults to the time the server received it. |
source | string | The service or job name. You filter on it. |
release | string | Version of the running build. |
commitSha | string | Commit of the running build. |
stackTrace | string | The full trace, for errors. |
fields | object | Your own key/values, all searchable. |
tags | array of strings | Free labels. |
What the server does with them, tested line by line:
- Severity is never rejected. Case does not matter and
warningbecomeswarn. Anything else becomesinfo, includingcriticalandtrace. If you sendcritical, you will not find it underseverity:fatal. Map your levels before you send. - A bad value fails the whole request.
"timestamp":"yesterday"or"message":42returns400 Invalid JSONand nothing in that request is stored, not even the valid lines. messageis not enforced. A line without one is accepted and stored with an empty message. Always send it.- Unknown top-level keys are dropped. A
userIdnext tomessageis lost. Put it insidefields. fieldskeep their JSON types. Numbers stay numbers, sofields.durationMs:>60000works. Booleans, arrays and nested objects are kept. Anullvalue is dropped.- Keep a field's type the same. If
orderIdis a number in one line and"A-17"in the next, the second line can be accepted with202and then fail to index, so it never shows in Search.
A traceId inside fields links the line to its trace. If you also send traces, see sending traces over OTLP.
A shell helper for scripts and CI
Building JSON with echo breaks the first time a message contains a quote. Let jq do the escaping:
zl_log() {
local severity="$1" message="$2"
jq -nc --arg s "$severity" --arg m "$message" --arg src "nightly-backup" \
--arg host "$(hostname)" \
'{severity: $s, message: $m, source: $src, fields: {machineName: $host}}' |
curl -sS --max-time 10 -X POST https://app.ziplogger.ai/ingest/v1/logs \
-H "X-Api-Key: $ZIPLOGGER_API_KEY" \
-H "Content-Type: application/json" \
--data-binary @- -o /dev/null -w '%{http_code}\n'
}
zl_log info "Backup started"
zl_log error 'Backup failed: disk "/mnt/backup" is full'
Both calls print 202, and the quotes around /mnt/backup arrive intact. --max-time 10 matters: without it, a network problem can hang your job.
To know that the job ran at all, not only what it logged, use a job monitor's check-in URL instead. See cron job monitoring.
Batches, gzip and size
One request per line is fine for a script. For more volume, send lines together as one NDJSON body. The SDKs send 100 per request, a good size to copy.
Gzip works. Add Content-Encoding: gzip and send the compressed bytes:
gzip -c batch.ndjson | curl -X POST https://app.ziplogger.ai/ingest/v1/logs \
-H "X-Api-Key: $ZIPLOGGER_API_KEY" -H "Content-Type: application/x-ndjson" \
-H "Content-Encoding: gzip" --data-binary @-
Keep each request under 1 MB on the wire. A larger body is refused with 413 by the proxy in front of the API, before your key is even checked, and that 413 has an HTML body, not JSON. The limit is on the bytes you send, so a gzipped body well over 1 MB before compression passes. If you get a 413, split the batch. Retrying it as it is will fail the same way.
An empty body or [] returns 400 {"error":"No log entries in payload"}.
Responses and when to retry
| Status | Meaning | Retry? |
|---|---|---|
202 | All lines accepted. | No |
400 | Invalid JSON, invalid gzip, or no lines. | No, fix the body |
401 | Missing X-Api-Key or Invalid API key. | No |
403 | The plan does not include this, or the workspace is suspended. | No |
413 | Body too large. | No, split it |
422 | Idempotency-Key reused with a different body. | No, use a new key |
409 | The same Idempotency-Key is still being processed. | Yes, after Retry-After |
429 | A quota or limit is reached. | Yes, after Retry-After |
503 | Ingestion cannot store the request right now. Nothing was accepted. | Yes, after Retry-After |
Retry 408, 429, 5xx and network errors, with backoff, and wait at least as long as Retry-After says. It is in seconds. Do not retry the rows marked No. A 401 stays a 401.
A 429 can accept part of a batch. This is a real response from a batch of 1,000 lines that crossed a daily limit:
HTTP/1.1 429 Too Many Requests
Retry-After: 43200
{"accepted":988,"rejected":12,"quota":{…},"error":"daily logs quota exceeded for the current plan"}
The first 988 lines were stored. The last 12 were not. When the refusal comes from the monthly log and trace allowance or its daily safety limit, the body instead has "code":"quota", a quota.resource naming what ran out, and the counts under detail.
Safe retries with Idempotency-Key
If a request times out, you do not know whether it was stored. Send an Idempotency-Key header with a value that is unique per batch, and resend the same body with the same key:
curl -X POST https://app.ziplogger.ai/ingest/v1/logs \
-H "X-Api-Key: $ZIPLOGGER_API_KEY" -H "Idempotency-Key: ci-run-4812" \
-H "Content-Type: application/json" -d '{"message":"once"}'
Sending that twice returned the same 202 body both times and stored one line. The same key with a different body returned 422. After a partial 429, resending the same body with the same key stores only the lines that were rejected, so you do not have to cut the batch yourself. The header is optional.
What this API does not do
- It does not batch, buffer or retry for you. If your script exits while the network is down, the line is gone. The SDKs keep a queue and retry. For a long-running service in a supported language, use one, for example Python.
- It does not read files. To ship existing log files, use a shipper such as Fluent Bit or Vector; the setup is in the shipper docs.
- It does not return your logs. Reading needs a separate key with read access.
- It is not OTLP. OpenTelemetry exporters send to
/v1/logsand/v1/tracesinstead. - Every accepted line counts toward your plan. Quotas are on the pricing page.
The field reference is also in the REST API docs. To send your first line, create an account and make a key under Settings → API keys.