Bilis Docs
Documentation

Ingestion

Endpoints

The two ingest endpoints, how they authenticate, and the response contract they promise.

Bilis exposes two ingest endpoints. Both authenticate the same way, both write into the same table, and both follow the same never-blame-the-client contract.

Endpoint Payload Success
POST /api/v1/logs OTLP ExportLogsServiceRequest, JSON encoding 200
POST /api/v1/ingest Simple JSON: one object or an array of them 202

Authentication

Send the project API key as a bearer token, or in X-Bilis-Key if the client cannot set an Authorization header:

-H "Authorization: Bearer bilis_YOUR_API_KEY"
# or
-H "X-Bilis-Key: bilis_YOUR_API_KEY"

The key is looked up by hash and resolved to exactly one project. That project id is the only one that will ever be written. Nothing in the payload can set, override or suggest a project — a resource attribute named project.id is just an attribute. A missing or unknown key is the one case that does return a client error: 401 with {"message": "API key invalid."}.

OTLP: POST /api/v1/logs

Standard OTLP/HTTP, JSON encoding:

curl -X POST https://bilis.example.com/api/v1/logs \
  -H "Authorization: Bearer bilis_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "resourceLogs": [{
      "resource": { "attributes": [
        { "key": "service.name", "value": { "stringValue": "checkout" } }
      ]},
      "scopeLogs": [{
        "scope": { "name": "checkout.payments" },
        "logRecords": [{
          "timeUnixNano": "1756211400123456789",
          "severityNumber": 17,
          "severityText": "ERROR",
          "body": { "stringValue": "Card declined for order 41902" },
          "attributes": [
            { "key": "order.id", "value": { "stringValue": "41902" } }
          ]
        }]
      }]
    }]
  }'

Both the camelCase and snake_case spellings of the OTLP JSON fields are accepted (timeUnixNano / time_unix_nano, and so on).

Protobuf is not supported. A request with Content-Type: application/x-protobuf (or application/protobuf) gets 415 with the fix in the body:

{
    "message": "Only the OTLP JSON encoding is supported. Set OTEL_EXPORTER_OTLP_PROTOCOL=http/json and send Content-Type: application/json."
}

Decoding protobuf would mean adding a dependency; v1 does not.

Responses

A fully accepted export answers 200 with an empty JSON object, as OTLP requires:

{}

When some records could not be mapped, the healthy ones are still stored and the rest are reported through OTLP's partial success field — still 200:

{
    "partialSuccess": {
        "rejectedLogRecords": 2,
        "errorMessage": "Some log records could not be parsed and were skipped."
    }
}

Simple JSON: POST /api/v1/ingest

For anything without an OTel exporter. The body is one log object or a list of them. Only the message is required:

[
    {
        "message": "Card declined for order 41902",
        "level": "error",
        "service": "checkout",
        "timestamp": "2026-08-26T14:02:11.418+02:00",
        "context": { "order_id": "41902", "attempt": 3 }
    },
    { "message": "Retrying in 8s", "level": "warn", "service": "checkout" }
]

Recognised fields, with the aliases each accepts:

Field Aliases Notes
message body Required. Non-strings are stringified; objects are JSON-encoded.
level severity See Severity.
timestamp time See Timestamps. Defaults to arrival time.
service service_name Filterable in the viewer.
context attributes Flattened to a string map.
trace_id traceId Stored as-is.
span_id spanId Stored as-is.
scope Logger or component name.
event Event name.

The response is 202 Accepted with counts, plus a message when something was skipped:

{ "accepted": 12, "skipped": 1 }

A record with no usable message is skipped and counted. A body that is not JSON at all counts as one skipped record — still 202, never 400.

The never-400 contract

Ingest does not return 4xx for a bad payload. This is deliberate, and it is a correctness rule rather than politeness:

  • OTel clients treat 4xx as permanent and drop the batch. A malformed field in one record would silently destroy the whole export, including the records that were fine.
  • They treat 5xx as retryable. So a storage problem must present as 5xx, or the data is gone.

The two things that follow from that:

  1. Bad records are skipped and counted, never rejected. One unparseable record does not cost you the other 499 in the batch.

  2. Storage failures return 503 with Retry-After: 5 — every ClickHouse error, overload or not:

    HTTP/1.1 503 Service Unavailable
    Retry-After: 5
    
    {"message": "Log storage is temporarily unavailable. Please retry."}
    

Correct status codes buy more effective availability here than a second server would. The client is never blamed for a problem on our side.

Note: the payload is acknowledged first and parsed best-effort after. If you need to know whether a specific record made it, check the counts in the response — not the status code.