API
Endpoints
| Method · Path | Description |
|---|---|
POST /api/v1/query | Read query. Returns all results as a JSON array. Sets the truncated flag when the limit is reached |
POST /api/v1/query/stream | Same query as NDJSON — one row per line |
POST /api/v1/update | Write query (TQL is rejected) |
POST /api/v1/format | Query formatting · indentation |
POST /api/v1/cache | Retrieve a single in-memory current value |
GET·POST·DELETE /api/v1/keys · /keys/{id} | API key list · issue · revoke |
GET·POST /api/v1/login | Console login — the only path that passes without a key under /api/v1 |
GET /api/v1/openapi.json | OpenAPI 3.0.3 documentation (authentication required) |
GET /api/health | Anonymous readiness — DB · in-memory · query log |
GET /admin/metrics · /ping · /healthcheck · /threads | Operational metrics (authentication required) |
Paths not listed here are fail-closed and return 401. The only paths opened anonymously are / · /index.html · /api/health · static assets, and /api/v1/login — it is the door that issues keys, so it cannot require one.
Headers
| Header | Required | Value |
|---|---|---|
X-API-Key | O | The issued key or the configured default key |
X-Lang | O | PQL · CQL · SQL · TQL — anything else returns 400 |
X-Params | Parameter binding. A URL-encoded JSON object | |
Content-Type | O | application/json |
The query is the request body itself — not wrapped in JSON.
Reads — POST /api/v1/query
curl -X POST -H "X-API-Key: ${PP_DATA_GATEWAY_API_KEY}" -H "X-Lang: PQL" \
-H "Content-Type: application/json" \
-d "SELECT tablename FROM pg_tables WHERE schemaname='pp'" \
https://host:5501/api/v1/query
{"result":"SUCCESS","message":"","truncated":false,
"data":[{"tablename":"mm_asset"},{"tablename":"mm_tag"}]}
truncatedIf the result reaches the limit (default 100,000 rows), the gateway stops reading and disconnects. If truncated is true and you don't check it, you will aggregate truncated results as if they were complete.
Streams — POST /api/v1/query/stream
This is the way to handle large results. The server doesn't load everything into memory, and the client receives the first row immediately.
curl -X POST -H "X-API-Key: ${PP_DATA_GATEWAY_API_KEY}" -H "X-Lang: PQL" \
-H "Content-Type: application/json" \
-d "SELECT tablename FROM pg_tables WHERE schemaname='pp'" \
https://host:5501/api/v1/query/stream
{"row":{"tablename":"mm_asset"}}
{"row":{"tablename":"mm_tag"}}
{"end":{"count":43,"exec_ms":42,"truncated":false}}
The response is application/x-ndjson, and if an error occurs mid-stream, the last line is error, not end.
{"error":{"message":"..."}}
end lineAuthentication is complete at the header stage, so this response always begins with 200. Any failures after that are only visible within the body — if you don't receive end, the result is not finished, but the connection was dropped, and if end.truncated is true, it was truncated at the limit.
Parameter binding — X-Params
Create placeholders in the query using :이름, and put a URL-encoded JSON object into X-Params.
PARAMS=$(python3 -c "import urllib.parse,json;print(urllib.parse.quote(json.dumps({
'site_id': 'SITE_00001',
'from:ts-long': 1772041960266,
'limit:int': 500
})))")
curl -X POST -H "X-API-Key: ${PP_DATA_GATEWAY_API_KEY}" -H "X-Lang: PQL" \
-H "X-Params: ${PARAMS}" -H "Content-Type: application/json" \
-d "SELECT * FROM pp.mm_tag WHERE site_id = :site_id AND created_at >= :from LIMIT :limit" \
https://host:5501/api/v1/query
Append :타입 to the key to specify a conversion. If you don't append it, the value is passed as its native JSON type.
| Suffix | Meaning |
|---|---|
:int · :long · :float · :double | Number |
:string | String |
:epoch · :ts-long | Epoch milliseconds → timestamp |
:ts-string | Date string → timestamp |
An unknown type suffix returns a 400 PARAM_MISSING — it is not silently ignored.
X-Params is malformed, it is a 400, not a 500A value that cannot be decoded (%zz) or is not JSON is a client error. In the past, this leaked out as a 500 INTERNAL_ERROR and was read as "the server crashed."
Writes — POST /api/v1/update
curl -X POST -H "X-API-Key: ${PP_DATA_GATEWAY_API_KEY}" -H "X-Lang: PQL" \
-H "Content-Type: application/json" \
-d "UPDATE pp.mm_tag SET description = 'reactor inlet' WHERE tag_id = 'TAG_001'" \
https://host:5501/api/v1/update
TQL is rejected on this path — the time series engine does not accept writes through the gateway.
Error contract
All errors come in the same envelope.
{"result":"ERROR","error_code":"QUERY_SYNTAX",
"message":"DDL statements are not allowed: DROP"}
error_code | HTTP | Meaning |
|---|---|---|
AUTH_MISSING | 401 | X-API-Key header is missing |
AUTH_INVALID | 401 | Key doesn't match |
PARAM_MISSING | 400 | Header · language · parameter type conversion failure |
QUERY_SYNTAX | 400 | DDL rejected · multiple statements · syntax error |
QUERY_DATA | 400 | Data constraint violation |
QUERY_CONSTRAINT | 409 | Integrity constraint |
QUERY_DEADLOCK | 409 | Deadlock |
QUERY_AUTH | 401 | Backend storage authentication failure |
QUERY_CONNECTION | 503 | Could not connect to the backend |
QUERY_TIMEOUT | 504 | Backend response was slow |
QUERY_ERROR | 500 | Any other execution failure |
QUERY_UNSUPPORTED | 400 | Request not supported by that language |
NOT_FOUND | 404 | Path not found |
METHOD_NOT_ALLOWED | 405 | Method not allowed |
UNSUPPORTED_MEDIA_TYPE | 415 | Content-Type mismatch |
API_KEY_NOT_CONFIGURED | 503 | Master key not configured — not a credential failure |
LOGIN_NOT_CONFIGURED | 503 | Console account two keys not configured |
Syntax · constraints · data · authentication are client causes (4xx), and connection · timeout are infrastructure causes (5xx). If you lump them together, callers cannot distinguish "is my query wrong or is the storage down?" The server logs also follow the same standard — 4xx is WARN, 5xx is ERROR.
There are two API keys
| Type | Where it lives | Lifetime |
|---|---|---|
| Default key | Configuration file data.gateway.api.key (rendered) | Until the configuration changes |
| Issued key | Created via POST /api/v1/keys. Storage is salt + SHA-256 digest only | Lives only in the process — disappears on restart |
Comparison is always constant-time.
On successful login, it returns the default key from the configuration unchanged. The reason is bootstrapping — on a clean installation, the key store is empty, and every path that creates a key already requires one. The first entrant must present credentials that exist "before" the store, and that is only the key in the configuration file.
So this path neither reads nor writes the key store. It is the door that must remain open even if the DB is down.