📋 Overview
Hub dokumentasi API untuk seluruh ekosistem ikavia.com.
Pilih service di sidebar atau lewat parameter ?p=<name>:
- [Account Service](/docs?p=account) — Auth, RBAC, organizations (Rust, REST)
- [Media Service](/docs?p=media) — Files, storage, sharing (Rust, REST)
- [Notification Service](/docs?p=notification) — Email/Telegram/WhatsApp + AMQP events (Go, gRPC + RabbitMQ)
🔐 Account Service
Authentication & RBAC source-of-truth
📁 Media Service
File upload, download, metadata
🔔 Notification Service
Multi-channel async notifications
🔐 Two principal types
Human users sign in with email + password; services authenticate with API keys (or exchange a key for a JWT).
🏢 Organization-scoped
Every authenticated request resolves to a current organization context.
🛡️ Defence in depth
Brute-force lockout, refresh-token rotation with reuse detection, server-side revocation.
📦 Standard response envelope
Every JSON response follows the same wrapper shape — success, error, and 4xx/5xx alike.
🚦 Error code table
Numeric codes mirror HTTP status families (2xxx success, 4xxx client error, 5xxx server error).
🍪 Cookies + SSO
Browser clients receive HttpOnly refresh + readable CSRF cookies, both scoped to the configured SSO domain.
🛂 CSRF middleware
Double-submit-cookie protection runs only on cookie-authenticated mutations.
⏱️ Rate limits
Per-IP token-bucket on auth endpoints; 429 carries retry hints.
📑 Pagination
?page + ?per_page query parameters; response wraps items in a paginated envelope.
🔄 gRPC parity
Most HTTP endpoints have a gRPC counterpart sharing the same use cases.
📚 OpenAPI / Swagger UI
This page is for human consumers; for codegen, use the OpenAPI spec.
📤 Streaming upload
Multipart with magic-byte sniff and DB-enforced quota
📁 Folder tree
Hierarchical, max depth 100, sibling-name unique
🔗 Shared links
Tokenised, optionally password-protected
🖼️ Image variants
Original kept; high/medium/low generated on demand
🎞️ Video range streaming
Browser scrubbing without full-file download
📬 Async (RabbitMQ)
Primary integration path
⚙️ gRPC API
Direct query & control
🔌 Providers
Multi-channel delivery
Base URL
Constraints
- Every authenticated request resolves to one organization (`org_id` in the JWT). Cross-org actions require switching first.
- Refresh tokens rotate on every `/auth/refresh`. Presenting an old refresh token after rotation revokes the entire session.
- Access tokens are bound to a session id (`sid`) for human principals and to `nil` for service principals. Logout / sessions revocation only applies to human sessions.
- Personal organization is auto-created on registration with the user as owner. The owner role is non-deletable (`is_system: true`).
- Service accounts cannot log in interactively. They authenticate via API key only.
- Per-user storage quota (default 1 GB) is enforced via a PostgreSQL CHECK constraint, not application code.
- Maximum folder depth is 100; sibling folder names must be unique per parent.
- Default upload size cap is 100 MB (override with `APP__MAX_FILE_SIZE`).
- Effective MIME is derived from a magic-byte sniff; client-declared `Content-Type` is overridden if it conflicts and re-validated against the allow/block policy.
- Shared-link tokens are opaque random strings. Passwords are bcrypt-hashed; legacy SHA-256 hashes are transparently upgraded on access.
- All write operations on folders and media require ownership; cross-tenant reparenting is rejected.
- Image originals are never overwritten. Compressed variants (`high`/`medium`/`low`) are stored separately in `media_variants` and selected via `?variant=` on the download endpoint.
- At-least-once delivery — consumer wajib idempotent. Dedup pakai event `id`.
- Max payload size 1 MiB per AMQP message (limit RabbitMQ guest config).
- Retry policy default 3x dengan exponential backoff. Setelah failed final, masuk dead-letter queue (manual replay).
- Provider unknown → message di-reject, masuk DLQ tanpa retry.
🔐 Account Service
Login, register, JWT/session, RBAC roles, organizations, audit logs, service grants. SSO refresh-cookie scoped to .ikavia.com. REST API documented via OpenAPI 3.1.
📁 Media Service
Storage abstraction (local FS default), MIME blocklist, per-user quota, folder hierarchy. Authenticates via JWT issued by account-service.
🔔 Notification Service
Email/Telegram/WhatsApp/Webhook providers. Driven by RabbitMQ events atau direct gRPC call. MongoDB-backed history & dedup.
🔐 Two principal types
- Human principals receive an access + refresh JWT pair on
/auth/login. Refresh tokens rotate on every/auth/refresh. - Service principals authenticate with an API key (
X-API-Key). Use/auth/token-exchangeto swap a key for a 1-hour JWT and avoid per-request key validation.
🏢 Organization-scoped
- On login the user's
default_organization_idbecomes the current org. - Use
POST /organizations/{org_id}/switchto issue a new token bound to a different org. The session id is preserved across the switch.
🛡️ Defence in depth
- Failed logins increment per-account and per-IP counters with progressive delay and lockout.
- Refresh tokens are rotated on every
/auth/refresh; presenting an old token after rotation revokes the session. - Logout / password-change writes the JTI to a Redis revocation list so stolen access tokens stop validating immediately.
📦 Standard response envelope
Successful responses: json { "success": true, "code": 2010, "data": { ... } }
codeis omitted (or absent) on most happy paths and is filled in for created (2010) and a few special cases.datacarries the endpoint's actual payload.
Failure responses: json { "success": false, "code": 4003, "message": "...", "trace_id": "..." }
codefollows the error-code table below — always check it before falling back to HTTP status.trace_idis set on errors that originate from a use case so support can correlate logs.
🚦 Error code table
| Code | HTTP | Meaning | |------|------|---------| | 2010 | 201 | Resource created (used on /auth/register). | | 4000 | 400 | Bad request (missing required header, malformed body). | | 4002 | 422 | Validation failed (e.g. invalid email format, weak password). | | 4003 | 401 | Authentication / authorization failure (invalid token, missing perm, revoked session). | | 4004 | 404 | Resource not found (account, organization, role, session). | | 4008 | 429 | Rate limit exceeded — retry_after field is included. | | 4030 | 403 | CSRF validation failed. | | 5000 | 500 | Internal error — usually transient (DB / Redis / RabbitMQ blip). Safe to retry with backoff. |
Validation errors carry the offending field name in message (e.g. "email: invalid format").
🍪 Cookies + SSO
Two cookies are issued by /auth/login, /auth/register, and rotated on /auth/refresh. Both are cleared by /auth/logout and /auth/logout-all.
| Cookie | Purpose | Attributes | |--------|---------|------------| | refresh_token | Carries the refresh JWT for the SSO subdomain flow. | HttpOnly, Path=/, Max-Age matches the refresh-token TTL, SameSite={configured}, Secure (configurable, default ON in prod), Domain={configured} (e.g. .example.com). | | csrf_token | Random URL-safe base64 token used by the double-submit pattern. | Not HttpOnly (frontend JS reads it), Path=/, Max-Age mirrors refresh_token, SameSite/Secure/Domain inherited from the refresh cookie config. |
- Domain is
{configurable per-deployment}— set once at the parent SSO domain so a login onaccount.example.comis honoured byapp.example.com. - The login response body also carries
access_tokenandrefresh_tokenfor non-browser clients (mobile, CLI, server-to-server) — these are the same values written to the cookies.
🛂 CSRF middleware
The middleware enforces the double-submit-cookie pattern. It runs only when ALL of the following hold: 1. Method is mutating (POST, PUT, PATCH, DELETE). 2. No Authorization header is present (Bearer/JWT flows are exempt). 3. No X-API-Key header is present (service-account flows are exempt). 4. The request actually carries the csrf_token cookie.
When the gate is active the request must echo the cookie value back in the X-CSRF-Token header (configurable per-deployment). The comparison is constant-time. A mismatch returns: json { "success": false, "code": 4030, "message": "Invalid CSRF token" } Mobile/CLI/SDK clients that authenticate with Authorization: Bearer ... or X-API-Key: ... never see this middleware. CSRF is a browser concern.
⏱️ Rate limits
Limits are configured per-deployment. Defaults at the time of writing:
| Endpoint family | Limit | |----------------|-------| | /auth/register, /auth/resend-verification | 3 / minute | | /auth/login, /me/password | 5 / minute | | /auth/verify | 100 / minute | | All other endpoints | bound by general per-IP cap |
On exceed: json { "success": false, "code": 4008, "message": "Rate limit exceeded. Try again in 30 seconds.", "retry_after": 30, "limit": 5, "window": "60s" } Honour retry_after (seconds) before retrying. The login flow ALSO applies per-account brute-force counters with progressive delay independent of this rate limit — a 200 OK can still be slow if the account is under attack.
📑 Pagination
Every list endpoint accepts:
page— 1-indexed (default1).per_page— page size (default20, hard cap100).
Response shape (inside data): json { "items": [ ... ], "total": 42, "page": 1, "per_page": 20, "total_pages": 3 } total_pages is ceil(total / per_page). There is no next_cursor — use page + 1 until page > total_pages.
🔄 gRPC parity
Every documented HTTP endpoint here delegates to an application use case. The gRPC delivery (account.v1.AuthService, account.v1.AccountService, account.v1.OrgService, account.v1.ServiceAccountService) wraps the same use cases — request and response shapes are derived from proto/account/v1/*.proto.
Use HTTP for browser/mobile/web clients. Use gRPC for service-to-service when you control both ends and want the typed contract. Both paths share session storage and the same revocation list.
📚 OpenAPI / Swagger UI
- OpenAPI 3.x JSON:
GET /openapi.json— generated fromutoipaannotations on the route handlers, always in sync with what the server actually exposes. - Swagger UI:
GET /docs(path is configurable per-deployment) renders an interactive client againstopenapi.json.
The OpenAPI spec is the source of truth for client SDK generation; this docs page is the narrative companion.
📤 Streaming upload
POST /upload streams the request body straight to disk, sniffs the magic bytes, and rejects executables even when disguised by a fake MIME header. Storage quota is enforced at the DB layer (CHECK constraint, migration 20260424000001) — the application pre-check is a fast-path only.
📁 Folder tree
Folders form a tree per owner (parent_id + depth + path). Sibling names must be unique under the same parent (migration 20260424000002). Recursive delete (force=true) purges every blob in the subtree before dropping rows.
🔗 Shared links
Generate an opaque token URL with optional bcrypt password, expiry, and download cap. Anonymous accessors hit GET /share/:token. Legacy SHA-256 hashes are upgraded to bcrypt on first successful access.
🖼️ Image variants
Image uploads keep the original bytes intact and never overwrite them. Three derived variants — high (q=90), medium (q=75), low (q=50) — can be requested via GET /media/{id}?variant=…. The medium tier is generated eagerly on upload (when IMAGE_EAGER_MEDIUM=true); high and low are generated lazily on first access and cached. All levels live in media_variants (one row per tier) — see migration 20260505000001. Delete cascades clean every variant blob from disk, not just the DB rows.
🎞️ Video range streaming
For video/ MIMEs the download endpoint advertises Accept-Ranges: bytes and honours Range: request headers, responding with 206 Partial Content for the requested byte window. Lets the HTML5 <video> element seek mid-file without buffering from byte 0. Out-of-bounds requests return 416 with the canonical Content-Range: bytes /<total> hint. Non-video files don't advertise ranges and serve a full 200.
📬 Async (RabbitMQ)
Service lain publish event ke exchange notification.requests (routing key per provider: email.send, telegram.send, dst). Notification service consume dari notification.events.queue.
⚙️ gRPC API
Untuk fetch history, cancel/retry, dan stats. Tersedia 13 RPC methods terbagi atas NotificationService (7) dan QueueMonitoringService (6).
🔌 Providers
email (SMTP), telegram (Bot API), whatsapp (Business API), http (webhook), grpc, rabbitmq (forward).
🔄 JWT Bearer Authentication Flow
Step-by-step authentication menggunakan JWT Bearer
POST /auth/register (new user) or POST /auth/login (existing). Both return access_token, refresh_token, and an HttpOnly refresh cookie.
Send Authorization: Bearer <access_token> on every protected request. CSRF middleware does not apply when this header is present.
Call POST /auth/refresh (body or cookie carries the refresh token) to receive a fresh pair. The old refresh token is revoked atomically — replaying it later kills the session.
POST /organizations/{org_id}/switch reissues a token bound to the new org. The session id is preserved.
POST /auth/logout revokes the current session and adds the access JTI to the revocation list. POST /auth/logout-all sweeps every active session for the account.
Public endpoints (/health, /share/:token) do not require a token.
🔄 API Key Authentication Flow
Step-by-step authentication menggunakan API Key
Owner of the org calls POST /service-accounts/{account_id}/api-keys. The plaintext key is returned ONCE — store it client-side immediately.
Set X-API-Key: <key> as a header. The middleware accepts it on any protected endpoint.
POST /auth/token-exchange with X-API-Key returns a 1-hour service JWT (principal_type=service). Verifiers can validate it locally with /auth/public-key, no per-request roundtrip to account-service.
POST /api-keys/{api_key_id}/revoke marks the key dead immediately. Use DELETE /api-keys/{api_key_id} for permanent removal.
Public endpoints (/health, /share/:token) do not require a token.
🔄 JWT Authentication Flow
Step-by-step authentication menggunakan JWT
Out of scope for this service. Obtain a JWT from the configured Account Service login endpoint.
Send Authorization: Bearer <token> on every endpoint except /health and /share/:token.
Each route asserts a specific permission (e.g. media:upload). Tokens missing the required permission get HTTP 403.
Public endpoints (/health, /share/:token) do not require a token.
🔄 Service Flow Diagram
Visualisasi alur data antara services
Return the authenticated principal's profile (account details + email verification status). Works for both human and service principals.
{
"success": true,
"data": {
"account_id": "550e8400-...",
"account_type": "human",
"email": "user@example.com",
"display_name": "Jane Doe",
"avatar_url": "",
"timezone": "Asia/Jakarta",
"language": "id",
"email_verified": true,
"bio": "",
"metadata": {},
"created_at": 1735689600
}
}
Patch the authenticated user's profile. Omitted fields are left untouched. model_config and capabilities apply to service accounts only.
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| display_name | string | optional | Updated display name. |
| bio | string | optional | Free-form bio (markdown allowed downstream). |
| timezone | string | optional | IANA timezone. |
| language | string | optional | ISO 639-1 code. |
| model_config | string | optional | Service accounts only — model preferences blob. |
| capabilities | string[] | optional | Service accounts only — capabilities list (e.g. museum:read). |
{
"display_name": "Jane D.",
"bio": "Building things.",
"timezone": "Asia/Singapore",
"language": "en"
}
{ "success": true, "data": { "account_id": "...", "account_type": "human", "email": "user@example.com", "display_name": "Jane D.", "timezone": "Asia/Singapore", "language": "en", "email_verified": true, "bio": "Building things.", "metadata": {}, "created_at": 1735689600 } }
Change the authenticated user's password. Verifies the current password, then revokes every active session except the one making this request (so the user isn't logged out of the tab they're sitting in). Service accounts cannot call this — they have no password. Rate limit: 5 / minute / IP.
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| current_password | string | required | Existing password (re-verified server-side). |
| new_password | string | required | New password — same complexity rules as registration. |
{
"current_password": "OldPass123!",
"new_password": "NewSecurePass456!"
}
{ "success": true, "data": { "success": true } }
List every active session for the authenticated user. The session backing the current request is flagged with is_current: true. Also available at /api/v1/sessions (legacy path).
{
"success": true,
"data": {
"sessions": [
{
"session_id": "550e8400-...",
"ip_address": "203.0.113.42",
"user_agent": "Mozilla/5.0 ...",
"device_fingerprint": "",
"created_at": 1735689600,
"last_used_at": 1735693200,
"expires_at": 1738281600,
"is_current": true
}
]
}
}
Revoke a specific session by id. The user can revoke any of their own sessions, including the current one (which terminates the calling session). Also available at /api/v1/sessions/{session_id}.
{ "success": true, "data": null }
Audit-log entries where the authenticated user is the actor. Paged. The actor_id filter is forced server-side to the calling user, so callers cannot pivot to another user's feed.
Action vocabulary (dot-notation, used in action filter and action field of returned items):
account.profile_updated,account.accessedauth.login,auth.login_failed,auth.logout,
auth.token_refreshed, auth.password_changed, auth.password_reset
member.invited,member.removedinvitation.cancelledservice_account.archived,service_account.paused,
service_account.resumed
api_key.created,api_key.revoked
New actions may be added without a docs revision; consumers should tolerate unknown values (display as-is, don't reject).
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
| page | integer | optional | 1-indexed page number. (default: 1) |
| per_page | integer | optional | Page size, max 100. (default: 20) |
| since | string | optional | ISO-8601 — filter created_at >= since. |
| until | string | optional | ISO-8601 — filter created_at < until. |
| action | string | optional | Dot-notation filter, e.g. member.invited. |
| org_id | string | optional | Optional org scope. Org-side activities are also available at /organizations/{org_id}/activities. |
{
"success": true,
"data": {
"items": [
{
"id": "550e8400-...",
"actor_id": "550e8400-...",
"actor_email": "user@example.com",
"actor_display_name": null,
"organization_id": "550e8400-...",
"organization_name": null,
"action": "organization.created",
"target_type": "organization",
"target_id": "550e8400-...",
"target_label": null,
"metadata": {},
"ip_address": "203.0.113.42",
"user_agent": "Mozilla/5.0 ...",
"created_at": 1735689600
}
],
"total": 42,
"page": 1,
"per_page": 20,
"total_pages": 3
}
}
Create a new human account. A personal organization is created in the same transaction with the registrant as owner. A verification email is queued (event email.requested). Auto-logs in: returns access + refresh tokens and sets the SSO refresh cookie. Rate limit: 3 / minute / IP.
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| string | required | Valid email address. Must be unique across all accounts. | |
| password | string | required | Minimum 8 chars, must include upper, lower, digit, and special character. |
| display_name | string | required | Free-form. Used for the personal organization name and greeting. |
| timezone | string | optional | IANA timezone. Defaults to 'UTC'. ex: Asia/Jakarta |
| language | string | optional | ISO 639-1 code. Defaults to 'en'. ex: id |
{
"email": "user@example.com",
"password": "SecurePass123!",
"display_name": "Jane Doe",
"timezone": "Asia/Jakarta",
"language": "id"
}
{
"success": true,
"code": 2010,
"data": {
"account_id": "550e8400-e29b-41d4-a716-446655440000",
"email": "user@example.com",
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"refresh_token": "eyJhbGciOiJSUzI1NiIs...",
"expires_in": 900,
"personal_org": {
"org_id": "550e8400-e29b-41d4-a716-446655440001",
"name": "Jane Doe's Personal",
"slug": "jane-doe-personal-550e8400",
"status": "active",
"owner_account_id": "550e8400-e29b-41d4-a716-446655440000",
"plan": "free",
"created_at": 1735689600,
"updated_at": 1735689600,
"my_role": "owner",
"my_permissions": ["*"]
}
}
}
Authenticate with email + password. Returns access + refresh tokens and the user's full organization list. Sets refresh + CSRF cookies. Brute-force protection: per-IP and per-account counters with progressive delay and lockout. Rate limit: 5 / minute / IP.
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| string | required | Account email. | |
| password | string | required | Account password. |
{
"email": "user@example.com",
"password": "SecurePass123!"
}
{
"success": true,
"data": {
"account_id": "550e8400-e29b-41d4-a716-446655440000",
"email": "user@example.com",
"display_name": "Jane Doe",
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"refresh_token": "eyJhbGciOiJSUzI1NiIs...",
"expires_in": 900,
"organizations": [
{ "org_id": "...", "name": "Jane Doe's Personal", "slug": "jane-doe-personal-...", "status": "active", "owner_account_id": "...", "plan": "free", "created_at": 1735689600, "updated_at": 1735689600, "my_role": "owner", "my_permissions": ["*"] }
],
"current_org_id": "550e8400-e29b-41d4-a716-446655440001",
"requires_email_verification": false,
"csrf_token": "f3a1c0..."
}
}
Exchange a refresh token for a new access + refresh pair. The old refresh token's hash is rotated server-side; the old JTI is added to the revocation list once rotation is persisted. Replaying the old refresh token after this call kills the session (reuse detection). Refresh token may be in the body or in the refresh_token cookie (SSO flow).
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| refresh_token | string | optional | Optional in body. Falls back to the refresh_token cookie when omitted. |
{ "refresh_token": "eyJhbGciOiJSUzI1NiIs..." }
{
"success": true,
"data": {
"account_id": "...",
"email": "user@example.com",
"display_name": "Jane Doe",
"access_token": "eyJhbGciOiJSUzI1NiIs... (new)",
"refresh_token": "eyJhbGciOiJSUzI1NiIs... (new)",
"expires_in": 900,
"organizations": [ ... ],
"current_org_id": "...",
"requires_email_verification": false,
"csrf_token": "..."
}
}
Revoke the current session. The access token's JTI is also added to the revocation list so a token stolen before logout stops validating immediately. Cookies are always cleared on the response, even if the server-side revoke fails. Service principals cannot call this endpoint — they have no session.
{ "success": true, "data": { "success": true } }
Revoke every active session for the calling account. Useful for "I lost my phone" or post-password-change cleanups. Cookies are rotated/cleared on the response.
{ "success": true, "data": { "success": true } }
Re-emit the verification email for an unverified account. Always returns success regardless of whether the email exists, to avoid leaking account presence. Rate limit: 3 / minute / IP.
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| string | required | Email to resend verification to. |
{ "email": "user@example.com" }
{ "success": true, "data": { "message": "If the account exists and is unverified, a new verification email has been sent." } }
Initiate a password reset. Always returns success, even when the email is unknown, to prevent email enumeration. Generates a 1-hour JWT and emits an email.requested event with the reset link.
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| string | required | Email of the account to reset. |
{ "email": "user@example.com" }
{ "success": true, "data": null }
Complete a password reset using the JWT from the email link. Validates the token (signature + jti not previously used), updates the password hash, and revokes all sessions for the account.
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| token | string | required | Reset token from the email link (JWT). |
| new_password | string | required | Same complexity rules as registration. |
{
"token": "eyJhbGciOiJSUzI1NiIs...",
"new_password": "NewSecurePass456!"
}
{ "success": true, "data": { "success": true } }
Introspect an access token (signature + exp + revocation). Used by downstream services that prefer a server-side check over local public-key verification. Returns the claims as-is: permissions come from the token, not re-derived from current state. Use /auth/refresh if you need fresh permissions. Rate limit: 100 / minute / IP.
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| token | string | required | Access token to introspect. |
{ "token": "eyJhbGciOiJSUzI1NiIs..." }
{
"success": true,
"data": {
"valid": true,
"account_id": "550e8400-...",
"organization_id": "550e8400-...",
"permissions": ["*"],
"expires_at": 1735693200,
"session_id": "550e8400-..."
}
}
Return the principal identity carried by the request — exactly what the middleware extracted, with no extra DB lookups. Useful for SDKs and gateways debugging auth wiring.
{
"success": true,
"data": {
"account_id": "550e8400-...",
"principal_type": "human",
"session_id": "550e8400-...",
"current_org_id": "550e8400-...",
"organizations": [ { "id": "550e8400-...", "role": "owner" } ],
"permissions": ["*"]
}
}
Return the RS256 public key (PEM) for local JWT verification by downstream services. Cache this for the lifetime of expires_at (default 90 days).
Key rotation strategy:
- Each issued JWT carries a
kidclaim in its header naming the
key that signed it (e.g. key-2026-03-01-v1).
- Verifiers should cache by
kid. On a cache miss (unfamiliar
kid), re-fetch this endpoint to learn the new key.
- During rotation, account-service serves the new key here
but continues to accept tokens signed by the previous key until those tokens naturally exp. Verifiers should retain the previous key entry until its tokens have aged out.
expires_atis the rough cache horizon — refresh before that
even without a kid mismatch to pick up scheduled rotations.
{
"success": true,
"data": {
"key_id": "key-2026-03-01-v1",
"algorithm": "RS256",
"public_key": "-----BEGIN PUBLIC KEY-----\nMIIB...\n-----END PUBLIC KEY-----\n",
"expires_at": 1743465600
}
}
Validate the supplied X-API-Key header and return a 1-hour service JWT (principal_type: service). Lets a service principal authenticate downstream calls with a verifiable JWT instead of re-presenting the API key on every hop. The JWT carries the service account's capabilities as permissions and nil as sid.
{
"success": true,
"data": {
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 3600,
"account_id": "550e8400-...",
"organization_id": "550e8400-...",
"permissions": ["museum:read", "artifact:write"],
"principal_type": "service"
}
}
Lightweight liveness check. Returns 200 with a small JSON when the process is up. Does not touch DB / Redis / RabbitMQ — use /health/detailed for that. Suitable for k8s livenessProbe.
{ "status": "ok" }
Per-dependency health snapshot. Probes Postgres, Redis, and RabbitMQ and returns each dependency's state. A degraded dependency yields HTTP 503 so a load balancer can drop the pod from rotation. Heavier than /health — call from readinessProbe, not livenessProbe.
{
"status": "ok",
"dependencies": {
"postgres": { "status": "ok", "latency_ms": 3 },
"redis": { "status": "ok", "latency_ms": 1 },
"rabbitmq": { "status": "ok", "latency_ms": 5 }
},
"version": "1.0.0",
"uptime_seconds": 12345
}
Prometheus exposition format. Includes HTTP request counters / latency histograms (sanitized path labels — id segments collapsed to :id so cardinality stays bounded), gRPC counters, brute-force counters, rate-limiter counters, and Redis connection pool stats. Should be firewalled to the metrics-collector subnet, not exposed publicly.
# HELP http_requests_total Total HTTP requests
# TYPE http_requests_total counter
http_requests_total{method="GET",path="/api/v1/me",status="200"} 142
...
Lightweight existence + display info for an organization. Used by frontends to validate org-scoped invitation links before login. Returns minimal data — no member or role info is leaked.
{
"success": true,
"data": {
"org_id": "550e8400-...",
"name": "Acme Corp",
"created_at": 1735689600
}
}
List organizations the authenticated principal is a member of. Each entry includes the principal's role in that org.
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
| page | integer | optional | 1-indexed page number. (default: 1) |
| per_page | integer | optional | Page size, max 100. (default: 20) |
{
"success": true,
"data": {
"items": [
{ "org_id": "...", "name": "Acme Corp", "slug": "acme-corp", "description": null, "status": "active", "owner_id": "...", "created_at": 1735689600, "updated_at": 1735689600, "member_count": 12 }
],
"total": 1,
"page": 1,
"per_page": 20,
"total_pages": 1
}
}
Create a new organization with the caller as owner. Slug is auto-derived from the name when omitted; if the slug collides a 4xx is returned and the caller should retry with an explicit slug.
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| name | string | required | Display name of the organization. |
| slug | string | optional | URL-safe slug. Auto-generated from name when omitted. |
{ "name": "Acme Corp", "slug": "acme-corp" }
{
"success": true,
"data": {
"org_id": "550e8400-...",
"name": "Acme Corp",
"slug": "acme-corp",
"description": null,
"status": "active",
"owner_id": "550e8400-...",
"created_at": 1735689600,
"updated_at": 1735689600,
"member_count": 1
}
}
Fetch a single organization by id. Caller must be a member.
{ "success": true, "data": { "org_id": "...", "name": "Acme Corp", "slug": "acme-corp", "description": null, "status": "active", "owner_id": "...", "created_at": 1735689600, "updated_at": 1735689600, "member_count": 12 } }
Update the organization's name or slug.
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| name | string | optional | New display name. |
| slug | string | optional | New URL-safe slug. |
{ "name": "Acme Inc.", "slug": "acme-inc" }
{ "success": true, "data": { "org_id": "...", "name": "Acme Inc.", "slug": "acme-inc", "status": "active", "owner_id": "...", "created_at": 1735689600, "updated_at": 1735693200, "member_count": 12 } }
Soft-delete (archive) an organization. Owner-only — protected by the owner role's * wildcard rather than a per-action permission. Members lose access on next token rotation; all sessions remain intact for other orgs the user belongs to.
{ "success": true, "data": null }
Issue a new token pair bound to a different organization. The existing session id is reused, so refresh continuity is preserved. The caller must already be a member of the target org.
{
"success": true,
"data": {
"account_id": "...",
"email": "user@example.com",
"display_name": "Jane Doe",
"access_token": "eyJ...new...",
"refresh_token": "eyJ...new...",
"expires_in": 900,
"organizations": [ { "org_id": "...", "name": "Acme Corp", "slug": "acme-corp", "status": "active", "owner_account_id": "...", "plan": "free", "created_at": 1735689600, "updated_at": 1735689600, "my_role": "owner", "my_permissions": ["*"] } ],
"current_org_id": "550e8400-...new..."
}
}
Remove the caller's own membership. Owners cannot leave — transfer ownership first or delete the organization.
{ "success": true, "data": null }
Paged member list with role + permissions.
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
| page | integer | optional | 1-indexed page. (default: 1) |
| per_page | integer | optional | Page size, max 100. (default: 20) |
{
"success": true,
"data": {
"items": [
{ "account_id": "...", "email": "user@example.com", "display_name": "Jane Doe", "role": "owner", "permissions": ["*"], "joined_at": 1735689600 }
],
"total": 1,
"page": 1,
"per_page": 20,
"total_pages": 1
}
}
Send an invitation to an email address. If a role is omitted the org's default-member role is assigned on accept. The recipient gets an email via the email.requested event.
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| string | required | Invitee email. | |
| role_id | string | optional | Role id to assign on accept. Defaults to org default member role. |
{ "email": "newmember@example.com", "role_id": "550e8400-..." }
{
"success": true,
"data": {
"invitation_id": "550e8400-...",
"message": "Invitation sent to newmember@example.com"
}
}
Remove a member from the org. Cannot remove the owner.
{ "success": true, "data": null }
Reassign a member to a different role. New role takes effect on their next token rotation.
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| role_id | string | required | Target role id. |
{ "role_id": "550e8400-..." }
{ "success": true, "data": null }
List roles defined in the org (system + custom).
{
"success": true,
"data": {
"items": [
{ "role_id": "...", "name": "owner", "description": "Organization owner with full access", "permissions": ["*"], "is_system": true, "created_at": 1735689600, "updated_at": 1735689600 }
],
"total": 1,
"page": 1,
"per_page": 20,
"total_pages": 1
}
}
Create a custom role. Permissions are free-form strings (the permission service interprets them); see the Permissions section for the canonical names used by account-service itself.
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| name | string | required | Display name (unique within the org). |
| description | string | optional | Optional description. |
| permissions | string[] | required | Permission strings granted by this role. |
{
"name": "auditor",
"description": "Read-only access to audit logs",
"permissions": ["read:organizations", "audit_logs:read"]
}
{ "success": true, "data": { "role_id": "...", "name": "auditor", "description": "Read-only access to audit logs", "permissions": ["read:organizations","audit_logs:read"], "is_system": false, "created_at": 1735689600, "updated_at": 1735689600 } }
Fetch a single role.
{ "success": true, "data": { "role_id": "...", "name": "auditor", "description": "Read-only access to audit logs", "permissions": ["read:organizations","audit_logs:read"], "is_system": false, "created_at": 1735689600, "updated_at": 1735689600 } }
Patch a role. System roles (e.g. owner) are read-only.
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| name | string | optional | New name. |
| description | string | optional | New description (pass null to clear). |
| permissions | string[] | optional | New permissions list (replaces, not merges). |
{ "permissions": ["read:organizations", "audit_logs:read", "members:invite"] }
{ "success": true, "data": { "role_id": "...", "name": "auditor", "permissions": ["read:organizations","audit_logs:read","members:invite"], "is_system": false, "created_at": 1735689600, "updated_at": 1735693200 } }
Delete a custom role. Fails if any member is currently assigned to it — reassign them first. System roles cannot be deleted.
{ "success": true, "data": null }
Org-wide audit feed. Same shape as /me/activities but scoped to all actors within the organization. Requires audit_logs:read permission in the org.
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
| page | integer | optional | 1-indexed page. (default: 1) |
| per_page | integer | optional | Page size, max 100. (default: 20) |
| since | string | optional | ISO-8601. |
| until | string | optional | ISO-8601. |
| action | string | optional | Dot-notation action filter. |
{
"success": true,
"data": {
"items": [
{ "id": "...", "actor_id": "...", "actor_email": "user@example.com", "actor_display_name": null, "organization_id": "...", "organization_name": null, "action": "member.invited", "target_type": "member", "target_id": "...", "target_label": null, "metadata": {}, "ip_address": "203.0.113.42", "user_agent": "...", "created_at": 1735689600 }
],
"total": 1, "page": 1, "per_page": 20, "total_pages": 1
}
}
List service accounts in an organization. The org is supplied as a query parameter (not a path segment) because callers commonly enumerate across orgs they manage.
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
| organization_id | string | required | Organization id to filter by. |
| status | string | optional | One of active, paused, archived. |
| page | integer | optional | 1-indexed page. (default: 1) |
| per_page | integer | optional | Page size, max 100. (default: 20) |
{
"success": true,
"data": {
"items": [
{ "account_id": "...", "display_name": "Render bot", "status": "active", "capabilities": ["museum:read"], "last_used_at": 1735693200, "created_at": 1735689600 }
],
"total": 1, "page": 1, "per_page": 20, "total_pages": 1
}
}
Create a new service account scoped to an organization. The returned account has no API key yet — call POST /service-accounts/{account_id}/api-keys afterwards.
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| organization_id | string | required | Organization to attach the service account to. |
| display_name | string | required | Human-readable name. |
| description | string | optional | Optional description. |
| capabilities | string[] | required | Capabilities granted (resource:action format). |
| allowed_tools | string[] | required | Subset of tool ids the service may invoke. Empty list = none. |
| rate_limit_per_minute | integer | optional | Per-key rate cap; clamped to 1-10000. Default 60. |
{
"organization_id": "550e8400-...",
"display_name": "Render bot",
"description": "Generates thumbnail previews",
"capabilities": ["museum:read", "artifact:write"],
"allowed_tools": ["thumbnailer"],
"rate_limit_per_minute": 120
}
{
"success": true,
"data": {
"account_id": "550e8400-...",
"organization_id": "550e8400-...",
"display_name": "Render bot",
"description": "Generates thumbnail previews",
"status": "active",
"capabilities": ["museum:read", "artifact:write"],
"allowed_tools": ["thumbnailer"],
"rate_limit_per_minute": 120,
"last_used_at": null,
"created_at": 1735689600,
"updated_at": 1735689600
}
}
Fetch one service account by id.
{
"success": true,
"data": { "account_id": "...", "organization_id": "...", "display_name": "Render bot", "status": "active", "capabilities": ["museum:read","artifact:write"], "allowed_tools": ["thumbnailer"], "rate_limit_per_minute": 120, "last_used_at": 1735693200, "created_at": 1735689600, "updated_at": 1735689600 }
}
Patch metadata, capabilities, or rate limit. Permission changes take effect on the next /auth/token-exchange call (or immediately for raw X-API-Key flows).
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| organization_id | string | required | Org id (used as a safety check that the caller scoped the request correctly). |
| display_name | string | optional | New display name. |
| description | string | optional | New description. |
| capabilities | string[] | optional | Replacement capabilities list. |
| allowed_tools | string[] | optional | Replacement allowed-tools list. |
| rate_limit_per_minute | integer | optional | New rate limit. |
{
"organization_id": "550e8400-...",
"display_name": "Render bot v2",
"rate_limit_per_minute": 240
}
{ "success": true, "data": { "account_id": "...", "display_name": "Render bot v2", "rate_limit_per_minute": 240, "status": "active", "capabilities": ["museum:read","artifact:write"], "allowed_tools": ["thumbnailer"], "organization_id": "...", "created_at": 1735689600, "updated_at": 1735693200 } }
Soft-delete (archive) a service account. All of its API keys are revoked. Existing service JWTs continue to validate until their natural exp (max 1 hour); add the JTI to the revocation list via re-issued tokens to shorten that further.
{ "success": true, "data": null }
Temporarily disable a service account. API keys stop authenticating until resumed; metadata and capabilities are preserved.
{ "success": true, "data": null }
Re-enable a paused service account.
{ "success": true, "data": null }
List metadata for every key on a service account. The plaintext is never returned here — only key_prefix for identification.
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
| page | integer | optional | 1-indexed page. (default: 1) |
| per_page | integer | optional | Page size, max 100. (default: 20) |
{
"success": true,
"data": {
"items": [
{ "api_key_id": "...", "key_prefix": "ak_live_a1b2", "name": "prod render bot", "permissions": ["museum:read"], "expires_at": 1751155200, "last_used_at": 1735693200, "created_at": 1735689600, "is_revoked": false, "is_expired": false }
],
"total": 1, "page": 1, "per_page": 20, "total_pages": 1
}
}
Mint a new API key. The key field in the response is the only time the plaintext is ever exposed — store it client-side immediately. Subsequent calls see only the hash and key_prefix.
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| name | string | optional | Free-form label for the key. |
| permissions | string[] | required | Permission strings granted to this key (subset of the service account's capabilities). |
| expires_in_days | integer | optional | TTL in days. Omit for no expiry. |
{
"name": "prod render bot",
"permissions": ["museum:read"],
"expires_in_days": 180
}
{
"success": true,
"data": {
"api_key_id": "...",
"key": "ak_live_a1b2c3d4e5f6...REDACTED_RAW_KEY...",
"key_prefix": "ak_live_a1b2",
"name": "prod render bot",
"permissions": ["museum:read"],
"expires_at": 1751155200,
"created_at": 1735689600
}
}
Mark a key as revoked. The key continues to exist for audit purposes but stops authenticating immediately.
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| reason | string | optional | Optional human-readable reason recorded on the audit log. |
{ "reason": "leaked in public repo" }
{ "success": true, "data": null }
Permanently remove a key (hard delete). Prefer revoke for auditable history.
{ "success": true, "data": null }
Creates a folder owned by the caller. If parent_id is provided, the folder is nested under that parent and inherits a depth of parent.depth + 1. The DB enforces sibling-name uniqueness — duplicates return HTTP 409.
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| name | string | required | Folder name. 1–255 chars; no / or control characters. |
| parent_id | uuid | optional | Parent folder. Omit for a root-level folder. |
| visibility | string | optional | public | organization | private. |
{
"name": "Vacation Photos",
"parent_id": "4f2c…",
"visibility": "organization"
}
{
"id": "f9a1…",
"name": "Vacation Photos",
"parent_id": "4f2c…",
"path": "Documents/Vacation Photos",
"depth": 2,
"created_at": "2026-04-30T08:14:22Z"
}
Lists folders owned by the caller. Default scope is the current parent's children (or roots when parent_id is omitted). Set recursive=true to flatten the entire subtree.
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
| parent_id | uuid | optional | Listing scope. Omit for root-level folders. |
| recursive | bool | optional | Walk the full subtree under parent_id. (default: false) |
| sort | string | optional | name | created_at | updated_at. (default: name) |
| order | string | optional | asc | desc. (default: asc) |
Renames a folder. The DB trigger trg_update_folder_path rewrites every descendant's path inside the same transaction. Root folders cannot be renamed.
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| name | string | required | New folder name. |
{ "name": "Archive 2026" }
Returns the immediate children: subfolders and media files in a single paginated response. Useful for a Drive-style file browser UI.
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
| page | int | optional | 1-indexed page number. (default: 1) |
| per_page | int | optional | Items per page. (default: 20) |
Deletes the folder. The non-force path requires the folder to be empty (no media, no subfolders) and returns HTTP 412 if it is not. With force=true the entire subtree is purged: for every media in the subtree, all derived variant blobs (medium /high/low) are removed first, then the original blob, then the media row; finally the folder rows cascade. Variant cleanup failures are logged but never abort the purge — same reasoning as the single-media delete endpoint.
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
| force | bool | optional | Recursively delete every file and subfolder in the subtree. (default: false) |
{
"deleted": true,
"folder_id": "f9a1…",
"deleted_items": { "folders": 3, "media": 17 }
}
Returns a static JSON payload indicating the service process is up. Does not check downstream dependencies (DB, storage).
{
"status": "healthy",
"service": "media-service"
}
Paginated listing. Supports filtering by folder, visibility, file type, and a search term applied to original_name. The search input is parameterised at the SQL layer.
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
| folder_id | uuid | optional | Restrict to this folder; omit for all-folders view. |
| page | int | optional | 1-indexed page number. (default: 1) |
| per_page | int | optional | Items per page. (default: 20) |
| visibility | string | optional | Filter: public | organization | private. |
| file_type | string | optional | Filter: image | video | document | audio | other. |
| search | string | optional | Substring match on original_name. |
| sort | string | optional | created_at | name | size | updated_at. (default: created_at) |
| order | string | optional | asc | desc. (default: desc) |
{
"items": [
{
"id": "9e1c…",
"original_name": "photo.jpg",
"mime_type": "image/jpeg",
"size_bytes": 248122,
"visibility": "organization",
"folder_id": "4f2c…",
"created_at": "2026-04-30T08:14:22Z"
}
],
"total": 1,
"page": 1,
"per_page": 20,
"total_pages": 1,
"quota": { "used_bytes": 248122, "max_bytes": 1073741824 }
}
Streams the media bytes back with the original Content-Type and Content-Disposition. Visibility rules apply: public is unauthenticated, organization requires same-org JWT, private requires the owner's JWT.
Variant selection (?variant=) — for image uploads, the service stores the original plus optional re-encoded variants at decreasing quality. Use the query param to pick which one to download:
original (default) — uncompressed bytes, format as uploaded. SHA-256 matches the upload-time checksum. high (q=90) — light recompression, near-original quality. medium (q=75) — balanced; suitable for previews. low (q=50) — aggressive; suitable for thumbnails.
Aliases also accepted: orig / raw for original, hi for high, med for medium, lo for low. Invalid values return HTTP 400 with the allowed list.
The medium variant is generated eagerly on upload (when IMAGE_EAGER_MEDIUM=true); other levels are generated lazily on first request and cached for subsequent ones. Generation is best-effort — if the original cannot be re-encoded (corrupt image, unsupported format), the request fails with HTTP 500 rather than silently returning the original.
The Content-Disposition filename is always the upload's display name regardless of variant — the tier is invisible to the user-facing UI.
Range / streaming (video only) — when the underlying MIME is video/*, the response includes Accept-Ranges: bytes and the endpoint honours Range: request headers. This lets the browser's HTML5 <video> element scrub mid-file without downloading from byte 0. Supported forms:
Range: bytes=N-M → 206 Partial Content Range: bytes=N- → 206 from N to EOF Range: bytes=-M → 206 last M bytes Multi-range / unparseable → ignored, full 200 returned Out-of-bounds → 416 with Content-Range: bytes /<total>
Non-video MIMEs do NOT advertise Accept-Ranges and ignore any Range: header — they always stream the full body.
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
| variant | string | optional | original (default) | high | medium | low. Aliases: orig/raw, hi, med, lo. (default: original) |
Returns the metadata only (no bytes). Useful for clients that want size/MIME/checksum without paying for the download.
Updates the user-facing original_name. The on-disk filename is unchanged; only the display name is rewritten.
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| original_name | string | required | New display name. Client should preserve the extension. |
{ "original_name": "vacation-2026-04.jpg" }
Switches the media row's visibility level. Does not propagate to shared links; existing tokens keep working until they expire or are deleted.
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| visibility | string | required | public | organization | private. |
{ "visibility": "private" }
Removes every blob (original + each derived variant) from storage, then deletes the media row. The matching media_variants rows go away via FK cascade. A StorageError::NotFound is tolerated per file so retries on a partially-failed delete are idempotent.
A failure cleaning a single variant blob is logged but does not abort the delete — the user's media still goes away; worst case is one orphan blob on disk. Operators can sweep orphans by walking the storage directory and reconciling against media_variants.storage_path.
{
"deleted": true,
"media_id": "9e1c…",
"filename": "9e1c….jpg",
"freed_bytes": 248122
}
Returns the caller's most recently accessed media, newest-first. Capped at the value the client requests (default applied server-side).
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
| limit | int | optional | Max items to return; server applies a sane upper bound. |
Records an access event for the given media. Idempotent at the row level — repeated tracks update the existing row's timestamp instead of inserting duplicates.
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| access_type | string | optional | Free-form classifier (e.g. view, download, preview). |
{ "access_type": "view" }
Wipes the caller's entire recent-files history. The underlying media files are untouched.
Mints a shared-link token for the given media. The caller must own the media or have download access through it. Visibility defaults to restricted (creator-only) — set public for anonymous access or organization to gate by the caller's org.
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| visibility | string | optional | public | organization | restricted. |
| password | string | optional | If set, accessors must supply this password (bcrypt-hashed at rest). |
| expires_in_hours | int | optional | Lifetime in hours. Omit for no expiry. |
| max_downloads | int | optional | Cap on successful downloads through this link. |
{
"visibility": "public",
"password": "spring-2026",
"expires_in_hours": 168,
"max_downloads": 50
}
{
"id": "ab12…",
"token": "8f3c2a1b…",
"share_url": "https://media.example.com/share/8f3c2a1b…",
"visibility": "public",
"has_password": true,
"expires_at": "2026-05-07T08:14:22Z",
"max_downloads": 50,
"created_at": "2026-04-30T08:14:22Z"
}
Anonymous (or authenticated, for restricted/organization links) entry point. Streams the media bytes back. The download counter is incremented atomically.
Failure modes (401/403/404): link not found / revoked → 404 expired → 410 download cap reached → 429 password missing/wrong → 401 * caller not eligible for restricted / organization → 403
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
| password | string | optional | Required when the link was created with a password. |
Lists shared links the caller created. Paginated.
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
| page | int | optional | 1-indexed page number. (default: 1) |
| per_page | int | optional | Items per page. (default: 20) |
Revokes a shared link. Subsequent GET /share/:token requests return 404. The underlying media file is unaffected.
Lists the caller's starred media in reverse-chronological order (most recently starred first). Each item includes the underlying media metadata plus starred_at and notes.
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
| page | int | optional | 1-indexed page number. (default: 1) |
| per_page | int | optional | Items per page. (default: 20) |
Stars (or re-stars) a media file. Optional notes are stored alongside the star. Calling this on an already-starred file is a no-error update.
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| notes | string | optional | Optional annotation, surfaced in the listing. |
{ "notes": "Reference for the launch deck" }
Removes the star. Idempotent — unstarring an already-unstarred file returns success.
Convenience endpoint that flips the star state. Useful for UIs that bind a single button to "star/unstar" without tracking current state client-side.
Bulk lookup: given a list of media IDs, returns which ones the caller has starred. Lets a list view annotate every row in one round-trip.
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| media_ids | array<string> | required | Up to N media UUIDs to check at once. |
{ "media_ids": ["9e1c…", "f9a1…", "ab12…"] }
{
"items": [
{ "media_id": "9e1c…", "is_starred": true },
{ "media_id": "f9a1…", "is_starred": false },
{ "media_id": "ab12…", "is_starred": true }
]
}
Streams a single multipart file part (field name file) into storage. The response carries the canonical media metadata, including the SHA-256 checksum and the storage path of the original bytes.
Validation order: filename → MIME allow/block → quota fast-path → stream to disk (max-size enforced authoritatively) → magic-byte sniff → rebuild against policy → DB row.
Original is never overwritten. For images, the upload pipeline additionally generates the medium variant (quality 75) post-DB-save when IMAGE_EAGER_MEDIUM=true. high (90) and low (50) are generated lazily on first GET /media/{id}?variant=…. Variant generation is best-effort — failure here logs a warning but the upload itself succeeds with the original committed.
On any post-stream failure the staged blob is removed; the invariant "DB row ⇒ storage file exists" is preserved.
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
| folder_id | uuid | optional | Target folder. Omit for root. |
| visibility | string | optional | public | organization | private. Defaults to organization. (default: organization) |
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| file | file (multipart) | required | The file part. Filename is taken from the part's filename attribute. |
# multipart/form-data — uploads always need a JWT (the
# `visibility` knob only affects who can READ the file later,
# not who can upload).
# 1) Organization-scoped (default). Anyone in the same org as
# the uploader can download.
curl -X POST 'http://localhost:3001/upload?folder_id=4f2c…&visibility=organization' \
-H 'Authorization: Bearer <token>' \
-F 'file=@/path/to/photo.jpg'
# 2) Public. Anyone with the media id can download anonymously
# via GET /media/{id} — no token required on the read side.
# Useful for assets that are meant to be embedded on a
# public web page.
curl -X POST 'http://localhost:3001/upload?visibility=public' \
-H 'Authorization: Bearer <token>' \
-F 'file=@/path/to/banner.jpg'
# 3) Private. Only the uploader can download (and org admins
# if your deployment grants them override).
curl -X POST 'http://localhost:3001/upload?visibility=private' \
-H 'Authorization: Bearer <token>' \
-F 'file=@/path/to/secret.pdf'
{
"id": "9e1c3a82-3a1d-4f0c-9b71-3f7a1e8c0d11",
"filename": "9e1c3a82-3a1d-4f0c-9b71-3f7a1e8c0d11.jpg",
"original_name": "photo.jpg",
"mime_type": "image/jpeg",
"size_bytes": 248122,
"checksum": "sha256:7e1f…",
"visibility": "organization",
"folder_id": "4f2c…",
"created_at": "2026-04-30T08:14:22Z"
}
Membuat satu notifikasi dengan satu atau banyak recipient. Akan dipush ke provider sesuai provider_type (email/telegram/whatsapp/http/grpc/rabbitmq).
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| recipients | array<Recipient> | required | List recipient. Recipient = {type, address, name} |
| content | Content | required | {subject, body, is_html, html_body} |
| priority | string | optional | low | normal | high | urgent |
| provider_type | string | required | email | telegram | http | grpc | whatsapp | rabbitmq |
| metadata | map<string,string> | optional | |
| max_retries | int32 | optional | |
| scheduled_at | timestamp | optional | Untuk delayed delivery |
{
"notification_id": "ntf_01HXYZ...",
"status": "queued",
"created_at": "2026-05-13T21:00:00Z",
"message": "queued for delivery"
}
Kirim banyak notifikasi sekaligus dengan batch_id tunggal.
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| notifications | array<BulkNotificationItem> | required | Setiap item: {recipients, content, priority, provider_type} |
{
"batch_id": "btc_01HXYZ...",
"notification_ids": ["ntf_...", "ntf_..."],
"status": "queued",
"total_count": 2
}
Fetch detail satu notifikasi berdasarkan ID.
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| notification_id | string | required |
{
"id": "ntf_01HXYZ...",
"recipients": [{"type": "email", "address": "user@example.com"}],
"content": {"subject": "Welcome", "body": "Hi"},
"provider_type": "email",
"status": "delivered",
"priority": "normal",
"retry_count": 0,
"max_retries": 3,
"created_at": "2026-05-13T21:00:00Z",
"delivered_at": "2026-05-13T21:00:03Z"
}
Paginated history dengan filter optional (status, provider_type, date_from, date_to).
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| page | int32 | optional | ex: 1 |
| page_size | int32 | optional | ex: 20 |
| filters | map<string,string> | optional | status | provider_type | date_from | date_to |
{
"notifications": [ /* Notification[] */ ],
"total": 1234,
"page": 1,
"page_size": 20,
"total_pages": 62
}
Cancel notifikasi yang masih queued/processing.
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| notification_id | string | required |
{"success": true, "message": "cancelled"}
Retry notifikasi yang failed.
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| notification_id | string | required |
{"success": true, "message": "queued for retry"}
Aggregate counter per status + average delivery time.
{
"pending": 42,
"processing": 5,
"delivered": 9821,
"failed": 17,
"cancelled": 3,
"average_delivery_time": 1.43
}
Stats per queue (message_count, consumer_count, rate, avg_processing_time).
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| queue_name | string | optional | Kosongkan untuk semua queue |
Metadata queue (durable, auto_delete, arguments, created_at).
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| queue_name | string | required |
Consumer list per queue dengan ack/unack count, prefetch.
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| queue_name | string | required |
Detail satu message dari queue (payload, published_at, delivered_at, delivery_count).
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| message_id | string | required | |
| queue_name | string | required |
⚠️ Hapus semua message dari queue. Operasi destruktif — gunakan hanya untuk recovery atau testing.
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| queue_name | string | required |
Status keseluruhan service + dependency check per komponen.
{
"healthy": true,
"status": "ok",
"version": "1.0.0",
"timestamp": "2026-05-13T21:00:00Z",
"services": [
{"name": "rabbitmq", "healthy": true},
{"name": "mongodb", "healthy": true}
]
}
Returns 200 OK kalau service hidup dan dependencies (RabbitMQ + MongoDB) reachable.
{"status": "ok", "uptime_seconds": 604800}
📱 Lost device / session recovery
What to do when a user reports a lost or stolen device, or just wants to audit where they're signed in. Three patterns: review, revoke-one, revoke-all.
Step 1: Sign in from a trusted device
The user logs in from a device they still control. This issues a fresh access + refresh pair on a new session id. Existing sessions are not affected — the lost device's session is still alive at this point.
Service: account-service
Fields
| Field | Type | Required | Description |
|---|---|---|---|
| string | required | Account email. | |
| password | string | required | Account password. |
curl -X POST https://account.example.com/api/v1/auth/login \
-H 'Content-Type: application/json' \
-d '{ "email": "user@example.com", "password": "SecurePass123!" }'
Step 2: Review active sessions
Lists every active session on the account, with last-used IP and user-agent so the user can spot the rogue one. The session backing the current request is flagged with is_current: true.
Service: account-service
curl https://account.example.com/api/v1/me/sessions \
-H 'Authorization: Bearer eyJ...'
{
"success": true,
"data": {
"sessions": [
{ "session_id": "550e8400-...", "ip_address": "203.0.113.42", "user_agent": "Mozilla/5.0 (iPhone)", "created_at": 1735689600, "last_used_at": 1735693200, "expires_at": 1738281600, "is_current": false },
{ "session_id": "660e8400-...", "ip_address": "198.51.100.7", "user_agent": "Mozilla/5.0 (Macintosh)", "created_at": 1735693300, "last_used_at": 1735693300, "expires_at": 1738285200, "is_current": true }
]
}
}
Step 3: Revoke just the lost device's session (option A)
Targeted revoke. Pass the suspected session_id from the list. The next time that device tries to refresh its access token, it will get 4003 Session has been revoked or expired and be forced back to the login screen.
Service: account-service
curl -X DELETE https://account.example.com/api/v1/me/sessions/550e8400-... \
-H 'Authorization: Bearer eyJ...'
{ "success": true, "data": null }
Step 4: Or revoke EVERYTHING (option B — paranoid)
Sweep every active session for the account, including the one making the request. Use when the user can't tell which session is the rogue one, or after a credential leak. The browser cookies are also cleared on the response.
After this, the user is logged out everywhere — they'll need to re-login on each device they actually want to keep.
Service: account-service
curl -X POST https://account.example.com/api/v1/auth/logout-all \
-H 'Authorization: Bearer eyJ...'
{ "success": true, "data": { "success": true } }
Next Steps
Endpoint: #change-password
🚀 Onboard a new tenant
End-to-end flow from a brand-new email address to a working multi-member organization. Covers registration, email verification, tenant org creation, and inviting the first teammate.
Step 1: Register
Creates a human account + a personal organization (the user is the owner) in a single transaction. Returns access + refresh tokens immediately — the user is auto-logged-in. A verification email is queued via the email.requested event.
Service: account-service
Fields
| Field | Type | Required | Description |
|---|---|---|---|
| string | required | Unique across all accounts. | |
| password | string | required | Min 8, must include upper/lower/digit/special. |
| display_name | string | required | Used for the personal org name. |
| timezone | string | optional | IANA, default UTC. |
| language | string | optional | ISO 639-1, default en. |
curl -X POST https://account.example.com/api/v1/auth/register \
-H 'Content-Type: application/json' \
-d '{
"email": "user@example.com",
"password": "SecurePass123!",
"display_name": "Jane Doe",
"timezone": "Asia/Jakarta",
"language": "id"
}'
{
"success": true, "code": 2010,
"data": {
"account_id": "550e8400-...",
"email": "user@example.com",
"access_token": "eyJ...",
"refresh_token": "eyJ...",
"expires_in": 900,
"personal_org": { "org_id": "550e8400-...", "name": "Jane Doe's Personal", "slug": "jane-doe-personal-550e8400", "my_role": "owner", "my_permissions": ["*"], "...": "..." }
}
}
Step 2: Verify email (out-of-band)
The user clicks the link in the verification email. Account service hosts the verification page itself at GET /verify-email (HTML, outside /api/v1). Once verified, the user's NEXT issued token (login, register, or refresh) carries permissions: ["*"] instead of the read-only fallback. Existing tokens issued before verification keep their reduced perms until refreshed.
If the email never arrived:
Service: account-service
Fields
| Field | Type | Required | Description |
|---|---|---|---|
| string | required | Email to resend to. Always returns success regardless of existence. |
curl -X POST https://account.example.com/api/v1/auth/resend-verification \
-H 'Content-Type: application/json' \
-d '{ "email": "user@example.com" }'
{ "success": true, "data": { "message": "If the account exists and is unverified, a new verification email has been sent." } }
Step 3: Refresh after verification
To upgrade an in-flight session from read-only to full perms without forcing the user to re-login, hit /auth/refresh. The new access token will reflect the now-verified status.
Service: account-service
Fields
| Field | Type | Required | Description |
|---|---|---|---|
| refresh_token | string | optional | Falls back to refresh_token cookie when omitted. |
curl -X POST https://account.example.com/api/v1/auth/refresh \
-H 'Content-Type: application/json' \
-d '{ "refresh_token": "eyJ..." }'
{
"success": true,
"data": {
"access_token": "eyJ...new_with_full_perms...",
"refresh_token": "eyJ...",
"expires_in": 900,
"...": "..."
}
}
Step 4: Create a team org (optional)
The personal org is private to the user. To collaborate, create a shared org. The caller becomes its owner.
Service: account-service
Fields
| Field | Type | Required | Description |
|---|---|---|---|
| name | string | required | Display name. |
| slug | string | optional | URL-safe; auto-derived if omitted. |
curl -X POST https://account.example.com/api/v1/organizations \
-H 'Authorization: Bearer eyJ...' \
-H 'Content-Type: application/json' \
-d '{ "name": "Acme Corp", "slug": "acme-corp" }'
{
"success": true,
"data": { "org_id": "550e8400-...", "name": "Acme Corp", "slug": "acme-corp", "owner_id": "550e8400-...", "status": "active", "...": "..." }
}
Step 5: Invite first teammate
Sends an invitation email. Recipient accepts via the email link (which lives on the frontend; account-service emits the email.requested event with the token). Requires members:invite permission in the org.
Service: account-service
Fields
| Field | Type | Required | Description |
|---|---|---|---|
| string | required | Invitee email. | |
| role_id | string | optional | Assign on accept; defaults to org default member role. |
curl -X POST https://account.example.com/api/v1/organizations/550e8400-.../members \
-H 'Authorization: Bearer eyJ...' \
-H 'Content-Type: application/json' \
-d '{ "email": "teammate@example.com", "role_id": "550e8400-..." }'
{ "success": true, "data": { "invitation_id": "550e8400-...", "message": "Invitation sent to teammate@example.com" } }
Step 6: Switch to the new org (when ready)
The invitation creator's tokens still point at their personal org. Switching reissues the token bound to the team org without re-login. Session id is preserved across the switch.
Service: account-service
curl -X POST https://account.example.com/api/v1/organizations/550e8400-.../switch \
-H 'Authorization: Bearer eyJ...'
{
"success": true,
"data": { "access_token": "eyJ...new...", "refresh_token": "eyJ...new...", "current_org_id": "550e8400-...", "...": "..." }
}
🔑 Password reset (forgot password)
End-to-end password reset for users who can't log in. Two endpoints and one out-of-band email step. Both endpoints always return success to prevent account enumeration — meaningful failures (invalid token, weak password) are surfaced only on the confirm call when the user actually has a valid token in hand.
Step 1: Request the reset
Generates a 1-hour JWT carrying permissions: ["password:reset"] and emits the email.requested event. The token's hash is stored in Redis so it can only be used once.
Always returns success regardless of whether the email exists — checking the existence here would leak account membership to anyone who can reach the endpoint.
Service: account-service
Fields
| Field | Type | Required | Description |
|---|---|---|---|
| string | required | Account email. |
curl -X POST https://account.example.com/api/v1/auth/password-reset \
-H 'Content-Type: application/json' \
-d '{ "email": "user@example.com" }'
{ "success": true, "data": null }
Step 2: User clicks link in email (out-of-band)
The email contains a frontend URL with the token as a query parameter (or fragment, depending on frontend convention). The frontend renders a "set new password" form. Account-service itself also hosts a fallback HTML form at GET /reset-password (outside /api/v1) for out-of-the-box use.
Token TTL is 1 hour. After expiry the user must restart from step 1.
Step 3: Confirm with new password
Validates the token (signature + jti not previously used + not expired), updates the password hash, and revokes every active session for the account so any in-flight access tokens stop validating immediately. The user must log in again with the new password.
Service: account-service
Fields
| Field | Type | Required | Description |
|---|---|---|---|
| token | string | required | JWT from the email link. |
| new_password | string | required | Same complexity rules as registration. |
curl -X POST https://account.example.com/api/v1/auth/password-reset/confirm \
-H 'Content-Type: application/json' \
-d '{
"token": "eyJ...reset_token...",
"new_password": "NewSecurePass456!"
}'
{ "success": true, "data": { "success": true } }
Next Steps
Endpoint: #login
🤖 Service-to-service authentication
How a non-human consumer (job runner, ML pipeline, internal automation) authenticates against your APIs. Covers creating a service account, minting an API key, exchanging it for a JWT, and revoking on leak.
Step 1: Create the service account
A service account lives inside one organization and has capabilities (e.g. museum:read, artifact:write) that bound what its keys may grant. Requires service_accounts:create in the target org.
Service: account-service
Fields
| Field | Type | Required | Description |
|---|---|---|---|
| organization_id | string | required | Org to attach to. |
| display_name | string | required | Human-readable name. |
| description | string | optional | Optional. |
| capabilities | string[] | required | resource:action format. |
| allowed_tools | string[] | required | Subset of tool ids the service may invoke. |
| rate_limit_per_minute | integer | optional | 1-10000, default 60. |
curl -X POST https://account.example.com/api/v1/service-accounts \
-H 'Authorization: Bearer eyJ...' \
-H 'Content-Type: application/json' \
-d '{
"organization_id": "550e8400-...",
"display_name": "Render bot",
"capabilities": ["museum:read", "artifact:write"],
"allowed_tools": ["thumbnailer"],
"rate_limit_per_minute": 120
}'
{ "success": true, "data": { "account_id": "550e8400-...", "status": "active", "capabilities": ["museum:read","artifact:write"], "...": "..." } }
Step 2: Mint an API key
The plaintext key is returned only here, only once — store it client-side immediately (e.g. in a secret manager). Subsequent list/get calls expose only key_prefix for identification. Requires api_keys:create.
Service: account-service
Fields
| Field | Type | Required | Description |
|---|---|---|---|
| name | string | optional | Free-form label. |
| permissions | string[] | required | Subset of the service account's capabilities. |
| expires_in_days | integer | optional | Omit for no expiry. |
curl -X POST https://account.example.com/api/v1/service-accounts/550e8400-.../api-keys \
-H 'Authorization: Bearer eyJ...' \
-H 'Content-Type: application/json' \
-d '{
"name": "prod render bot",
"permissions": ["museum:read"],
"expires_in_days": 180
}'
{
"success": true,
"data": {
"api_key_id": "550e8400-...",
"key": "ak_live_a1b2c3d4...REDACTED...",
"key_prefix": "ak_live_a1b2",
"name": "prod render bot",
"permissions": ["museum:read"],
"expires_at": 1751155200,
"created_at": 1735689600
}
}
Step 3: Use the key directly (option A)
Cheapest integration: send X-API-Key on every protected request. The middleware accepts it identically to a Bearer JWT. Suitable for low-throughput automations.
Service: account-service
curl https://account.example.com/api/v1/me \
-H 'X-API-Key: ak_live_a1b2c3d4...'
{ "success": true, "data": { "account_id": "550e8400-...", "account_type": "service", "display_name": "Render bot", "...": "..." } }
Step 4: Or exchange for a JWT (option B — recommended)
For high-throughput callers, swap the key for a 1-hour service JWT and validate locally with /auth/public-key. Reduces account-service load from per-request to ~once an hour per pod. The JWT carries principal_type: service and sid: <nil-uuid>.
Service: account-service
curl -X POST https://account.example.com/api/v1/auth/token-exchange \
-H 'X-API-Key: ak_live_a1b2c3d4...'
{
"success": true,
"data": {
"access_token": "eyJ...service_jwt...",
"token_type": "Bearer",
"expires_in": 3600,
"account_id": "550e8400-...",
"organization_id": "550e8400-...",
"permissions": ["museum:read"],
"principal_type": "service"
}
}
Step 5: Call downstream services with the JWT
Downstream services fetch /auth/public-key once, cache by kid, then verify the JWT locally on every request — no roundtrip to account-service per call. The permissions claim is derived from the API key's permissions at exchange time.
Service: downstream-service
curl https://media.example.com/v1/artifacts/123 \
-H 'Authorization: Bearer eyJ...service_jwt...'
Step 6: Revoke on leak
When a key leaks (committed to a public repo, posted in a Slack channel, etc.), revoke immediately. The key continues to exist for audit/forensics but stops authenticating. Use DELETE to hard- remove (loses audit trail).
Service: account-service
Fields
| Field | Type | Required | Description |
|---|---|---|---|
| reason | string | optional | Recorded on the audit log. |
curl -X POST https://account.example.com/api/v1/api-keys/550e8400-.../revoke \
-H 'Authorization: Bearer eyJ...' \
-H 'Content-Type: application/json' \
-d '{ "reason": "leaked in public repo" }'
{ "success": true, "data": null }
📡 Email Requested
Emitted when the service needs an outbound email — verification link on registration, resent verification, or password reset. The email service consumes this event and renders/sends the actual mail. Account service never talks to SMTP directly.
amqpexchange: account.events / routing key: email.requestedpublish Outbound email request
One event per outbound mail. The email service is the only documented subscriber today.
Payload
| Field | Type | Required | Description |
|---|---|---|---|
event_type |
string | yes | Always 'email.requested' for this channel. |
to |
string | yes | Recipient email address. |
template |
string | yes | One of: email_verification, password_reset. |
display_name |
string | no | Used for greeting line. |
token |
string | yes | Verification or reset token (raw — hash is what's stored server-side). |
base_url |
string | yes | Frontend base URL used to construct the click-through link. |
is_resend |
bool | no | Only set for verification template; false on first send. |
Example
{
"event_type": "email.requested",
"to": "user@example.com",
"template": "email_verification",
"display_name": "Jane Doe",
"token": "8x...redacted...",
"base_url": "https://app.example.com",
"is_resend": false
}
📡 notification.events.queue
Primary inbound queue. Service lain publish event ke exchange notification.requests dengan routing key sesuai provider (email.send, telegram.send, whatsapp.send, http.send). Notification service consume dari binding queue ini lalu memproses sesuai provider_type.
amqpnotification.events.queue (bound to exchange `notification.requests`)subscribe Notification service consumes events
At-least-once delivery. Service deduplicate berdasarkan id field. Failed delivery di-retry sampai max_retries (default 3).
Payload
| Field | Type | Required | Description |
|---|---|---|---|
id |
string | yes | Unique event ID untuk dedup |
source_service |
string | yes | Nama service yang publish (e.g. account-service) |
timestamp |
string | yes | ISO 8601 |
recipient |
object | yes | {email, name, telegram_chat_id, whatsapp_number} |
content |
object | yes | {subject, body, template_data} |
provider_type |
string | yes | email | telegram | whatsapp | http | grpc | rabbitmq |
provider_name |
string | yes | |
metadata |
map<string,string> | no | |
created_at |
string | yes |
Example
{
"id": "evt-user-reg-1700000000",
"source_service": "account-service",
"timestamp": "2026-05-13T21:00:00Z",
"recipient": {
"email": "user@example.com",
"name": "John"
},
"content": {
"subject": "Welcome to ikavia",
"body": "Hi John, your account is ready"
},
"provider_type": "email",
"provider_name": "smtp_email",
"metadata": {
"user_id": "acc_01HX...",
"event": "user_registered"
},
"created_at": "2026-05-13T21:00:00Z"
}
📡 notification.outbound
Exchange tempat notification service mempublish hasil pemrosesan (status update, audit log) untuk dikonsumsi service lain.
amqpnotification.outbound (topic exchange)publish Notification service publishes delivery results
Routing key bentuk notification.<status>.<provider> misal notification.delivered.email. Subscriber bisa bind ke wildcard (e.g. notification.failed.*).
Payload
| Field | Type | Required | Description |
|---|---|---|---|
notification_id |
string | yes | |
status |
string | yes | delivered | failed | cancelled | retrying |
provider_type |
string | yes | |
attempt |
int | yes | |
error |
string | no | Hanya ada saat status=failed |
timestamp |
string | yes |
Example
{
"notification_id": "ntf_01HX...",
"status": "delivered",
"provider_type": "email",
"attempt": 1,
"timestamp": "2026-05-13T21:00:03Z"
}
🔐 Credentials
Konfigurasi credentials untuk testing API
Cara Menggunakan
🔐 Authentication
Methods and permissions for API access
Authentication Methods
JWT Bearer
Header: Authorization: Bearer <access_token>
Source: account-service
Short-lived access token (default 15 min) issued by /auth/login, /auth/register, /auth/refresh, or /auth/token-exchange. RS256-signed; verifiers can fetch the public key from /auth/public-key.
Note: Refresh token (default 30 days) is sent separately as an HttpOnly cookie or in the /auth/refresh body.
API Key
Header: X-API-Key: <api_key>
Source: account-service (`POST /service-accounts/{account_id}/api-keys`)
Long-lived credential bound to a service account. Send as X-API-Key header on any protected endpoint, or call /auth/token-exchange once an hour to swap it for a JWT (cheaper than per-request key lookup).
Note: Plaintext is returned only at creation time; only a sha-256 hash is stored server-side.
JWT
Header: Authorization: Bearer <token>
Source: Account Service
RS256-signed JWT issued by the Account Service. The signature is verified against JWT_PUBLIC_KEY_PATH (defaults to public_key.pem in the working directory). Optional dynamic key rotation is supported via ACCOUNT_SERVICE_URL.
Permissions
| Permission | Description |
|---|---|
* |
Wildcard — all permissions. Carried by verified human access tokens. Membership/role grants in the org also use * for the system 'owner' role. |
read:profile |
Read your own account profile. Issued on tokens for unverified-email users. |
read:organizations |
List the organizations you belong to. Issued on tokens for unverified-email users. |
read:sessions |
List your own active sessions. Issued on tokens for unverified-email users. |
members:invite |
POST /organizations/{org_id}/members. |
members:remove |
DELETE /organizations/{org_id}/members/{account_id}. |
members:update_role |
PUT /organizations/{org_id}/members/{account_id}/role. |
invitations:cancel |
Cancel a pending invitation (gRPC only today). |
organizations:update |
PUT /organizations/{org_id}. |
roles:create |
POST /organizations/{org_id}/roles. |
roles:update |
PUT /organizations/{org_id}/roles/{role_id}. |
roles:delete |
DELETE /organizations/{org_id}/roles/{role_id}. |
service_accounts:create |
POST /service-accounts. |
service_accounts:read |
GET /service-accounts and GET /service-accounts/{id}. |
service_accounts:update |
PUT /service-accounts/{id}. |
service_accounts:archive |
DELETE /service-accounts/{id}. |
service_accounts:pause |
POST /service-accounts/{id}/pause. |
service_accounts:resume |
POST /service-accounts/{id}/resume. |
api_keys:create |
POST /service-accounts/{id}/api-keys. |
api_keys:read |
GET /service-accounts/{id}/api-keys. |
api_keys:revoke |
POST /api-keys/{id}/revoke. |
api_keys:delete |
DELETE /api-keys/{id}. |
audit_logs:read |
GET /organizations/{org_id}/activities. The /me/activities feed is filtered to the calling user and needs no extra permission. |
password:reset |
Embedded only in the JWT issued by /auth/password-reset (1 h TTL). Not user-grantable. |
media:upload |
Upload new media files. |
media:download |
Download media files; required to create shared links. |
media:list |
List media; gates recent and starred listings. |
media:update |
Rename media or change its visibility. |
media:delete |
Delete media. |
folder:create |
Create folders. |
folder:list |
List folders and folder contents. |
folder:update |
Rename or move folders. |
folder:delete |
Delete folders (set force=true to delete recursively). |