Ludex — trusted-data egress (pull API)
Ludex protects your telemetry behind a validation + drift recovery pipeline before it becomes "trusted data" you can feed into dashboards, analytics, or ML pipelines. The trusted-data egress surface is how your systems pull that already-validated (and recovered) event stream back out — securely, incrementally, and with full provenance on every row.
This page documents Phase 2 of the trusted-data egress plan: the pull API (GET /v1/trusted/events). Phase 3 adds scheduled Parquet exports and per-object signed download URLs — see Trusted-data egress (Parquet exports).
What you get from the pull API
- Validated telemetry only. Rows appear here only after they have passed deterministic schema validation, or after they have been recovered through a governed schema-drift resolution (replay path). Raw, never-validated events are never served.
- Provenance on every row. Every response row carries a
trust_originfield with one of: validated— the row passed the active schema on the first try.recovered— the row originally failed validation, but a governed recovery (approved schema + replay) later accepted it.- Policy-aware filtering. Each
(organization, project, environment)scope has aTrustedExportPolicywith amode: validated_only— recovered rows are excluded.validated_plus_recovered— both origins are served.custom— reserved for future per-field or per-state filters.- Keyset pagination. Pages use an opaque
cursorover(timestamp, id)so concurrent writes on the server never produce duplicate or skipped rows during a walk. - Credential-scoped strictly. Unlike dashboard reads, trusted-egress credentials are narrowed to a single project and a single environment. There is no "read everything I can see" mode in v1 (see Strict credential scoping).
Endpoint
GET /v1/trusted/events
Authentication
Use a Ludex Bearer API key with the read:trusted scope. Authentication is identical to the ingestion and dashboard surfaces:
Authorization: Bearer <your-api-key>
The credential fixes the organization_id, project_id, and environment_id you can read. Requests outside that scope are rejected — see below.
Query parameters
| Parameter | Type | Default | Purpose |
|---|---|---|---|
project_id |
string | credential's project | Must equal the credential's project (or be omitted). See Strict credential scoping. |
environment_id |
string | credential's environment | Must equal the credential's environment (or be omitted). |
since |
ISO-8601 UTC | open | Inclusive lower bound on timestamp (e.g. 2026-04-20T00:00:00Z). |
until |
ISO-8601 UTC | open | Exclusive upper bound on timestamp. |
event_type |
string | any | Filter by ingestion event_type. |
ludex_event_type |
string | any | Filter by normalised Ludex event type. |
source_event_name |
string | any | Filter by original source event name. |
include_recovered |
bool | policy-governed | false narrows a validated_plus_recovered policy to validated rows only. Cannot widen a validated_only policy — policy is authoritative. |
limit |
int (1-5000) | 500 | Page size ceiling. |
cursor |
opaque string | none | Pass the next_cursor from the previous response. |
Response shape
{
"status": "ok",
"message": "Trusted events (Phase 2 pull API)",
"data": [
{
"organization_id": "org_abc",
"project_id": "proj_xyz",
"environment_id": "env_prod",
"event_id": "evt_012",
"timestamp": "2026-04-20T12:00:00Z",
"event_type": "score",
"ludex_event_type": "LUDEX_SCORE",
"source_event_name": "score_update",
"user_id": "user_42",
"ludex_session_id": "lsid_42",
"correlation_id": "corr_42",
"schema_version": 3,
"payload": { "points": 1200 },
"trust_origin": "validated"
}
],
"next_cursor": "eyJ0cyI6IjIwMjYtMDQtMjBUMTI6MDA6MDBaIiwiaWQiOjEwMTJ9",
"policy": {
"mode": "validated_plus_recovered",
"include_recovered": true,
"policy_hash": "..."
},
"scope": {
"organization_id": "org_abc",
"project_id": "proj_xyz",
"environment_id": "env_prod"
}
}
next_cursorisnullwhen the page is the last one. Otherwise pass it back as thecursorquery param to get the next page.- Field order within each
data[]object matches the sharedTRUSTED_EVENT_FIELDScontract; tooling that serialises row-by-row should not reorder keys. - The internal primary key (
telemetry_events.id) is deliberately not exposed. Useevent_idfor customer-facing joins.
Errors
| HTTP | Body code |
Meaning |
|---|---|---|
| 401 | auth_failed |
Missing or invalid Bearer token. |
| 403 | insufficient_scope (reason project_mismatch) |
project_id query param does not match the credential. |
| 403 | insufficient_scope (reason project_wildcard_not_allowed) |
project_id=all on a non-wildcard credential (see Strict credential scoping). |
| 403 | insufficient_scope (reason environment_mismatch / environment_wildcard_not_allowed) |
Same as above, for environment_id. |
| 400 | invalid_timestamp |
since / until not parseable as ISO-8601. |
| 422 | (FastAPI validation) | limit outside [1, 5000]. |
Strict credential scoping
Machine integrations reading trusted data are scoped tighter than dashboards. A read:trusted credential ties to exactly one (organization_id, project_id, environment_id) triple. This is intentional and cannot be loosened at call time:
project_id=allis rejected. Cross-project reads require a wildcard credential, which the v1 schema does not yet express. When wildcard credentials land (they are tracked as an additive change in a later phase), this endpoint starts honouringproject_id=allonly for credentials that are explicitly markedallow_wildcard=true. Until then, everyproject_id=allattempt returns 403project_wildcard_not_allowed.project_idmust equal the credential's project when supplied explicitly. Any other value returns 403project_mismatchand increments theludex_trusted_events_scope_rejections_total{reason="project_mismatch"}counter.environment_idfollows the same rule — mismatch returns 403environment_mismatch, wildcard returns 403environment_wildcard_not_allowed.- Omitting
project_id/environment_iddefaults to the credential's values, never to "all". The machine contract narrows, not widens, when params are missing. include_recoveredcan only narrow the policy. It cannot widen avalidated_onlypolicy to serve recovered rows.
Every rejection is logged at warning level (trusted_events_scope_rejected) with the reason label so operators can audit credential misuse through the Loki dashboard panel "Scope-rejected requests (security-relevant)" in grafana-dashboards/trusted_egress_dashboard.json.
Example — curl
curl -sS \
-H "Authorization: Bearer $LUDEX_TRUSTED_API_KEY" \
"https://api.ludex.example.com/v1/trusted/events?since=2026-04-20T00:00:00Z&limit=500"
Continuing with the cursor:
CURSOR=$(curl -sS -H "Authorization: Bearer $LUDEX_TRUSTED_API_KEY" \
"https://api.ludex.example.com/v1/trusted/events?limit=500" | jq -r .next_cursor)
curl -sS \
-H "Authorization: Bearer $LUDEX_TRUSTED_API_KEY" \
"https://api.ludex.example.com/v1/trusted/events?limit=500&cursor=$CURSOR"
Example — Python
import os
import httpx
API = "https://api.ludex.example.com"
KEY = os.environ["LUDEX_TRUSTED_API_KEY"]
def iter_trusted_events(since, until=None, limit=500):
"""Yield validated trusted events for the caller's credential scope."""
cursor = None
while True:
params = {"limit": limit, "since": since}
if until is not None:
params["until"] = until
if cursor is not None:
params["cursor"] = cursor
r = httpx.get(
f"{API}/v1/trusted/events",
params=params,
headers={"Authorization": f"Bearer {KEY}"},
timeout=60.0,
)
r.raise_for_status()
body = r.json()
for row in body["data"]:
yield row
cursor = body.get("next_cursor")
if cursor is None:
return
for event in iter_trusted_events(since="2026-04-20T00:00:00Z"):
assert event["trust_origin"] in ("validated", "recovered")
Operational notes
GET /v1/trusted/eventsis internal-only in Phase 2 — it is exposed to customers through your ingress layer. Prometheus scrapes the service's metrics port over the internal Docker network; no customer traffic reaches the metrics endpoint.- Panels for request rate, latency, rows served by
trust_origin, and strict-scope rejections live ingrafana-dashboards/trusted_egress_dashboard.json. The cross-service overview (grafana-dashboards/ludex-services-dashboard.json) includes a Trusted-Data Egress row that picks up the same metrics. - Loki filters JSON logs by
service=ludex-egress; thetrusted_events_pull_succeeded/trusted_events_pull_failed/trusted_events_scope_rejectedevents are the pivots to use when on-call.
See also
- Trusted-data egress (Parquet exports) — bulk Parquet manifests and signed URLs.
- Schema drift detection — how recovered rows become trusted data in the first place.
- Error catalog — full list of machine-readable error codes on Ludex HTTP surfaces.