Skip to content

Console API

This is the API that apps/console calls (and that a script or an operator can call directly). Source of truth in the repo: docs/console-api.md, apps/api/src/projects/projects.controller.ts and apps/api/src/sessions/sessions.controller.ts.

Two paths, both accepted by SessionOrAdminKeyGuard on every endpoint below except where noted:

User session (the normal path):

Authorization: Bearer <jwt>

Issued by POST /v1/auth/login. Everything scopes automatically to the signed-in user’s tenant.

Admin key — for operating and diagnosing without an account:

x-espejo-admin-key: <ESPEJO_ADMIN_KEY>

Rules, mirrored from a guard already battle-tested elsewhere:

  • Minimum 32 characters — otherwise an empty or unset env var turns "" into a master key.
  • Compared in constant time (timingSafeEqual), never ===.
  • Fails closed: if ESPEJO_ADMIN_KEY isn’t configured, every endpoint answers 401. Never “no key configured, so anything goes.”
  • This key does not grant ingest. Writing sessions needs a project’s ingest key instead.
  • It sees every tenant — no filter is applied. That’s why it’s refused for anything that grants access on a person’s behalf: creating a project, and everything under /v1/mcp/* (see MCP server).

Every success response: { "success": true, "data": … }. Errors use Nest’s shape: { "statusCode", "message", "error" }.

An id outside the caller’s scope returns 404, never 403. A 403 already confirms the id exists — half of what someone probing ids is looking for.

{ "success": true, "data": [
{ "id": "uuid", "slug": "angiru", "name": "Angirú",
"mode": "ring", "storage_mode": "ours", "upload_mode": "proxy",
"retention_days": 30, "ingest_paused": false,
"daily_byte_budget": "1073741824", "daily_session_budget": 500,
"keys": [{ "public_id": "pk_live_…", "revoked_at": null, "last_seen_at": "" }],
"origins": ["https://app.angiru.ar"] }
] }

BigInt fields (daily_byte_budget, session bytes_total) travel as stringsJSON.stringify throws on a raw BigInt, and converting to number loses precision above 2^53.

Creates a project in the caller’s tenant. Requires a user session — the admin key is refused here, with 403: a project needs a tenant to belong to, and the admin key has none.

{ "name": "Angirú" }

The server derives and validates the slug ([0-9a-z-] only — it ends up inside the bucket object path, and bundlePrefix in @espejo/core rejects anything else). A collision gets a suffix; it never fails or overwrites.

The first ingest key is issued at creation, so the response already has everything a snippet needs:

{ "success": true, "data": { "id": "uuid", "slug": "angiru", "name": "Angirú",
"keys": [{ "public_id": "pk_live_…" }] } }

Issues another key (rotation). Returns the new one.

Revokes one — sets revoked_at; the row stays, so what came in under it can still be audited.

:keyId is the public_id (pk_live_…), the only thing this API exposes about a key. The internal uuid is also accepted, in case it’s ever surfaced. This was ambiguous once and cost a bug: the API looked up by uuid only while the console sent the public_id, so revoking always 404’d.

Replaces the allowed-origins list: { "origins": ["https://app.angiru.ar"] }. An empty list accepts any origin — the default, so the first integration isn’t blocked before it starts.

Per-day consumption, for the usage/limits screen.

{ "success": true, "data": {
"budget": { "daily_bytes": "1073741824", "daily_sessions": 500 },
"today": { "sessions": 12, "bytes": "48213311" },
"days": [{ "day": "2026-08-14", "sessions": 12, "bytes": "48213311" }],
"stored": { "sessions": 431, "bytes": "2311444992" }
} }

GET /v1/sessions?project=<slug>&limit=50&cursor=<id>&filter=all|errors|video&q=<text>

Section titled “GET /v1/sessions?project=<slug>&limit=50&cursor=<id>&filter=all|errors|video&q=<text>”

Newest first (started_at desc).

{ "success": true, "data": {
"items": [{
"id": "32 hex", "project_slug": "angiru",
"started_at": "", "duration_ms": 41200, "bytes_total": "1244133",
"description": "Can't save the weighing",
"page_host": "app.angiru.ar", "app_release": null, "app_env": null,
"user_ref": null, "user_agent": "",
"capture": "dom", "trigger": "manual",
"has_dom": true, "has_video": false,
"counts": { "requests": 84, "errors_4xx": 2, "errors_5xx": 1,
"console_errors": 3, "interactions": 17 }
}],
"next_cursor": null
} }

Same shape as a list item, plus objects (kind, key, bytes).

The bucket’s events.json, parsed, wrapped in the usual { success, data } envelope. data holds the @espejo/core contract — summary, network, console, interactions, navigations.

The only two un-enveloped routes are /dom and /video, because they stream: wrapping a stream would mean buffering it whole first, exactly what streaming avoids.

The rrweb replay events, streamed, content-type: application/json. Can be several MB.

Proxies the video bytes from the bucket with accept-ranges so a <video> tag can seek. Never a public bucket URL — if the object could be requested without going through here, the key would stop mattering.

Deletes the bucket objects and the row. Idempotent.

Only { "description": "…" } — the “rename recording” action.

GET /v1/mcp/grants · DELETE /v1/mcp/grants/:id

Section titled “GET /v1/mcp/grants · DELETE /v1/mcp/grants/:id”

The tenant’s live MCP connections, and how to cut them. Enveloped, like everything else. Guarded with JwtAuthGuard and does not accept the admin key — approving or revoking an integration is an act a person takes over their own space, and the admin key has no tenant. Full contract in MCP server.

{ "success": true, "data": [
{ "id": "uuid", "app_name": "Claude", "scopes": ["mcp:read"],
"project_mode": "all", "project_ids": [],
"created_at": "", "last_used_at": "" }
] }
  • It never talks to the bucket directly. Everything goes through this API.
  • It never builds an object path. Keys are the server’s to know.