Ingestion
Endpoints
The ingest endpoints, how they authenticate, and the response contract they promise.
Fill this page in with a real API key
The samples below carry a placeholder key and an example host. Log in or create an account to issue one and have them rewritten in place.
Or hand it to a coding agent
Copy this prompt into Claude Code — or any agent that can read a URL — and let it do the setup against the current version of this page.
Set up Bilis — self-hosted log, trace and metric storage — in this project, following its "Endpoints" guide: https://bilis.app/docs/ingestion/endpoints.md Bilis endpoint: https://bilis.app API key: bilis_YOUR_API_KEY Read the guide first, then make the changes it describes. Keep the API key in the environment rather than in a file that gets committed, and finish by sending one record and confirming it arrives.
Bilis exposes four ingest endpoints. They all authenticate the same way and all follow the same never-blame-the-client contract.
| Endpoint | Payload | Success |
|---|---|---|
POST /api/v1/logs |
OTLP ExportLogsServiceRequest, JSON or protobuf |
200 |
POST /api/v1/ingest |
Simple JSON: one object or an array of them | 202 |
POST /api/v1/traces |
OTLP ExportTraceServiceRequest, JSON or protobuf |
200 |
POST /api/v1/metrics |
OTLP ExportMetricsServiceRequest, JSON or protobuf |
200 |
The first two write log lines; the third writes spans; the fourth writes metric data points. See Traces for the trace endpoint in full — including why OTLP over gRPC on port 4317 is not supported, which is the most common reason a new install looks broken — and Metrics for the metrics endpoint.
One more path accepts what the Sentry SDKs send, for applications that already report exceptions through one; it follows the same contract. See Sentry-compatible ingest.
Authentication
Send the project API key as a bearer token, or in X-Bilis-Key if the client
cannot set an Authorization header — see API keys
for how a key is issued, what its two halves are for, and how to revoke one:
-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
Content-Type: application/x-protobuf (or application/protobuf) is accepted
too, and produces exactly the same rows as the JSON encoding of the same
export. That matters because most OTel SDKs cannot speak OTLP/JSON at all — the
Go, Java, .NET and Rust HTTP exporters are protobuf-only — so this is what lets
them point straight at Bilis.
It is decoded in-process, in pure PHP, by app/Services/Ingest/Protobuf:
about three hundred lines reading the wire format, no composer package and no
ext-protobuf. Every message is a method named after its .proto message, and
unknown fields are skipped rather than refused, so a newer collector talking to
an older Bilis keeps working.
Because it parses untrusted binary, it has an off switch:
BILIS_OTLP_PROTOBUF=false
With that set, a protobuf export answers 415 with the old hint, and only the
JSON path is reachable:
{
"message": "Only the OTLP JSON encoding is supported. Set OTEL_EXPORTER_OTLP_PROTOCOL=http/json and send Content-Type: application/json."
}
gRPC on port 4317 is still not supported and will not be: PHP is a poor gRPC server, and a Collector already bridges that hop.
Compression
Every OTLP endpoint inflates a body sent with Content-Encoding: gzip or deflate —
which the Collector's otlphttp exporter does by default. Anything else
(zstd, snappy, lz4, br) answers 415 naming what is supported, because
no amount of retrying makes such a body readable; configure the exporter
instead.
A decompressed body is capped (BILIS_INGEST_MAX_DECOMPRESSED_BYTES, 32 MB by
default) so a compression bomb cannot be traded for the server's memory. A body
that hits the cap is discarded whole rather than half-parsed.
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."
}
}
OTLP: POST /api/v1/metrics
The same OTLP/HTTP contract as the logs endpoint — JSON or protobuf, gzip or
deflate, the same key — for an ExportMetricsServiceRequest. All five metric
types are accepted (gauge, sum, histogram, exponential histogram, summary), one
row per data point, kept for 30 days. A point that cannot be stored — no
storable time, a NaN or infinite value, a histogram whose bucket counts do not
match its bounds — is skipped and counted in rejectedDataPoints:
{
"partialSuccess": {
"rejectedDataPoints": 3,
"errorMessage": "Some data points could not be stored and were skipped."
}
}
Per-SDK setup, a Collector metrics pipeline and a curl example are on the
Metrics page.
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
4xxas 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
5xxas retryable. So a storage problem must present as5xx, or the data is gone.
The two things that follow from that:
-
Bad records are skipped and counted, never rejected. One unparseable record does not cost you the other 499 in the batch. The count comes back in OTLP's
partialSuccess—rejectedLogRecords,rejectedSpansorrejectedDataPoints, by signal — or asskippedfrom the simple endpoint. -
Storage failures return
503withRetry-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.