HTTP API overview
The Junjo server exposes a REST API over HTTP, an SSE event stream, and a webhook dispatcher. Cloud and self-host run the exact same binary; the only difference is who hosts the Postgres database.
This page describes the shared conventions every resource route follows. Per-resource pages (groups, members, roles, …) document only the routes themselves.
Versioning
All resource routes are mounted under /v1. The unversioned root (/) and /healthz are infrastructure-only and are not part of the API contract.
Authentication
Every /v1/* request must include an API key in the Authorization header:
Authorization: Bearer <prefix>.<secret>API keys are issued as prefix.secret strings. The prefix is shown to you in the dashboard (or printed by the seed helper for local dev) and identifies the key for lookup. The secret is the part you must keep private; it is hashed on the server with scrypt and is not recoverable if you lose it. Rotate by issuing a new key and revoking the old one.
A missing, malformed, or unrevoked-but-mismatched key returns:
{ "code": "invalid_api_key", "status": 401, "message": "..." }A small number of routes are intentionally public: the request reaches the handler without an API key check. These are limited to read paths the dev’s frontend may want to call directly to render an invite-acceptance UI before the user signs in. Public routes are explicitly called out in the per-resource docs:
GET /v1/invitations/:code- fetch an invitation preview by code.
A separate set of routes is gated by an admin token instead of a per-game API key. These are server-wide endpoints (typically used by the dashboard to query across the games on a single deployment); the admin token is configured via the JUNJO_ADMIN_TOKEN env var. See Admin for the full surface.
Error envelope
Every error response uses the same JSON shape:
{
"code": "<snake_case_code>",
"status": <http_status>,
"message": "<human-readable explanation>"
}The SDK preserves this shape on the JunjoError it throws, so callers can branch on error.code rather than parsing messages.
Reserved codes the server returns today:
| code | status | meaning |
|---|---|---|
invalid_api_key | 401 | API key missing, malformed, unknown, or revoked. |
invalid_admin_token | 401 | Admin endpoint called with a missing or wrong admin token, or the server has JUNJO_ADMIN_TOKEN unset. See Admin. |
bad_request | 400 | Body or query failed Zod validation. |
permission_denied | 403 | Caller lacks permission to perform the action. |
not_found | 404 | Resource does not exist (or is soft-deleted). |
rate_limit_exceeded | 429 | Per-API-key token-bucket exceeded. Response carries a Retry-After header (seconds, integer >= 1) indicating the minimum wait before the next request will be admitted. Tunable via RATE_LIMIT_PER_MINUTE + RATE_LIMIT_BURST env vars (defaults: 600 sustained, 100 burst). |
internal | 500 | Unhandled server error. The full error is logged server-side; the response body is generic on purpose. |
Per-resource pages may introduce additional codes; those are listed alongside the routes that raise them. The full inventory of every code the server and SDK can produce - including the SDK-only codes raised before any HTTP request (auth-adapter misconfiguration, webhook signature failures) - is on the Errors page.
Rate limiting
/v1/* routes that go through the API-key middleware (the per-game routes - groups, members, roles, permissions, audit, webhooks, SSE) are rate limited per API key via an in-memory token bucket. Defaults: 600 requests per minute sustained refill, 100 requests burst. Set RATE_LIMIT_PER_MINUTE or RATE_LIMIT_BURST to 0 to disable rate limiting (useful when the server runs behind a gateway that handles it externally). Buckets are keyed on the API key prefix (the part before the dot in prefix.secret); requests without a parseable bearer share a single “anon” bucket. Rate limiting is in front of the API-key crypto verify, so a flood of requests with the same key is rejected before the scrypt cost is paid.
The bucket map is per-process and unbounded in size by design; multi-instance deployments need an external store (Redis, etc.) for cross-process coordination, deferred for now. The 429 response carries a Retry-After header in seconds.
The public invitation route (GET /v1/invitations/:code) and the admin endpoints (/v1/admin/*, /v1/users/:junjoUserId/games) are NOT rate limited at this layer - they are mounted before the rate-limit middleware. The admin token’s secrecy and the invitation route’s harmless read-only nature are the protection there; if you need rate limiting on those routes, put the server behind a gateway.
Server bootstrap
The runnable entry point is packages/server/src/index.ts. It loads env via loadEnv(), builds the Hono app via createApp(), and registers SIGINT/SIGTERM handlers that disconnect Prisma cleanly. Tests boot the same createApp() factory with injected fakes so each test file owns an isolated app instance.
If you are running the server yourself (not calling the cloud), the Self-hosting page is the operational reference: Docker / Compose recipes, the env-var table, the migration lifecycle across upgrades, API-key issuance, and the reverse-proxy notes for the SSE path.