Appearance
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/ingestThe key decides the Stream, so there's nothing else in the URL.
Authentication
The ingest key goes in any one of these places:
| Where | Example |
|---|---|
Authorization header | Authorization: Bearer lit_XXXXXXXXXXX |
X-Latency-Key header | X-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:
| Field | Meaning | Also accepted |
|---|---|---|
name | Event name | event, event_name |
timestamp | When the event happened (ISO 8601, or Unix s, ms, µs or ns) | ts, time, @timestamp |
duration_ms | How long the operation took | duration, duration_s, duration_ns |
service | Which app sent the record | service.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"}'| Field | Meaning | Also accepted |
|---|---|---|
message | The log line | msg, body, log |
level | Severity: debug, info, warn, error, fatal | severity, status, numeric pino and syslog levels |
service | Which app sent the record | service.name, app |
trace_id, span_id | Link the log to a trace | traceId, 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.logTo 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.ndjsonResponses
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.