Skip to content

HTTP API reference

Every structured_log_server endpoint: parameters, request/response bodies, the specific errors each one can return, and a curl example. Shared object shapes live in models.md; the full error catalog (shared across endpoints) lives in errors.md — this document links to both rather than repeating them per endpoint.

This is a reading aid over the normative OpenSpec specs, not a replacement — it fills in the concrete wire-level shapes the specs leave abstract. If specs/*.md changes, it wins.

GET /v1/logs, /v1/audit-log, /v1/users, /v1/groups and /v1/projects are paginated the same way. Each takes optional limit and cursor and answers {"items": [...], "next_cursor": string | null}.

Param Notes
limit Default 50, ceiling 200 — a larger value is served at 200 rather than refused. Not a positive integer → 400 invalid_request.
cursor The previous response’s next_cursor, passed back untouched. A value the server did not issue → 400 invalid_request.

Items are ordered newest first (largest id first); cursor continues toward older ones, so a row created while you are paging never shifts a page. next_cursor is null on the last page — the server says so itself, so there is no need to request an empty page to find the end. There is no total: counting the audit log and the entries costs more than the pages are worth.

Lists that are bounded by the size of one group or one project — teams, team members, a project’s secret keys, role assignments — are not paginated and return everything in one response.

Breaking change: GET /v1/groups and GET /v1/projects used to return every visible row and now return the first page (50). A caller that read them whole must follow next_cursor.

Examples below use http://localhost:8080 as the server’s base URL, and shell variables $ACCESS_TOKEN (a JWT from POST /v1/auth/token) and $PROJECT_SECRET_KEY (from POST /v1/projects/:id/secret-keys, prefixed slk_) — substitute your own.

Cross-origin requests (CORS) (log-server-api)

Section titled “Cross-origin requests (CORS) (log-server-api)”

Off by default — every response below is written as it looks with no Origin header at all, or with one the operator hasn’t named (--cors-allowed-origins, unset by default; see configuration.md). This is what a same-origin deployment (deploy/) sees, and what every non-browser client sees regardless.

When the request carries an Origin header that exactly matches one of the configured origins (case-sensitive, no wildcard — a scheme/host/port triple, verbatim), two things change:

  • A preflight is answered before anything else. OPTIONS with an Access-Control-Request-Method header gets 204 immediately — ahead of rate limiting and authentication, on every endpoint, ingestion included — with:

    Access-Control-Allow-Origin: <the matched origin>
    Access-Control-Allow-Methods: GET, POST, PATCH, DELETE, OPTIONS
    Access-Control-Allow-Headers: Authorization, Content-Type
    Vary: Origin

    These three values are fixed constants (what this API actually uses), not an echo of what the browser asked for in Access-Control-Request-Method/-Headers.

  • Every other response, success or error, gets two headers added on top of whatever it already carries — Access-Control-Allow-Origin: <the matched origin> and Vary: Origin — so a browser can read a 401, 403, 429, or 5xx body too, not only a 200.

An Origin that doesn’t match any configured value is treated exactly like no Origin at all — no CORS headers, and no separate rejection response. See README.md for where this sits relative to rate limiting and authentication.

Log ingestion, query, and live stream (log-server-api, log-server-live-stream)

Section titled “Log ingestion, query, and live stream (log-server-api, log-server-live-stream)”

Spec: specs/log-server-api/spec.md, specs/log-server-live-stream/spec.md.

Auth: Authorization: Bearer <project-secret-key> — the key’s value carries the slk_ prefix that tells it apart from an access token in the header both schemes share. Presenting an access token here answers 401, exactly as a missing credential does.

Request body: JSON array of free-form log-entry objects — see models.md#logentry. No wrapper object; the array is the entire body.

Response 202: Ingestion response — always 202 if the request itself is well-formed/authenticated/ under the size limit and the project isn’t blocked, even if every entry was rejected; see errors.md.

Errors: 401 unauthorized (bad/unknown/revoked key), 403 project_blocked, 413 payload_too_large. Per-entry validation_error/ quota_exceeded are reported inside the 202 body, not as HTTP errors.

Terminal window
curl -X POST http://localhost:8080/v1/logs \
-H "Authorization: Bearer $PROJECT_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '[
{"event": "payment_failed", "level": "error", "timestamp": "2026-03-05T14:29:59.981Z",
"category": "checkout", "order_id": "ord_44821"},
{"event": "request_completed", "level": "info", "timestamp": "2026-03-05T14:30:00.100Z"}
]'

Auth: Authorization: Bearer <access-token>.

Query parameters:

Param Type Notes
project_id xor group_id integer Exactly one required
level string Minimum level (inclusive)
category, logger string Exact match
from, to ISO 8601 Range on timestamp
session_id, request_id, connection_generation, tool_call_id, message_id, operation_id string Exact match
q string Full-text, matched against event and content
context.<key> string Exact match on a custom field, e.g. context.order_id=ord_44821
limit, cursor Pagination

Response 200: {"items": [LogEntry], "next_cursor": string \| null} — see models.md#logentry. Without cursor, items is ordered newest-first by id; cursor advances toward older entries.

Errors: 403 forbidden (no grant covering the scope), 403 project_blocked (direct project_id only — a group_id query silently drops the blocked project’s entries instead), 404 not_found (project_id/group_id doesn’t exist).

Terminal window
curl -G http://localhost:8080/v1/logs \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-d project_id=7 \
-d level=warning \
-d from=2026-03-05T00:00:00Z \
-d limit=50

Auth: Authorization: Bearer <access-token>.

Query parameters: same as GET /v1/logs above, plus since_id (integer, optional — catch-up cutoff, see live-streaming.md). No limit/cursor — this is a stream, not a page.

Response 200: Content-Type: text/event-stream; frames are id: <log entry id> / event: log / data: <LogEntry as JSON>, plus periodic : ping keep-alive comments and a possible terminal event: end — full framing in live-streaming.md.

Errors: same as GET /v1/logs, returned as a normal (non-streamed) response before the connection upgrades — a rejected subscription never opens a stream that then errors.

Terminal window
curl -N http://localhost:8080/v1/logs/stream \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-G -d project_id=7 -d level=warning

Auth: none.

Response 200: {"status": "ok"}.

Errors: none defined — the endpoint doesn’t respond (connection refused/timeout) rather than returning an error status while the server isn’t ready.

Terminal window
curl http://localhost:8080/healthz

Authentication and password recovery (log-server-auth, log-server-password-reset)

Section titled “Authentication and password recovery (log-server-auth, log-server-password-reset)”

Spec: specs/log-server-auth/spec.md, specs/log-server-password-reset/spec.md, specs/log-server-email-verification/spec.md. See auth.md.

Planned, not implemented: POST /v1/auth/register, POST /v1/auth/verify-email, POST /v1/auth/verify-email/resend, POST /v1/auth/password-reset, and POST /v1/auth/password-reset/confirm — five of the nine endpoints below — are part of the originally specified design (linked above) but have no route in the running server; each is marked individually below. Every account is created by an administrator or a group owner instead (POST /v1/users), and a forgotten password is reset the same way (PATCH /v1/users/:id) — see the Developer Guide.

Every endpoint in this section is rate-limited (log-server-rate-limit), along with POST /v1/auth/change-password and DELETE /v1/users/me: a rejected request answers 429 too_many_requests with a Retry-After header and the general JSON envelope — including the token endpoint, the one place it departs from the RFC 6749 shape (errors.md). A 429 means the action never ran: no password was checked, no token issued, no email sent, and no account was locked (auth.md). The error lists below don’t repeat 429 per endpoint.

Planned, not implemented — see the note at the top of this section.

Auth: none. JSON body, not form-encoded — unlike the token endpoint below, this path isn’t part of the RFC 6749 token contract, so it follows this API’s normal JSON convention.

Request body:

Field Type Required
username string yes
password string yes
email string yes — mandatory on this path specifically
display_name string no

Response 201: User (no RoleAssignment yet, email_verified_at: null). A verification email is sent as a side effect — the account cannot log in via grant_type=password until it’s confirmed, see below and auth.md.

Errors: 400 invalid_request (missing email/username/password), 403 forbidden (registrationEnabled = false), 409 username_taken, 409 email_taken.

Terminal window
curl -X POST http://localhost:8080/v1/auth/register \
-H "Content-Type: application/json" \
-d '{"username": "alice", "password": "correct-horse-battery-staple", "email": "alice@example.com"}'

Planned, not implemented — see the note at the top of this section.

Auth: none (the verification token is the credential). JSON body.

Request body: {"token": "..."}

Response 200: {}. Sets email_verified_at, after which grant_type=password works normally for this account.

Errors: 400 invalid_token (unknown/expired/already-used token).

Terminal window
curl -X POST http://localhost:8080/v1/auth/verify-email \
-H "Content-Type: application/json" \
-d '{"token": "a1b2c3..."}'

Planned, not implemented — see the note at the top of this section.

Auth: none. JSON body.

Request body: {"email": "..."}

Response 202: {} — always, regardless of whether the email is registered or already verified (anti-enumeration, same pattern as password-reset).

Errors: 400 invalid_request (missing email).

Terminal window
curl -X POST http://localhost:8080/v1/auth/verify-email/resend \
-H "Content-Type: application/json" \
-d '{"email": "alice@example.com"}'

Auth: none. Form-encoded (application/x-www-form-urlencoded), RFC 6749 — the one endpoint in this API that deviates from the general JSON envelope on both request and error response, for compatibility with off-the-shelf OAuth2 clients (auth.md).

Request body (grant_type=password): grant_type=password&username=...&password=...

Request body (grant_type=refresh_token): grant_type=refresh_token&refresh_token=...

Response 200: Token response.

Errors: 429 too_many_requests (general envelope, see above); otherwise all 400, RFC shape — invalid_request (missing field for the given grant_type), unsupported_grant_type, invalid_grant (wrong credentials; unknown/expired/revoked refresh token; blocked user on refresh; unverified email on grant_type=password — response additionally carries reason: "email_not_verified", see errors.md).

Terminal window
curl -X POST http://localhost:8080/v1/auth/token \
-d grant_type=password \
-d username=alice \
-d password=correct-horse-battery-staple
Terminal window
curl -X POST http://localhost:8080/v1/auth/token \
-d grant_type=refresh_token \
-d refresh_token=$REFRESH_TOKEN

Auth: none (the refresh token being revoked is the credential). Form-encoded, RFC 7009.

Request body: refresh_token=...

Response 200: {} — always, whether or not the token was valid (anti-enumeration, auth.md).

Errors: 400 invalid_request (RFC shape) only if the refresh_token field itself is missing from the body.

Terminal window
curl -X DELETE http://localhost:8080/v1/auth/token \
-d refresh_token=$REFRESH_TOKEN

Planned, not implemented — see the note at the top of this section.

Auth: none. JSON body.

Request body: {"email": "..."}

Response 202: {} — always, regardless of whether the email is registered (anti-enumeration).

Errors: 400 invalid_request (missing email).

Terminal window
curl -X POST http://localhost:8080/v1/auth/password-reset \
-H "Content-Type: application/json" \
-d '{"email": "alice@example.com"}'

Planned, not implemented — see the note at the top of this section.

Auth: none (the reset token is the credential). JSON body.

Request body: {"token": "...", "new_password": "..."}

Response 200: {}.

Errors: 400 invalid_token (unknown/expired/already-used token), 400 invalid_request (missing field).

Terminal window
curl -X POST http://localhost:8080/v1/auth/password-reset/confirm \
-H "Content-Type: application/json" \
-d '{"token": "a1b2c3...", "new_password": "even-better-passphrase"}'

Auth: Authorization: Bearer <access-token>. JSON body.

Request body: {"password": "..."}

Response 204: empty body.

Errors: 401 invalid_grant (wrong password), 403 cannot_delete_primary_admin, 409 sole_group_owner (details.blocking_groups).

Terminal window
curl -X DELETE http://localhost:8080/v1/users/me \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"password": "correct-horse-battery-staple"}'

Auth: Authorization: Bearer <access-token>. JSON body. Available to any authenticated role, over their own account only — not just while must_change_password is set (log-server-forced-password-change, auth.md).

Request body: {"current_password": "...", "new_password": "..."}

Response 200: {}. Clears must_change_password if it was set.

Errors: 401 invalid_grant (wrong current password); 400 invalid_request if new_password is shorter than 8 characters (details.reason: "too_short", min_length) or longer than 72 bytes in UTF-8 ("too_long", max_bytes) — the same rule applies wherever a password is set (POST /v1/users, PATCH /v1/users/:id). It is checked when a password is chosen, never at login.

Terminal window
curl -X POST http://localhost:8080/v1/auth/change-password \
-H "Authorization: Bearer $ACCESS_TOKEN" -H "Content-Type: application/json" \
-d '{"current_password": "temp-password-123", "new_password": "a-much-better-passphrase"}'

Users, groups, teams, roles (log-server-rbac)

Section titled “Users, groups, teams, roles (log-server-rbac)”

Spec: specs/log-server-rbac/spec.md, specs/log-server-forced-password-change/spec.md. See rbac-and-lifecycle.md. All endpoints below: Authorization: Bearer <access-token>, JSON bodies.

Role: admin.

Request body: same fields as POST /v1/auth/register, but email is optional here.

Response 201: Usermust_change_password: true always (log-server-forced-password-change); email_verified_at: null if email was set.

Errors: 400 invalid_request (missing username/password, or a password outside 8 characters – 72 bytes, details.reason too_short/too_long), 403 forbidden, 409 username_taken, 409 email_taken.

Terminal window
curl -X POST http://localhost:8080/v1/users \
-H "Authorization: Bearer $ACCESS_TOKEN" -H "Content-Type: application/json" \
-d '{"username": "bob", "password": "temp-password-123", "display_name": "Bob Diaz"}'

Role: admin.

Query: username (substring match), limit, cursor — see Pagination.

Response 200: {"items": [User], "next_cursor": string \| null}.

Errors: 403 forbidden.

Terminal window
curl -G http://localhost:8080/v1/users -H "Authorization: Bearer $ACCESS_TOKEN" -d limit=50

Role: admin. Partial update — any subset of the fields below.

Request body:

Field Type Notes
email string A new value resets email_verified_at to null and triggers a fresh verification email
display_name string
password string Setting this always sets must_change_password: true and revokes all of the target’s refresh tokens

Response 200: User (updated).

Errors: 403 forbidden, 404 not_found, 409 email_taken.

Terminal window
curl -X PATCH http://localhost:8080/v1/users/42 \
-H "Authorization: Bearer $ACCESS_TOKEN" -H "Content-Type: application/json" \
-d '{"password": "new-temp-password-456"}'

POST /v1/users/:id/block / POST /v1/users/:id/unblock

Section titled “POST /v1/users/:id/block / POST /v1/users/:id/unblock”

Role: admin. No request body.

Response 200: User (updated is_active).

Errors: 403 forbidden, 404 not_found; unblock additionally: 409 deleted_account.

Terminal window
curl -X POST http://localhost:8080/v1/users/42/block -H "Authorization: Bearer $ACCESS_TOKEN"
curl -X POST http://localhost:8080/v1/users/42/unblock -H "Authorization: Bearer $ACCESS_TOKEN"

Role: admin. No request body — target’s password is never required.

Response 204: empty body.

Errors: 400 self_deletion_requires_me (:id == caller), 403 forbidden, 403 cannot_delete_primary_admin, 404 not_found, 409 sole_group_owner.

Terminal window
curl -X DELETE http://localhost:8080/v1/users/99 -H "Authorization: Bearer $ACCESS_TOKEN"

Role: admin.

Request body: {"name": "..."}

Response 201: Group.

Errors: 403 forbidden.

Terminal window
curl -X POST http://localhost:8080/v1/groups \
-H "Authorization: Bearer $ACCESS_TOKEN" -H "Content-Type: application/json" \
-d '{"name": "payments-team"}'

Role: any authenticated user; results scoped to visible groups. What the caller may see is part of the query, so a caller with one group among a thousand gets it on the first page.

Query: name (substring, case-insensitive), limit, cursor — see Pagination.

Response 200: {"items": [Group], "next_cursor": string \| null}.

Terminal window
curl http://localhost:8080/v1/groups -H "Authorization: Bearer $ACCESS_TOKEN"

Role: owner of :groupId, or admin.

Request body: {"name": "..."}

Response 201: Team.

Errors: 403 forbidden, 404 not_found (:groupId).

Terminal window
curl -X POST http://localhost:8080/v1/groups/3/teams \
-H "Authorization: Bearer $ACCESS_TOKEN" -H "Content-Type: application/json" \
-d '{"name": "on-call"}'

Role: any role with read access to :groupId (admin/owner/user).

Response 200: {"items": [Team]}.

Errors: 403 forbidden, 404 not_found (:groupId).

Terminal window
curl http://localhost:8080/v1/groups/3/teams -H "Authorization: Bearer $ACCESS_TOKEN"

Role: same as above. Minimal shape, not a full User — just enough to show and pick a member: {"items": [{"user_id": 42, "username": "alice"}]}.

Errors: 403 forbidden, 404 not_found (:teamId).

Terminal window
curl http://localhost:8080/v1/teams/5/members -H "Authorization: Bearer $ACCESS_TOKEN"

Role: owner of the team’s group, or admin. Bumps token_version for the added user only, not the team’s other members — their own access is unaffected by someone else joining (auth.md).

Request body: {"user_id": 42}

Response 204: empty body.

Errors: 403 forbidden, 404 not_found (:teamId or user_id).

Terminal window
curl -X POST http://localhost:8080/v1/teams/5/members \
-H "Authorization: Bearer $ACCESS_TOKEN" -H "Content-Type: application/json" \
-d '{"user_id": 42}'

Role: owner of the team’s group, or admin. Bumps token_version for the removed user only — the same single-user effect as above, applied to whoever just lost access.

Response 204: empty body.

Errors: 403 forbidden, 404 not_found, 409 sole_group_owner (the member is the last one of a team that owns a group and nobody else owns it — details.blocking_groups).

Terminal window
curl -X DELETE http://localhost:8080/v1/teams/5/members/42 -H "Authorization: Bearer $ACCESS_TOKEN"

Role: admin (any grant), or owner (owner/user within their own group/its projects only).

Request body:

Field Type Notes
subject_type "user" | "team"
subject_id integer
role "admin" | "owner" | "user"
scope_type "global" | "group" | "project"
scope_id integer Required unless scope_type: "global"

Response 201: RoleAssignment.

Errors: 400 invalid_request, 403 forbidden (including an owner attempting role: admin, or a scope outside their own group), 404 not_found (subject_id/scope_id).

Terminal window
curl -X POST http://localhost:8080/v1/role-assignments \
-H "Authorization: Bearer $ACCESS_TOKEN" -H "Content-Type: application/json" \
-d '{"subject_type": "user", "subject_id": 42, "role": "user", "scope_type": "project", "scope_id": 7}'

Role: same rule as creating it.

Response 204: empty body.

Errors: 403 forbidden, 404 not_found, 409 sole_group_owner (revoking the last owner grant on a group — details.blocking_groups).

Terminal window
curl -X DELETE http://localhost:8080/v1/role-assignments/128 -H "Authorization: Bearer $ACCESS_TOKEN"

Projects and quotas (log-server-rbac, log-server-quotas)

Section titled “Projects and quotas (log-server-rbac, log-server-quotas)”

Spec: specs/log-server-quotas/spec.md. See quotas-and-audit.md. All endpoints: Authorization: Bearer <access-token>, JSON bodies.

Role: any authenticated user; every project the caller may read, flat — a role on one project does not cover its group, so this is such a user’s only way to find it.

Query: group_id, name (substring, case-insensitive), limit, cursor — see Pagination.

Response 200: {"items": [Project], "next_cursor": string \| null}. Carries is_blocked, not the usage counters — those come from GET /v1/projects/:id.

Terminal window
curl -G http://localhost:8080/v1/projects -H "Authorization: Bearer $ACCESS_TOKEN" -d limit=50

Role: owner of :groupId, or admin.

Request body:

Field Type Required
name string yes
retention_days integer yes
max_entries integer no
max_bytes integer no

Response 201: Project (without entry_count/total_bytes).

Errors: 400 invalid_request (missing retention_days), 403 forbidden, 404 not_found (:groupId).

Terminal window
curl -X POST http://localhost:8080/v1/groups/3/projects \
-H "Authorization: Bearer $ACCESS_TOKEN" -H "Content-Type: application/json" \
-d '{"name": "checkout-service", "retention_days": 30, "max_entries": 1000000}'

Role: owner/admin.

Request body: any of retention_days/max_entries/max_bytes (partial update).

Response 200: Project (updated, without entry_count/total_bytes).

Errors: 403 forbidden, 404 not_found.

Terminal window
curl -X PATCH http://localhost:8080/v1/projects/7 \
-H "Authorization: Bearer $ACCESS_TOKEN" -H "Content-Type: application/json" \
-d '{"max_entries": 2000000}'

Role: owner/user with access, or admin.

Response 200: Project, with entry_count/total_bytes.

Errors: 403 forbidden, 404 not_found.

Terminal window
curl http://localhost:8080/v1/projects/7 -H "Authorization: Bearer $ACCESS_TOKEN"

POST /v1/projects/:id/block / POST /v1/projects/:id/unblock

Section titled “POST /v1/projects/:id/block / POST /v1/projects/:id/unblock”

Role: admin only — not owner, even for their own project. No request body.

Response 200: Project (updated is_blocked).

Errors: 403 forbidden, 404 not_found.

Terminal window
curl -X POST http://localhost:8080/v1/projects/7/block -H "Authorization: Bearer $ACCESS_TOKEN"
curl -X POST http://localhost:8080/v1/projects/7/unblock -H "Authorization: Bearer $ACCESS_TOKEN"

Role: owner/admin.

Request body: {"label": "..."} (optional).

Response 201: ProjectSecretKey, with secret — shown exactly this once.

Errors: 403 forbidden, 404 not_found.

Terminal window
curl -X POST http://localhost:8080/v1/projects/7/secret-keys \
-H "Authorization: Bearer $ACCESS_TOKEN" -H "Content-Type: application/json" \
-d '{"label": "prod-checkout-instance-1"}'

Role: owner/user with access, or admin.

Response 200: {"items": [ProjectSecretKey]} — metadata only, never secret.

Errors: 403 forbidden, 404 not_found.

Terminal window
curl http://localhost:8080/v1/projects/7/secret-keys -H "Authorization: Bearer $ACCESS_TOKEN"

DELETE /v1/projects/:id/secret-keys/:keyId

Section titled “DELETE /v1/projects/:id/secret-keys/:keyId”

Role: owner/admin. Irreversible.

Response 204: empty body.

Errors: 403 forbidden, 404 not_found.

Terminal window
curl -X DELETE http://localhost:8080/v1/projects/7/secret-keys/15 -H "Authorization: Bearer $ACCESS_TOKEN"

Spec: specs/log-server-audit/spec.md. See quotas-and-audit.md.

Auth: Authorization: Bearer <access-token>. Role: admin only — not owner.

Query parameters: actor_user_id, action, target_type, target_id, from, to, limit, cursor.

Response 200: {"items": [AuditLogEntry], "next_cursor": string \| null, "audit_retention_days": integer \| null, "auth_event_retention_days": integer \| null} — the two retention fields report the policy in force (null = kept indefinitely), so an empty result outside the window explains itself (quotas-and-audit.md).

Errors: 403 forbidden.

There is no endpoint that deletes audit entries, for any role, and the retention periods are set in the server’s configuration rather than through the API — an admin is a subject of this log, not its owner. Entries disappear only through the operator’s configured policy, and each purge that removed anything leaves an audit.purged entry behind.

Terminal window
curl -G http://localhost:8080/v1/audit-log \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-d action=user.blocked \
-d limit=50
  • models.md — full object shapes.
  • errors.md — the complete error catalog, including the distinction between per-entry batch codes and top-level HTTP errors, and why 403 sometimes stands in for 404.
  • configuration.md--cors-allowed-origins and every other setting.