Skip to content

HTTP API ​

No SDK required. Anything that can make an HTTP request can send events and logs to an Events Stream, or logs to an OpenTelemetry Stream: shell scripts, cron jobs, CI pipelines, serverless functions, browsers and webhooks. (Traces and metrics come in through OpenTelemetry.)

The endpoint ​

POST https://ws.latency.app/v1/ingest

The key decides the Stream, so there's nothing else in the URL.

Authentication ​

The ingest key goes in any one of these places:

WhereExample
Authorization headerAuthorization: Bearer lit_XXXXXXXXXXX
X-Latency-Key headerX-Latency-Key: lit_XXXXXXXXXXX
key query parameter?key=lit_XXXXXXXXXXX (for browsers)

A disabled or revoked key gets 401.

Events ​

Send one JSON object:

bash
curl -X POST https://ws.latency.app/v1/ingest \
  -H "Authorization: Bearer lit_XXXXXXXXXXX" \
  -H "Content-Type: application/json" \
  -d '{"name": "signup", "plan": "pro", "duration_ms": 412}'

Every field sent becomes searchable and groupable. Nested objects are flattened with dots, so {"user": {"id": 7}} becomes user.id.

A few fields are recognized:

FieldMeaningAlso accepted
nameEvent nameevent, event_name
timestampWhen the event happened (ISO 8601, or Unix s, ms, µs or ns)ts, time, @timestamp
duration_msHow long the operation tookduration, duration_s, duration_ns
serviceWhich app sent the recordservice.name, app

No timestamp? The receive time is used.

Sending many at once ​

Batch any number of records into one request, up to 4 MB, in whichever shape is easiest:

json
[
  { "name": "pageview", "path": "/" },
  { "name": "pageview", "path": "/pricing" }
]
json
{
  "service": "web",
  "events": [
    { "name": "pageview", "path": "/" },
    { "name": "pageview", "path": "/pricing" }
  ]
}
txt
{"name": "pageview", "path": "/"}
{"name": "pageview", "path": "/pricing"}

In the wrapped form, top-level fields (like service above) apply to every record. The list can be called events, logs, records, data, items or batch.

For NDJSON, send Content-Type: application/x-ndjson.

Logs ​

A log is an event with a level and a message, so logs go to the same Events Stream:

bash
curl -X POST https://ws.latency.app/v1/ingest \
  -H "Authorization: Bearer lit_XXXXXXXXXXX" \
  -H "Content-Type: application/json" \
  -d '{"level": "error", "message": "payment failed", "service": "checkout", "order_id": "o-9"}'
FieldMeaningAlso accepted
messageThe log linemsg, body, log
levelSeverity: debug, info, warn, error, fatalseverity, status, numeric pino and syslog levels
serviceWhich app sent the recordservice.name, app
trace_id, span_idLink the log to a tracetraceId, spanId

Plain text ​

Already have log files? Send them as they are. Each line becomes an entry, and lines that are JSON are parsed as JSON:

bash
curl -X POST https://ws.latency.app/v1/ingest \
  -H "Authorization: Bearer lit_XXXXXXXXXXX" \
  -H "Content-Type: text/plain" \
  --data-binary @app.log

To an OpenTelemetry Stream ​

An OpenTelemetry Stream's key works here too. The same JSON, NDJSON or text becomes that Stream's logs, next to what its SDKs send, and OTLP logs (protobuf, or JSON with resourceLogs) are accepted at /v1/ingest as well:

bash
curl -X POST https://ws.latency.app/v1/ingest \
  -H "Authorization: Bearer lit_OTEL_STREAM_KEY" \
  -H "Content-Type: application/json" \
  -d '{"level": "error", "message": "payment failed", "service": "checkout", "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736"}'

Richer logs, traces and metrics

For logs linked to traces, distributed traces and metrics with percentiles, use an OpenTelemetry SDK with an OpenTelemetry Stream. An Events Stream's key sending OTLP gets 415.

Compression ​

Compress large batches and send the matching Content-Encoding header: gzip, deflate, br or zstd.

bash
gzip -c events.ndjson | curl -X POST https://ws.latency.app/v1/ingest \
  -H "Authorization: Bearer lit_XXXXXXXXXXX" \
  -H "Content-Type: application/x-ndjson" \
  -H "Content-Encoding: gzip" \
  --data-binary @-

From a browser ​

For website analytics, use the Analytics script: one line, covering pageviews, goals, page speed and errors. For custom events, pass the key as ?key= and send the body as plain text, which keeps the request simple with no CORS preflight. keepalive lets the request finish after the page closes:

js
fetch('https://ws.latency.app/v1/ingest?key=lit_XXXXXXXXXXX', {
  method: 'POST',
  body: JSON.stringify({ name: 'pageview', path: location.pathname }),
  headers: { 'content-type': 'text/plain' },
  keepalive: true,
});

Use fetch rather than navigator.sendBeacon: ad blockers drop beacons sent to another site.

Ingest keys can only write to their one stream, so shipping one in page code is safe. The endpoint accepts requests from any origin.

Cap browser keys

Give page-code keys a dedicated key limit, so a copied key can only ever send a little.

Retries ​

A 429 or 503 means "try again shortly": wait for the Retry-After header and resend. A 429 also names the limit that was hit in the X-Latency-Limit header. The exception is 429 quota_exceeded, which lasts until the next month or an upgrade.

To make retries safe, send an Idempotency-Key with a value unique to that batch. When the same key was stored within the last two minutes, the retry is acknowledged without storing a second copy:

bash
curl -X POST https://ws.latency.app/v1/ingest \
  -H "Authorization: Bearer lit_XXXXXXXXXXX" \
  -H "Idempotency-Key: batch-2026-09-24-0042" \
  -H "Content-Type: application/x-ndjson" \
  --data-binary @batch.ndjson

Responses ​

json
{ "accepted": true, "bytes": 54 }

202 Accepted means the data is stored. A retry that matched an earlier Idempotency-Key also gets "duplicate": true. For everything else, see Limits & Errors.

© 2026 Latency Labs LLC