Blog Sending logs and traces

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

FieldTypeNotes
messagestringThe log line.
severitystringdebug, info, warn, error, fatal. Defaults to info.
timestampISO-8601 stringDefaults to the time the server received it.
sourcestringThe service or job name. You filter on it.
releasestringVersion of the running build.
commitShastringCommit of the running build.
stackTracestringThe full trace, for errors.
fieldsobjectYour own key/values, all searchable.
tagsarray of stringsFree labels.

What the server does with them, tested line by line:

  • Severity is never rejected. Case does not matter and warning becomes warn. Anything else becomes info, including critical and trace. If you send critical, you will not find it under severity:fatal. Map your levels before you send.
  • A bad value fails the whole request. "timestamp":"yesterday" or "message":42 returns 400 Invalid JSON and nothing in that request is stored, not even the valid lines.
  • message is not enforced. A line without one is accepted and stored with an empty message. Always send it.
  • Unknown top-level keys are dropped. A userId next to message is lost. Put it inside fields.
  • fields keep their JSON types. Numbers stay numbers, so fields.durationMs:>60000 works. Booleans, arrays and nested objects are kept. A null value is dropped.
  • Keep a field's type the same. If orderId is a number in one line and "A-17" in the next, the second line can be accepted with 202 and 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

StatusMeaningRetry?
202All lines accepted.No
400Invalid JSON, invalid gzip, or no lines.No, fix the body
401Missing X-Api-Key or Invalid API key.No
403The plan does not include this, or the workspace is suspended.No
413Body too large.No, split it
422Idempotency-Key reused with a different body.No, use a new key
409The same Idempotency-Key is still being processed.Yes, after Retry-After
429A quota or limit is reached.Yes, after Retry-After
503Ingestion 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/logs and /v1/traces instead.
  • 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.

Parse log lines into fields and metrics

Add fields to plain-text logs as they arrive with pattern, JSON or key=value rules, then turn matching logs into a metric you can chart and alert on.

Alert on error logs and quiet services

Set up log-based alert rules that fire on error spikes or when a service goes quiet, send them to email or Slack, and keep them from paging for nothing.

Centralized .NET logging with Serilog and ILogger

Send ASP.NET Core logs to ZipLogger with the Serilog sink or the ILogger provider, with structured properties you can search by user or order id.

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.