Skip to main content

API

Endpoints​

Method · PathDescription
POST /api/v1/queryRead query. Returns all results as a JSON array. Sets the truncated flag when the limit is reached
POST /api/v1/query/streamSame query as NDJSON — one row per line
POST /api/v1/updateWrite query (TQL is rejected)
POST /api/v1/formatQuery formatting · indentation
POST /api/v1/cacheRetrieve a single in-memory current value
GET·POST·DELETE /api/v1/keys · /keys/{id}API key list · issue · revoke
GET·POST /api/v1/loginConsole login — the only path that passes without a key under /api/v1
GET /api/v1/openapi.jsonOpenAPI 3.0.3 documentation (authentication required)
GET /api/healthAnonymous readiness — DB · in-memory · query log
GET /admin/metrics · /ping · /healthcheck · /threadsOperational metrics (authentication required)
Unauthenticated paths return 401 — they are not anonymous

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​

HeaderRequiredValue
X-API-KeyOThe issued key or the configured default key
X-LangOPQL · CQL · SQL · TQL — anything else returns 400
X-ParamsParameter binding. A URL-encoded JSON object
Content-TypeOapplication/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"}]}
Do not ignore truncated

If 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":"..."}}
Be sure to check the end line

Authentication 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.

SuffixMeaning
:int · :long · :float · :doubleNumber
:stringString
:epoch · :ts-longEpoch milliseconds → timestamp
:ts-stringDate string → timestamp

An unknown type suffix returns a 400 PARAM_MISSING — it is not silently ignored.

If X-Params is malformed, it is a 400, not a 500

A 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_codeHTTPMeaning
AUTH_MISSING401X-API-Key header is missing
AUTH_INVALID401Key doesn't match
PARAM_MISSING400Header · language · parameter type conversion failure
QUERY_SYNTAX400DDL rejected · multiple statements · syntax error
QUERY_DATA400Data constraint violation
QUERY_CONSTRAINT409Integrity constraint
QUERY_DEADLOCK409Deadlock
QUERY_AUTH401Backend storage authentication failure
QUERY_CONNECTION503Could not connect to the backend
QUERY_TIMEOUT504Backend response was slow
QUERY_ERROR500Any other execution failure
QUERY_UNSUPPORTED400Request not supported by that language
NOT_FOUND404Path not found
METHOD_NOT_ALLOWED405Method not allowed
UNSUPPORTED_MEDIA_TYPE415Content-Type mismatch
API_KEY_NOT_CONFIGURED503Master key not configured — not a credential failure
LOGIN_NOT_CONFIGURED503Console account two keys not configured
The distinction between 4xx and 5xx is the key point

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​

TypeWhere it livesLifetime
Default keyConfiguration file data.gateway.api.key (rendered)Until the configuration changes
Issued keyCreated via POST /api/v1/keys. Storage is salt + SHA-256 digest onlyLives only in the process — disappears on restart

Comparison is always constant-time.

Console login does not "issue" a key

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.