📋 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-exchange to swap a key for a 1-hour JWT and avoid per-request key validation.

🏢 Organization-scoped

  • On login the user's default_organization_id becomes the current org.
  • Use POST /organizations/{org_id}/switch to 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": { ... } }

  • code is omitted (or absent) on most happy paths and is filled in for created (2010) and a few special cases.
  • data carries the endpoint's actual payload.

Failure responses: json { "success": false, "code": 4003, "message": "...", "trace_id": "..." }

  • code follows the error-code table below — always check it before falling back to HTTP status.
  • trace_id is 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 on account.example.com is honoured by app.example.com.
  • The login response body also carries access_token and refresh_token for 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 (default 1).
  • per_page — page size (default 20, hard cap 100).

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 from utoipa annotations 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 against openapi.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

1
Register or log in

POST /auth/register (new user) or POST /auth/login (existing). Both return access_token, refresh_token, and an HttpOnly refresh cookie.

2
Attach the access token

Send Authorization: Bearer <access_token> on every protected request. CSRF middleware does not apply when this header is present.

3
Rotate before expiry

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.

4
Switch organization (optional)

POST /organizations/{org_id}/switch reissues a token bound to the new org. The session id is preserved.

5
Log out

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

1
Create a key

Owner of the org calls POST /service-accounts/{account_id}/api-keys. The plaintext key is returned ONCE — store it client-side immediately.

2
Send X-API-Key on each call

Set X-API-Key: <key> as a header. The middleware accepts it on any protected endpoint.

3
Or exchange for a JWT (recommended)

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.

4
Revoke when leaked

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

1
Authenticate against Account Service

Out of scope for this service. Obtain a JWT from the configured Account Service login endpoint.

2
Attach the token on every protected request

Send Authorization: Bearer <token> on every endpoint except /health and /share/:token.

3
Permissions are claim-checked per route

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

GET /api/v1/me

Return the authenticated principal's profile (account details + email verification status). Works for both human and service principals.

Example Response
{
  "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
  }
}
PUT /api/v1/me

Patch the authenticated user's profile. Omitted fields are left untouched. model_config and capabilities apply to service accounts only.

Request Body Fields

FieldTypeRequiredDescription
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).
Example Request Body
{
  "display_name": "Jane D.",
  "bio": "Building things.",
  "timezone": "Asia/Singapore",
  "language": "en"
}
Example Response
{ "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 } }
POST /api/v1/me/password

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

FieldTypeRequiredDescription
current_password string required Existing password (re-verified server-side).
new_password string required New password — same complexity rules as registration.
Example Request Body
{
  "current_password": "OldPass123!",
  "new_password": "NewSecurePass456!"
}
Example Response
{ "success": true, "data": { "success": true } }
GET /api/v1/me/sessions

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).

Example Response
{
  "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
      }
    ]
  }
}
DELETE /api/v1/me/sessions/{session_id}

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}.

Example Response
{ "success": true, "data": null }
GET /api/v1/me/activities

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.accessed
  • auth.login, auth.login_failed, auth.logout,

auth.token_refreshed, auth.password_changed, auth.password_reset

  • member.invited, member.removed
  • invitation.cancelled
  • service_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

ParamTypeRequiredDescription
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.
Example Response
{
  "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
  }
}
POST /api/v1/auth/register

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

FieldTypeRequiredDescription
email 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
Example Request Body
{
  "email": "user@example.com",
  "password": "SecurePass123!",
  "display_name": "Jane Doe",
  "timezone": "Asia/Jakarta",
  "language": "id"
}
Example Response
{
  "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": ["*"]
    }
  }
}
POST /api/v1/auth/login

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

FieldTypeRequiredDescription
email string required Account email.
password string required Account password.
Example Request Body
{
  "email": "user@example.com",
  "password": "SecurePass123!"
}
Example Response
{
  "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..."
  }
}
POST /api/v1/auth/refresh

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

FieldTypeRequiredDescription
refresh_token string optional Optional in body. Falls back to the refresh_token cookie when omitted.
Example Request Body
{ "refresh_token": "eyJhbGciOiJSUzI1NiIs..." }
Example Response
{
  "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": "..."
  }
}
POST /api/v1/auth/logout

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.

Example Response
{ "success": true, "data": { "success": true } }
POST /api/v1/auth/logout-all

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.

Example Response
{ "success": true, "data": { "success": true } }
POST /api/v1/auth/resend-verification

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

FieldTypeRequiredDescription
email string required Email to resend verification to.
Example Request Body
{ "email": "user@example.com" }
Example Response
{ "success": true, "data": { "message": "If the account exists and is unverified, a new verification email has been sent." } }
POST /api/v1/auth/password-reset

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

FieldTypeRequiredDescription
email string required Email of the account to reset.
Example Request Body
{ "email": "user@example.com" }
Example Response
{ "success": true, "data": null }
POST /api/v1/auth/password-reset/confirm

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

FieldTypeRequiredDescription
token string required Reset token from the email link (JWT).
new_password string required Same complexity rules as registration.
Example Request Body
{
  "token": "eyJhbGciOiJSUzI1NiIs...",
  "new_password": "NewSecurePass456!"
}
Example Response
{ "success": true, "data": { "success": true } }
POST /api/v1/auth/verify

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

FieldTypeRequiredDescription
token string required Access token to introspect.
Example Request Body
{ "token": "eyJhbGciOiJSUzI1NiIs..." }
Example Response
{
  "success": true,
  "data": {
    "valid": true,
    "account_id": "550e8400-...",
    "organization_id": "550e8400-...",
    "permissions": ["*"],
    "expires_at": 1735693200,
    "session_id": "550e8400-..."
  }
}
GET /api/v1/auth/whoami

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.

Example Response
{
  "success": true,
  "data": {
    "account_id": "550e8400-...",
    "principal_type": "human",
    "session_id": "550e8400-...",
    "current_org_id": "550e8400-...",
    "organizations": [ { "id": "550e8400-...", "role": "owner" } ],
    "permissions": ["*"]
  }
}
GET /api/v1/auth/public-key

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 kid claim 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_at is the rough cache horizon — refresh before that

even without a kid mismatch to pick up scheduled rotations.

Example Response
{
  "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
  }
}
POST /api/v1/auth/token-exchange

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.

Example Response
{
  "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"
  }
}
GET /health

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.

Example Response
{ "status": "ok" }
GET /health/detailed

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.

Example Response
{
  "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
}
GET /metrics

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.

Example Response
# HELP http_requests_total Total HTTP requests
# TYPE http_requests_total counter
http_requests_total{method="GET",path="/api/v1/me",status="200"} 142
...
GET /api/v1/organizations/{org_id}/check

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.

Example Response
{
  "success": true,
  "data": {
    "org_id": "550e8400-...",
    "name": "Acme Corp",
    "created_at": 1735689600
  }
}
GET /api/v1/organizations

List organizations the authenticated principal is a member of. Each entry includes the principal's role in that org.

Query Parameters

ParamTypeRequiredDescription
page integer optional 1-indexed page number. (default: 1)
per_page integer optional Page size, max 100. (default: 20)
Example Response
{
  "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
  }
}
POST /api/v1/organizations

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

FieldTypeRequiredDescription
name string required Display name of the organization.
slug string optional URL-safe slug. Auto-generated from name when omitted.
Example Request Body
{ "name": "Acme Corp", "slug": "acme-corp" }
Example Response
{
  "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
  }
}
GET /api/v1/organizations/{org_id}

Fetch a single organization by id. Caller must be a member.

Example Response
{ "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 } }
PUT /api/v1/organizations/{org_id}

Update the organization's name or slug.

Request Body Fields

FieldTypeRequiredDescription
name string optional New display name.
slug string optional New URL-safe slug.
Example Request Body
{ "name": "Acme Inc.", "slug": "acme-inc" }
Example Response
{ "success": true, "data": { "org_id": "...", "name": "Acme Inc.", "slug": "acme-inc", "status": "active", "owner_id": "...", "created_at": 1735689600, "updated_at": 1735693200, "member_count": 12 } }
DELETE /api/v1/organizations/{org_id}

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.

Example Response
{ "success": true, "data": null }
POST /api/v1/organizations/{org_id}/switch

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.

Example Response
{
  "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..."
  }
}
POST /api/v1/organizations/{org_id}/leave

Remove the caller's own membership. Owners cannot leave — transfer ownership first or delete the organization.

Example Response
{ "success": true, "data": null }
GET /api/v1/organizations/{org_id}/members

Paged member list with role + permissions.

Query Parameters

ParamTypeRequiredDescription
page integer optional 1-indexed page. (default: 1)
per_page integer optional Page size, max 100. (default: 20)
Example Response
{
  "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
  }
}
POST /api/v1/organizations/{org_id}/members

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

FieldTypeRequiredDescription
email string required Invitee email.
role_id string optional Role id to assign on accept. Defaults to org default member role.
Example Request Body
{ "email": "newmember@example.com", "role_id": "550e8400-..." }
Example Response
{
  "success": true,
  "data": {
    "invitation_id": "550e8400-...",
    "message": "Invitation sent to newmember@example.com"
  }
}
DELETE /api/v1/organizations/{org_id}/members/{account_id}

Remove a member from the org. Cannot remove the owner.

Example Response
{ "success": true, "data": null }
PUT /api/v1/organizations/{org_id}/members/{account_id}/role

Reassign a member to a different role. New role takes effect on their next token rotation.

Request Body Fields

FieldTypeRequiredDescription
role_id string required Target role id.
Example Request Body
{ "role_id": "550e8400-..." }
Example Response
{ "success": true, "data": null }
GET /api/v1/organizations/{org_id}/roles

List roles defined in the org (system + custom).

Example Response
{
  "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
  }
}
POST /api/v1/organizations/{org_id}/roles

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

FieldTypeRequiredDescription
name string required Display name (unique within the org).
description string optional Optional description.
permissions string[] required Permission strings granted by this role.
Example Request Body
{
  "name": "auditor",
  "description": "Read-only access to audit logs",
  "permissions": ["read:organizations", "audit_logs:read"]
}
Example Response
{ "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 } }
GET /api/v1/organizations/{org_id}/roles/{role_id}

Fetch a single role.

Example Response
{ "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 } }
PUT /api/v1/organizations/{org_id}/roles/{role_id}

Patch a role. System roles (e.g. owner) are read-only.

Request Body Fields

FieldTypeRequiredDescription
name string optional New name.
description string optional New description (pass null to clear).
permissions string[] optional New permissions list (replaces, not merges).
Example Request Body
{ "permissions": ["read:organizations", "audit_logs:read", "members:invite"] }
Example Response
{ "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 /api/v1/organizations/{org_id}/roles/{role_id}

Delete a custom role. Fails if any member is currently assigned to it — reassign them first. System roles cannot be deleted.

Example Response
{ "success": true, "data": null }
GET /api/v1/organizations/{org_id}/activities

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

ParamTypeRequiredDescription
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.
Example Response
{
  "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
  }
}
GET /api/v1/service-accounts

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

ParamTypeRequiredDescription
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)
Example Response
{
  "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
  }
}
POST /api/v1/service-accounts

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

FieldTypeRequiredDescription
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.
Example Request Body
{
  "organization_id": "550e8400-...",
  "display_name": "Render bot",
  "description": "Generates thumbnail previews",
  "capabilities": ["museum:read", "artifact:write"],
  "allowed_tools": ["thumbnailer"],
  "rate_limit_per_minute": 120
}
Example Response
{
  "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
  }
}
GET /api/v1/service-accounts/{account_id}

Fetch one service account by id.

Example Response
{
  "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 }
}
PUT /api/v1/service-accounts/{account_id}

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

FieldTypeRequiredDescription
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.
Example Request Body
{
  "organization_id": "550e8400-...",
  "display_name": "Render bot v2",
  "rate_limit_per_minute": 240
}
Example Response
{ "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 } }
DELETE /api/v1/service-accounts/{account_id}

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.

Example Response
{ "success": true, "data": null }
POST /api/v1/service-accounts/{account_id}/pause

Temporarily disable a service account. API keys stop authenticating until resumed; metadata and capabilities are preserved.

Example Response
{ "success": true, "data": null }
POST /api/v1/service-accounts/{account_id}/resume

Re-enable a paused service account.

Example Response
{ "success": true, "data": null }
GET /api/v1/service-accounts/{account_id}/api-keys

List metadata for every key on a service account. The plaintext is never returned here — only key_prefix for identification.

Query Parameters

ParamTypeRequiredDescription
page integer optional 1-indexed page. (default: 1)
per_page integer optional Page size, max 100. (default: 20)
Example Response
{
  "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
  }
}
POST /api/v1/service-accounts/{account_id}/api-keys

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

FieldTypeRequiredDescription
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.
Example Request Body
{
  "name": "prod render bot",
  "permissions": ["museum:read"],
  "expires_in_days": 180
}
Example Response
{
  "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
  }
}
POST /api/v1/api-keys/{api_key_id}/revoke

Mark a key as revoked. The key continues to exist for audit purposes but stops authenticating immediately.

Request Body Fields

FieldTypeRequiredDescription
reason string optional Optional human-readable reason recorded on the audit log.
Example Request Body
{ "reason": "leaked in public repo" }
Example Response
{ "success": true, "data": null }
DELETE /api/v1/api-keys/{api_key_id}

Permanently remove a key (hard delete). Prefer revoke for auditable history.

Example Response
{ "success": true, "data": null }
POST /folders

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

FieldTypeRequiredDescription
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.
Example Request Body
{
  "name": "Vacation Photos",
  "parent_id": "4f2c…",
  "visibility": "organization"
}
Example Response
{
  "id": "f9a1…",
  "name": "Vacation Photos",
  "parent_id": "4f2c…",
  "path": "Documents/Vacation Photos",
  "depth": 2,
  "created_at": "2026-04-30T08:14:22Z"
}
GET /folders

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

ParamTypeRequiredDescription
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)
PATCH /folders/{id}

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

FieldTypeRequiredDescription
name string required New folder name.
Example Request Body
{ "name": "Archive 2026" }
GET /folders/{id}/contents

Returns the immediate children: subfolders and media files in a single paginated response. Useful for a Drive-style file browser UI.

Query Parameters

ParamTypeRequiredDescription
page int optional 1-indexed page number. (default: 1)
per_page int optional Items per page. (default: 20)
DELETE /folders/{id}

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

ParamTypeRequiredDescription
force bool optional Recursively delete every file and subfolder in the subtree. (default: false)
Example Response
{
  "deleted": true,
  "folder_id": "f9a1…",
  "deleted_items": { "folders": 3, "media": 17 }
}
GET /health

Returns a static JSON payload indicating the service process is up. Does not check downstream dependencies (DB, storage).

Example Response
{
  "status": "healthy",
  "service": "media-service"
}
GET /media

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

ParamTypeRequiredDescription
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)
Example Response
{
  "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 }
}
GET /media/{id}

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

ParamTypeRequiredDescription
variant string optional original (default) | high | medium | low. Aliases: orig/raw, hi, med, lo. (default: original)
GET /media/{id}/info

Returns the metadata only (no bytes). Useful for clients that want size/MIME/checksum without paying for the download.

PATCH /media/{id}

Updates the user-facing original_name. The on-disk filename is unchanged; only the display name is rewritten.

Request Body Fields

FieldTypeRequiredDescription
original_name string required New display name. Client should preserve the extension.
Example Request Body
{ "original_name": "vacation-2026-04.jpg" }
PATCH /media/{id}/visibility

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

FieldTypeRequiredDescription
visibility string required public | organization | private.
Example Request Body
{ "visibility": "private" }
DELETE /media/{id}

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.

Example Response
{
  "deleted": true,
  "media_id": "9e1c…",
  "filename": "9e1c….jpg",
  "freed_bytes": 248122
}
GET /recent

Returns the caller's most recently accessed media, newest-first. Capped at the value the client requests (default applied server-side).

Query Parameters

ParamTypeRequiredDescription
limit int optional Max items to return; server applies a sane upper bound.
POST /media/{id}/track

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

FieldTypeRequiredDescription
access_type string optional Free-form classifier (e.g. view, download, preview).
Example Request Body
{ "access_type": "view" }
DELETE /recent

Wipes the caller's entire recent-files history. The underlying media files are untouched.

POST /media/{id}/share

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

FieldTypeRequiredDescription
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.
Example Request Body
{
  "visibility": "public",
  "password": "spring-2026",
  "expires_in_hours": 168,
  "max_downloads": 50
}
Example Response
{
  "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"
}
GET /share/{token}

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

ParamTypeRequiredDescription
password string optional Required when the link was created with a password.
GET /shared-links

Lists shared links the caller created. Paginated.

Query Parameters

ParamTypeRequiredDescription
page int optional 1-indexed page number. (default: 1)
per_page int optional Items per page. (default: 20)
DELETE /shared-links/{id}

Revokes a shared link. Subsequent GET /share/:token requests return 404. The underlying media file is unaffected.

GET /starred

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

ParamTypeRequiredDescription
page int optional 1-indexed page number. (default: 1)
per_page int optional Items per page. (default: 20)
POST /media/{id}/star

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

FieldTypeRequiredDescription
notes string optional Optional annotation, surfaced in the listing.
Example Request Body
{ "notes": "Reference for the launch deck" }
DELETE /media/{id}/star

Removes the star. Idempotent — unstarring an already-unstarred file returns success.

POST /media/{id}/toggle-star

Convenience endpoint that flips the star state. Useful for UIs that bind a single button to "star/unstar" without tracking current state client-side.

POST /starred/check

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

FieldTypeRequiredDescription
media_ids array<string> required Up to N media UUIDs to check at once.
Example Request Body
{ "media_ids": ["9e1c…", "f9a1…", "ab12…"] }
Example Response
{
  "items": [
    { "media_id": "9e1c…", "is_starred": true },
    { "media_id": "f9a1…", "is_starred": false },
    { "media_id": "ab12…", "is_starred": true }
  ]
}
POST /upload

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

ParamTypeRequiredDescription
folder_id uuid optional Target folder. Omit for root.
visibility string optional public | organization | private. Defaults to organization. (default: organization)

Request Body Fields

FieldTypeRequiredDescription
file file (multipart) required The file part. Filename is taken from the part's filename attribute.
Example Request Body
# 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'
Example Response
{
  "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"
}
gRPC /notification.NotificationService/CreateNotification

Membuat satu notifikasi dengan satu atau banyak recipient. Akan dipush ke provider sesuai provider_type (email/telegram/whatsapp/http/grpc/rabbitmq).

Request Body Fields

FieldTypeRequiredDescription
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
Example Response
{
  "notification_id": "ntf_01HXYZ...",
  "status": "queued",
  "created_at": "2026-05-13T21:00:00Z",
  "message": "queued for delivery"
}
gRPC /notification.NotificationService/CreateBulkNotification

Kirim banyak notifikasi sekaligus dengan batch_id tunggal.

Request Body Fields

FieldTypeRequiredDescription
notifications array<BulkNotificationItem> required Setiap item: {recipients, content, priority, provider_type}
Example Response
{
  "batch_id": "btc_01HXYZ...",
  "notification_ids": ["ntf_...", "ntf_..."],
  "status": "queued",
  "total_count": 2
}
gRPC /notification.NotificationService/GetNotification

Fetch detail satu notifikasi berdasarkan ID.

Request Body Fields

FieldTypeRequiredDescription
notification_id string required
Example Response
{
  "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"
}
gRPC /notification.NotificationService/GetNotificationHistory

Paginated history dengan filter optional (status, provider_type, date_from, date_to).

Request Body Fields

FieldTypeRequiredDescription
page int32 optional ex: 1
page_size int32 optional ex: 20
filters map<string,string> optional status | provider_type | date_from | date_to
Example Response
{
  "notifications": [ /* Notification[] */ ],
  "total": 1234,
  "page": 1,
  "page_size": 20,
  "total_pages": 62
}
gRPC /notification.NotificationService/CancelNotification

Cancel notifikasi yang masih queued/processing.

Request Body Fields

FieldTypeRequiredDescription
notification_id string required
Example Response
{"success": true, "message": "cancelled"}
gRPC /notification.NotificationService/RetryNotification

Retry notifikasi yang failed.

Request Body Fields

FieldTypeRequiredDescription
notification_id string required
Example Response
{"success": true, "message": "queued for retry"}
gRPC /notification.NotificationService/GetStats

Aggregate counter per status + average delivery time.

Example Response
{
  "pending": 42,
  "processing": 5,
  "delivered": 9821,
  "failed": 17,
  "cancelled": 3,
  "average_delivery_time": 1.43
}
gRPC /queue.QueueMonitoringService/GetQueueStats

Stats per queue (message_count, consumer_count, rate, avg_processing_time).

Request Body Fields

FieldTypeRequiredDescription
queue_name string optional Kosongkan untuk semua queue
gRPC /queue.QueueMonitoringService/GetQueueInfo

Metadata queue (durable, auto_delete, arguments, created_at).

Request Body Fields

FieldTypeRequiredDescription
queue_name string required
gRPC /queue.QueueMonitoringService/GetConsumerInfo

Consumer list per queue dengan ack/unack count, prefetch.

Request Body Fields

FieldTypeRequiredDescription
queue_name string required
gRPC /queue.QueueMonitoringService/GetMessageDetails

Detail satu message dari queue (payload, published_at, delivered_at, delivery_count).

Request Body Fields

FieldTypeRequiredDescription
message_id string required
queue_name string required
gRPC /queue.QueueMonitoringService/PurgeQueue

⚠️ Hapus semua message dari queue. Operasi destruktif — gunakan hanya untuk recovery atau testing.

Request Body Fields

FieldTypeRequiredDescription
queue_name string required
gRPC /queue.QueueMonitoringService/HealthCheck

Status keseluruhan service + dependency check per komponen.

Example Response
{
  "healthy": true,
  "status": "ok",
  "version": "1.0.0",
  "timestamp": "2026-05-13T21:00:00Z",
  "services": [
    {"name": "rabbitmq", "healthy": true},
    {"name": "mongodb", "healthy": true}
  ]
}
GET /health

Returns 200 OK kalau service hidup dan dependencies (RabbitMQ + MongoDB) reachable.

Example Response
{"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.

POST /api/v1/auth/login

Service: account-service

Fields

FieldTypeRequiredDescription
email string required Account email.
password string required Account password.
cURL Example
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.

GET /api/v1/me/sessions

Service: account-service

cURL Example (JWT Bearer)
curl https://account.example.com/api/v1/me/sessions \
  -H 'Authorization: Bearer eyJ...'
Response Example
{
  "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.

DELETE /api/v1/me/sessions/{session_id}

Service: account-service

cURL Example (JWT Bearer)
curl -X DELETE https://account.example.com/api/v1/me/sessions/550e8400-... \
  -H 'Authorization: Bearer eyJ...'
Response Example
{ "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.

POST /api/v1/auth/logout-all

Service: account-service

cURL Example (JWT Bearer)
curl -X POST https://account.example.com/api/v1/auth/logout-all \
  -H 'Authorization: Bearer eyJ...'
Response Example
{ "success": true, "data": { "success": true } }

Next Steps

Consider also rotating your password →

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.

POST /api/v1/auth/register

Service: account-service

Fields

FieldTypeRequiredDescription
email 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 Example
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"
  }'
Response Example
{
  "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:

POST /api/v1/auth/resend-verification

Service: account-service

Fields

FieldTypeRequiredDescription
email string required Email to resend to. Always returns success regardless of existence.
cURL Example
curl -X POST https://account.example.com/api/v1/auth/resend-verification \
  -H 'Content-Type: application/json' \
  -d '{ "email": "user@example.com" }'
Response Example
{ "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.

POST /api/v1/auth/refresh

Service: account-service

Fields

FieldTypeRequiredDescription
refresh_token string optional Falls back to refresh_token cookie when omitted.
cURL Example
curl -X POST https://account.example.com/api/v1/auth/refresh \
  -H 'Content-Type: application/json' \
  -d '{ "refresh_token": "eyJ..." }'
Response Example
{
  "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.

POST /api/v1/organizations

Service: account-service

Fields

FieldTypeRequiredDescription
name string required Display name.
slug string optional URL-safe; auto-derived if omitted.
cURL Example (JWT Bearer)
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" }'
Response Example
{
  "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.

POST /api/v1/organizations/{org_id}/members

Service: account-service

Fields

FieldTypeRequiredDescription
email string required Invitee email.
role_id string optional Assign on accept; defaults to org default member role.
cURL Example (JWT Bearer)
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-..." }'
Response Example
{ "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.

POST /api/v1/organizations/{org_id}/switch

Service: account-service

cURL Example (JWT Bearer)
curl -X POST https://account.example.com/api/v1/organizations/550e8400-.../switch \
  -H 'Authorization: Bearer eyJ...'
Response Example
{
  "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.

POST /api/v1/auth/password-reset

Service: account-service

Fields

FieldTypeRequiredDescription
email string required Account email.
cURL Example
curl -X POST https://account.example.com/api/v1/auth/password-reset \
  -H 'Content-Type: application/json' \
  -d '{ "email": "user@example.com" }'
Response Example
{ "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.

POST /api/v1/auth/password-reset/confirm

Service: account-service

Fields

FieldTypeRequiredDescription
token string required JWT from the email link.
new_password string required Same complexity rules as registration.
cURL Example
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!"
  }'
Response Example
{ "success": true, "data": { "success": true } }

Next Steps

Now log in with the new password →

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.

POST /api/v1/service-accounts

Service: account-service

Fields

FieldTypeRequiredDescription
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 Example (JWT Bearer)
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
  }'
Response Example
{ "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.

POST /api/v1/service-accounts/{account_id}/api-keys

Service: account-service

Fields

FieldTypeRequiredDescription
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 Example (JWT Bearer)
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
  }'
Response Example
{
  "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.

GET /api/v1/me

Service: account-service

cURL Example (API Key)
curl https://account.example.com/api/v1/me \
  -H 'X-API-Key: ak_live_a1b2c3d4...'
Response Example
{ "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>.

POST /api/v1/auth/token-exchange

Service: account-service

cURL Example (API Key)
curl -X POST https://account.example.com/api/v1/auth/token-exchange \
  -H 'X-API-Key: ak_live_a1b2c3d4...'
Response Example
{
  "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.

GET /v1/<resource>

Service: downstream-service

cURL Example (JWT Bearer)
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).

POST /api/v1/api-keys/{api_key_id}/revoke

Service: account-service

Fields

FieldTypeRequiredDescription
reason string optional Recorded on the audit log.
cURL Example (JWT Bearer)
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" }'
Response Example
{ "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.

Protocol: amqp
Address: exchange: account.events / routing key: email.requested

publish Outbound email request

One event per outbound mail. The email service is the only documented subscriber today.

Payload

FieldTypeRequiredDescription
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.

Protocol: amqp
Address: notification.events.queue (bound to exchange `notification.requests`)

Notification service consumes events

At-least-once delivery. Service deduplicate berdasarkan id field. Failed delivery di-retry sampai max_retries (default 3).

Payload

FieldTypeRequiredDescription
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.

Protocol: amqp
Address: notification.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

FieldTypeRequiredDescription
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

Authorization: Bearer <value>
X-API-Key: <value>
Authorization: <value>
Authorization: Bearer <value>
💡 Info: Credentials tersimpan per environment di browser Anda (localStorage) dan tidak dikirim ke server.

Cara Menggunakan

1
Isi credentials di atas
2
Klik "Endpoints" di sidebar untuk memilih API
3
Klik "Try It" pada endpoint yang ingin di-test
4
Pilih metode autentikasi dan klik Send

🔐 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.

Token contains: sub (account id), sid (session id; nil for service principals), org_id (current organization), permissions, organizations[] (id + role), principal_type (human | service), jti (revocable id), exp / iat / iss / kid

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.

Token contains: sub (user UUID), org_id (organisation UUID, optional), permissions (e.g. ["media:upload", "folder:list"]), exp (standard JWT expiry)

Permissions

PermissionDescription
* 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).