Usage log
Read your key's own call log over the API — every signed call with its method, latency, request id and a machine-readable errorCode.
Every signed call your key makes leaves a row in a log that belongs to that key,
and GET /sdk/v1/logs hands it back. It is how you answer "did that request
reach us, and what did we think of it" without opening the console — and it is
the runtime that neccessory logs tail sits on.
GET /sdk/v1/logs
HMAC-signed like everything under /sdk/v1 — see
Authentication. The signature covers the path
without the query string and an empty body.
| Parameter | Meaning |
|---|---|
limit | 1…200, default 50. |
sinceTime | Millisecond timestamp. Returns entries at or after it. |
cursor | nextCursor from the previous page. Returns entries strictly after it. |
cursor and sinceTime are alternatives — when both are sent, cursor wins. A
cursor that is not <time>.<id> is a 400 validation_error.
{
"ok": true,
"data": {
"items": [
{
"id": "log_4f7c1b90a2d8e35617c4a0f2",
"time": 1756400000123,
"endpoint": "/sdk/v1/measurements",
"method": "POST",
"status": "success",
"latencyMs": 12,
"requestId": "req_9a1c4e77b0d2358fc6e4",
"error": null,
"errorCode": null,
"qualityLevel": "good"
}
],
"nextCursor": "1756400000123.log_4f7c1b90a2d8e35617c4a0f2",
"serverTime": 1756400000456
}
}Ordering is oldest first, keyset over (time, id) — the opposite of the
console, and the right order for a tail. Paginate by feeding nextCursor back
in; on an empty page it repeats the cursor you sent, so a poll loop needs no
special case.
The row
| Field | What it is |
|---|---|
id | Log row id, log_ + 24 hex characters. Only meaningful as the tiebreaker inside a cursor. |
time | Epoch milliseconds, taken when the request arrived. |
endpoint | Path without the query string, e.g. /sdk/v1/cloud/jobs. |
method | HTTP method of the call. |
status | success, unauthorized or rate_limited — those three and nothing else. |
latencyMs | Server-side handling time, or null. |
requestId | The request id of that call — see below. |
error | Free-form English explanation on a failure, null on success. Written for a human; do not branch on it. |
errorCode | Stable machine code on a failure the server recognises, otherwise null. |
qualityLevel | good / fair / poor when the call submitted a measurement, null otherwise. |
errorCode
error is prose and may be reworded; errorCode is the value to branch on. It
is not the same thing as the error.code of the failed response: the response
that rejected a bad signature says error.code: "unauthorized", while the log
row for it says errorCode: "invalid_signature" — the envelope tells the caller
that it was refused, the log tells you why.
errorCode | What happened |
|---|---|
missing_credentials | One of x-key-id, x-timestamp, x-nonce, x-signature was absent. |
invalid_nonce | Nonce outside 8…128 characters. |
invalid_timestamp | x-timestamp is not a number. |
timestamp_out_of_window | Clock skew beyond ±300 s. |
unknown_key | No key with that x-key-id. |
key_disabled | The key exists but is not active. |
key_expired | The key is past its expiry. |
invalid_signature | The signature did not match the canonical string. |
nonce_reused | That nonce was already spent within the window. |
rate_limited | A per-key limit, or the polling limit on this endpoint. |
Those ten are the whole vocabulary. Nine of them are credential problems and map one-to-one to the causes listed under Authentication — a tail of this log is the fastest way to tell clock skew apart from a wrong secret.
The log is not a record of every refusal
Only calls the signature layer ruled on are written: successes, rejected signatures, and rate limits. A request that authenticated and then failed validation — a malformed body, an unknown measurement id, an exhausted quota — returns its error to you and leaves no row here. Do not treat the log as a complete error feed; it is a record of access, not of outcomes.
Joining a call to what it produced
Every response from the API carries an X-Request-Id header, exposed to browser
clients through CORS. The same value comes back three ways:
- as
requestIdon the log row for that call; - as
requestIdon the measurement the call stored —POST /sdk/v1/measurementsrecords it, andGET /sdk/v1/measurements/{id}returns it; - in the console, where ⌘K accepts a request id and opens the record.
So one id ties the HTTP call, its log row and the stored measurement together. Keep the header when a call fails and you have something to search for later.
const res = await fetch(url, { method: 'POST', headers, body });
const requestId = res.headers.get('x-request-id');
if (!res.ok) logger.warn('neccessory refused the call', { requestId });Polling
The endpoint is limited to 60 requests per minute per key
(NC_SDK_LOGS_RATE_LIMIT), and going over it answers 429 rate_limited — which
is itself logged, so a runaway poller is visible in its own log. Successful reads
of /sdk/v1/logs are not logged, so tailing never feeds itself.
From the CLI
neccessory logs tail follows this endpoint. The plain
output prints time, status, endpoint, latency, request id and the error text;
--json emits the rows above verbatim as NDJSON, method and errorCode
included.
neccessory logs tail --since 1h --json | jq 'select(.errorCode)'