๐Ÿ“‹ Overview

Multi-tenant identity, authentication, and organization service. Issues short-lived RS256 JWTs (access + refresh) for human users and exchanges long-lived API keys for the same JWTs for service accounts. Organizations, memberships, roles, sessions, and audit activity all live here.

๐Ÿ” Two principal types, four sign-in surfaces

Human users sign in via password, Google, or Firebase. Services authenticate with API keys.

๐Ÿข Organization-scoped

Every authenticated request resolves to a current organization context.

๐Ÿค For sibling services: which auth do I need?

Peer microservices have three integration paths; pick the one that matches your use case before requesting credentials.

๐Ÿ›ก๏ธ 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.

๐Ÿ“ง How to tell if an account is email-verified

Three signals carry the verification state โ€” pick the one closest to where your code already is.

๐Ÿšฆ 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.

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.

๐Ÿ” Two principal types, four sign-in surfaces

  • Human via password โ€” POST /auth/login with email + password.

The historical / default path. Subject to brute-force protection.

  • Human via Sign-In-With-Google โ€” POST /auth/google with a

Google ID token from GIS / native SDK. Auto-links to an existing row by email match, or auto-creates a new account.

  • Human via Sign-In-With-Firebase โ€” POST /auth/firebase with a

Firebase ID token. Same resolution rules as Google but keyed on firebase_uid. Useful when the mobile app is already using Firebase Auth for multi-provider (Apple, Facebook, โ€ฆ).

  • Service principals authenticate with an API key

(X-API-Key). Use /auth/token-exchange once an hour to swap a key for a JWT and avoid per-request key validation.

Users can mix-and-match: e.g. register with password, then later click "Connect Google account" on /dashboard/connected-accounts to add Google as a second sign-in method against the same row.

๐Ÿข 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.

๐Ÿค For sibling services: which auth do I need?

Important context for teams building wacca, chat, planner, survey, media, or any future ikavia microservice. account-service is the central hub โ€” your service authenticates against it differently depending on what you're trying to do:

  • Calling public HTTP endpoints as your service identity

(e.g. GET /api/v1/accounts/{id} to enrich a response): create a service_account inside an organization, generate an API key, and either send X-API-Key: <key> directly or swap it for a JWT via /auth/token-exchange and send Authorization: Bearer <jwt>. This is the default path for HTTP. No coordination needed โ€” any org owner can create the service_account.

  • Calling internal gRPC InternalGrantService (to grant

your product's service-key like wacca-tenant:* to a user): your service needs a system account โ€” a basic-auth credential issued by a platform admin via /dashboard/admin-system-accounts. Request this from the platform team before you start integration; without it the gRPC port rejects you. The credential's allowed_prefixes constrains which service-keys you may touch โ€” propose your prefix when you file the request (e.g. wacca- for any wacca-* key).

  • Acting on behalf of a logged-in user (forwarding a

request your user made): just forward their JWT Authorization header to account-service. The principal stays human; no new credential needed. This is the path you want if you're building an API Key Manager in your service's UI โ€” but only expose that panel to organization owners in our model, or to your own service's superadmin role. Regular org members should not see it; API keys are too powerful to be a general self-service feature. See the "Recipe: API Key Manager in your own service" section for the audience rules, CORS allowlist setup, and worked examples.

Don't mix these up. service_account (org-owned, API-key) and system_account (platform-level, basic-auth) sound similar but are unrelated tables. See the "System accounts (admin)" section for the issuance UI and the "Internal gRPC" section for the wire protocol.

๐Ÿ›ก๏ธ 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:

{ "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:

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

๐Ÿ“ง How to tell if an account is email-verified

A human account starts with email_verified=false; the user clicks the link in the welcome email (or the resend) to flip it. Three places expose that state โ€” same source of truth, different surfaces.

  • data.email_verified on GET /api/v1/me โ€” the

canonical boolean. Read this from any authenticated client; the dashboard verify-email banner is wired this way.

  • data.requires_email_verification on login responses

โ€” appears on /auth/login, /auth/register, /auth/google, /auth/firebase, and /auth/refresh. true when verification is still pending. Useful for routing the user to a "check your email" page right after sign-in instead of straight to the dashboard.

  • JWT permissions claim on the access token โ€” verified

humans get ["*"]; unverified humans get the partial read-only set ["read:profile", "read:organizations", "read:sessions"]. Service-to-service code that inspects the JWT should treat the partial set as "ask the user to verify"; any *-gated endpoint will 403 the request otherwise.

Practical rules of thumb:

  • Frontend after sign-in โ€” read data.email_verified

from GET /api/v1/me. The dashboard verify-email banner (components/layout/verify-email-banner.tsx) handles the resend button.

  • Login screen UX โ€” route off

requires_email_verification straight from the login response, no extra round-trip.

  • Backend / RBAC โ€” inspect the JWT permissions claim.

Unverified users do NOT block sign-in or session creation โ€” they get a reduced permission set plus the banner. Email verification gates feature reach, not authentication.

๐Ÿšฆ Error code table

CodeHTTPMeaning
2010201Resource created (used on /auth/register).
4000400Bad request (missing required header, malformed body).
4002422Validation failed (e.g. invalid email format, weak password, phone-number charset).
4003401Authentication / authorization failure (invalid token, missing perm, revoked session).
4004404Resource not found (account, organization, role, session).
4005409Duplicate entry (email already exists; Google sub / Firebase uid already linked to another account; org slug collision).
4008429Rate limit exceeded โ€” retry_after field is included.
4009409Conflict โ€” destructive op blocked. Admin hard-delete: target owns a multi-member org (body includes blocking_organizations). Transactional rollback delete: target owns a multi-member org, or has FK-blocking actor rows (api_keys.created_by, invitations.invited_by, service_accounts.created_by_account_id) โ€” the account isn't pristine, rollback unsafe.
4030403CSRF validation failed.
5000500Internal 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.

CookiePurposeAttributes
refresh_tokenCarries 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_tokenRandom 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:

{ "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 familyLimit
/auth/register, /auth/resend-verification3 / minute
/auth/login, /me/password5 / minute
/auth/verify100 / minute
All other endpointsbound by general per-IP cap

On exceed:

{
  "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):

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

๐Ÿ”„ 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.

Most non-auth endpoints accept either method. Sessions and /auth/logout are JWT-only โ€” service principals don't own sessions.

๐Ÿ”„ 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.

Most non-auth endpoints accept either method. Sessions and /auth/logout are JWT-only โ€” service principals don't own sessions.

๐Ÿ”„ JWT Bearer (Sign in with Google) Authentication Flow

Step-by-step authentication menggunakan JWT Bearer (Sign in with Google)

1
Render the GIS button โ–พ

Frontend loads https://accounts.google.com/gsi/client and calls google.accounts.id.initialize({ client_id, callback }) with the OAuth Client ID issued by Google Cloud Console for this app. The button is rendered by Google inside an embedded iframe.

2
User picks an account โ–พ

Google returns a CredentialResponse containing a signed ID-token JWT in response.credential.

3
POST /auth/google โ–พ

Frontend posts { credential } to /api/v1/auth/google. The server verifies the token against Google's JWKS, then either logs in an already-linked row, auto-links a matching-email row, or auto-creates a fresh account + personal org. Response shape matches /auth/login.

4
Use the returned JWT โ–พ

From this point the flow is identical to password login โ€” same access_token + refresh_token + refresh / CSRF cookies. Subsequent calls happen via Authorization: Bearer ....

Most non-auth endpoints accept either method. Sessions and /auth/logout are JWT-only โ€” service principals don't own sessions.

๐Ÿ”„ 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",
    "username": "jane.doe",
    "display_name": "Jane Doe",
    "avatar_media_id": "9e1c3a82-3a1d-4f0c-9b71-3f7a1e8c0d11",
    "timezone": "Asia/Jakarta",
    "language": "id",
    "email_verified": true,
    "bio": "",
    "phone_number": "+62 812-3456-7890",
    "metadata": {},
    "must_change_password": false,
    "has_password": true,
    "created_at": 1735689600
  }
}

The `has_password` flag is **false** for users created via
Google or Firebase auto-signup who haven't yet set a
password. The Profile โ†’ Password tab uses this to swap
between a "Set password" and "Change password" form.

`phone_number` is optional free-form text (E.164-friendly).
Empty string when not set. The PUT endpoint accepts
updates with charset `[+0-9 ()-.]` and length 6-32; an
empty string in the PUT body clears it.

`avatar_media_id` is the profile photo, stored as a
**media-service media id** (UUID), not a URL. Render it via
`https://media.ikavia.com/api/media/{id}`. `null` when unset.
On the PUT endpoint: omit to leave unchanged, `""` to clear,
or a UUID to set โ€” the BE validates the id exists in
media-service (422 if not found / expired).
PUT /api/v1/me

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

username is the unique, public handle (3-64 chars, only letters/digits and ._@+-). Case-insensitive uniqueness is enforced at the database level โ€” a collision returns 4002 validation_failed rather than overwriting another row. The same field can also be edited by other services via the account.profile.update_requested RabbitMQ event (see Events).

Request Body Fields

FieldTypeRequiredDescription
username string optional New username handle. 3-64 chars; letters, digits, ._@+-. Must be globally unique (case-insensitive).
display_name string optional Updated display name (fullname).
bio string optional Free-form bio (markdown allowed downstream).
phone_number string optional Optional phone number. Three intents: field omitted from body โ†’ leave alone; empty string โ†’ explicit clear; non-empty โ†’ set/replace. Charset [+0-9 ()-.], length 6-32.
avatar_media_id string optional Profile photo as a media-service media id (UUID). Same three intents as phone_number (omit โ†’ leave, "" โ†’ clear, UUID โ†’ set). On set, the BE validates the id exists in media-service (422 if not found/expired).
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
{
  "username": "jane.d",
  "display_name": "Jane D.",
  "bio": "Building things.",
  "phone_number": "+62 812-3456-7890",
  "timezone": "Asia/Singapore",
  "language": "en"
}
Example Response
{ "success": true, "data": { "account_id": "...", "account_type": "human", "email": "user@example.com", "username": "jane.d", "display_name": "Jane D.", "timezone": "Asia/Singapore", "language": "en", "email_verified": true, "bio": "Building things.", "phone_number": "+62 812-3456-7890", "metadata": {}, "must_change_password": false, "has_password": true, "created_at": 1735689600 } }
POST /api/v1/me/password

Two modes selected automatically by the BE based on whether the account currently has a password_hash:

  • Change mode (has_password = true): the request must

supply the correct current_password. We verify it against the stored hash, accept the swap, then revoke every active session except the one making this request (so the tab the user is sitting in doesn't get logged out).

  • First-time set mode (has_password = false): typical

for users who signed up via Google or Firebase and never set a password. The current_password field is ignored in this mode โ€” there is nothing to verify โ€” but the caller must still be authenticated via JWT. After success the account has has_password = true and future calls hit the change-mode path.

The mode is decided server-side from the database; the FE uses the has_password field from /me to pick between a "Change password" and a "Set password" form.

Service accounts cannot call this โ€” they have no password. Rate limit: 5 / minute / IP.

Request Body Fields

FieldTypeRequiredDescription
current_password string optional Existing password. Required in change mode; ignored in first-time-set mode (send empty string or omit). The BE picks the mode automatically.
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 } }
POST /api/v1/me/email

Step 1 of the verify-new-email flow. Does NOT change the email immediately โ€” instead sends a confirmation link to the new address and a security notice to the old one. The old email stays active until the new one is confirmed (step 2). Guards against typos and session-hijack.

Re-auth: accounts that have a password MUST send current_password. SSO-only accounts (NULL password) skip it โ€” the valid session is the only credential they have.

Errors:

  • 422 โ€” new_email invalid, or current_password missing

on a password account.

  • 401 โ€” current_password incorrect.
  • 409 โ€” new_email already belongs to another account.

Sessions are not revoked by an email change (it's not a credential change). The confirmation link expires in 1 hour.

Request Body Fields

FieldTypeRequiredDescription
new_email string required The address to switch to. Must be unused by another account.
current_password string optional Required for password accounts (re-auth). Omit for SSO-only accounts.
Example Request Body
{
  "new_email": "new.address@example.com",
  "current_password": "CurrentPass123!"
}
Example Response
{
  "success": true,
  "data": {
    "message": "Confirmation link sent to your new email address. The change takes effect once you click it."
  }
}
POST /api/v1/me/email/confirm

Step 2 โ€” public (the token from the confirmation email is the auth). Consumes the token, swaps the account's email, marks it verified, and publishes account.account.updated / account.account.email_changed to exchange account.events. Single-use: the token is dropped after a successful swap.

Two entry points hit the same logic:

  • This JSON endpoint (POST) โ€” for a FE-driven confirm page.
  • GET /confirm-email-change?token=โ€ฆ โ€” the link in the

confirmation email lands here directly; the backend runs the same swap and renders a branded HTML result page (mirrors GET /verify-email). No FE page required.

For platform=wacca-mobile requests the email's primary button is a wacca:// deeplink with the https link as fallback โ€” but that's set at registration/login time, not here.

Errors:

  • 422 โ€” token missing / invalid / expired.
  • 409 โ€” the new email was taken by someone else during the

confirmation window.

Request Body Fields

FieldTypeRequiredDescription
token string required Plaintext confirmation token from the email link.
Example Response
{
  "success": true,
  "data": {
    "account_id": "550e8400-e29b-41d4-a716-446655440000",
    "email": "new.address@example.com"
  }
}
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/connected-accounts

Snapshot of which external identity providers are currently linked to the authenticated account. Today the only one is Google; the response shape is forward-compatible โ€” new providers added later become additional entries in the providers array without changing the wrapper.

For Google, linked_email carries the email of the linked Google identity (e.g. arian.personal@gmail.com) which may differ from the account's own email (arian@ikavia.com). The FE renders "Connected as {linked_email}" so the user knows which Google account is attached. Absent from the response when the provider is not linked.

can_disconnect_google is true only when the user has a password set; this lets the UI disable the disconnect button for Google-only accounts so they don't lock themselves out.

Example Response
{
  "success": true,
  "data": {
    "providers": [
      {
        "provider": "google",
        "linked": true,
        "linked_email": "arian.personal@gmail.com"
      }
    ],
    "can_disconnect_google": true
  }
}
POST /api/v1/me/connected-accounts/google/link

Link a verified Google identity to the currently-signed-in account. The body shape is identical to /auth/google: the credential is the raw JWT returned by Google Identity Services (web) or the native Google SDK (Android / iOS).

Validation:

  • Google ID token signature, iss, aud, exp must all

check out (same JWKS-verified path as /auth/google).

  • Google email_verified must NOT be false.
  • The token's email is NOT required to match the

account's email. A typical use case is a work account (arian@ikavia.com) linked to a personal Google identity (arian.personal@gmail.com); both are persisted so the FE can display the Google address alongside the account address.

Identity model: google_sub (Google's stable, never- reused subject id) is the lookup key โ€” DB UNIQUE constraint sits there. google_email is persisted only for display; on every successful login the BE refreshes it from the token so a user who later changes their Google primary email still sees the right value.

Idempotent: if the same Google sub is already linked to this account, the call refreshes the stored google_email and returns 200 with the current status. Linking a DIFFERENT sub to a row that already has one returns 4005 duplicate_entry โ€” the user must disconnect the old Google identity first. Linking a sub already owned by ANOTHER account also returns 4005.

Request Body Fields

FieldTypeRequiredDescription
credential string required Raw Google ID token (the same JWT shape /auth/google accepts).
Example Request Body
{ "credential": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjE4Y..." }
Example Response
{
  "success": true,
  "data": {
    "providers": [
      {
        "provider": "google",
        "linked": true,
        "linked_email": "arian.personal@gmail.com"
      }
    ],
    "can_disconnect_google": true
  }
}
DELETE /api/v1/me/connected-accounts/google

Clear the account's google_sub AND google_email. After this call the user can no longer sign in via Google for this account โ€” they must use password (or re-link Google later).

Lock-out guard: if the account has no password set (proxied today by password_hash IS NULL), the call returns 4002 with a message instructing the user to set a password first via /auth/password-reset. Idempotent: calling DELETE when Google is not linked returns 200 with the current status.

Example Response
{
  "success": true,
  "data": {
    "providers": [
      { "provider": "google", "linked": false }
    ],
    "can_disconnect_google": false
  }
}
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
  }
}
GET /api/v1/accounts/search

Authenticated user directory search. Match is ILIKE %q% against either display_name OR email. Returns the minimum fields a "find someone" picker needs.

Mitigations against enumeration (this endpoint is global, which is intentional but exposed):

  • Server enforces APP__SECURITY__SEARCH__MIN_QUERY_LENGTH

(default 3 characters). Shorter q โ†’ 422.

  • per_page is clamped server-side to

APP__SECURITY__SEARCH__MAX_PER_PAGE (default 50).

  • Dedicated rate-limit bucket (search, default **30/min/IP

fail-closed**) โ€” tighter than general, fail-closed so a limiter outage doesn't open a scraping window.

Excluded from results: soft-deleted accounts (deleted_at IS NOT NULL) and deactivated accounts โ€” the user closed their account; surfacing them in pickers would re-attach them to other people's workflows.

Service principals should use the existing s2s endpoints GET /accounts/{id} / POST /accounts/bulk-lookup instead โ€” fuzzy search isn't part of the s2s contract.

Errors:

  • 422 / 4002 โ€” q shorter than the configured minimum.
  • 429 / 4008 โ€” rate-limit hit, includes retry_after.

Query Parameters

ParamTypeRequiredDescription
q string required Search pattern. Substring match against display_name or email. Min length governed by APP__SECURITY__SEARCH__MIN_QUERY_LENGTH.
page int optional 1-based page index. (default: 1)
per_page int optional Page size. Clamped to APP__SECURITY__SEARCH__MAX_PER_PAGE. (default: 20)
Example Response
{
  "success": true,
  "data": [
    {
      "account_id": "c8555be3-17b0-44d9-87a6-57560281d4bf",
      "display_name": "dddd",
      "email": "cobaaja123@yopmail.com",
      "status": "active",
      "email_verified": true,
      "avatar_media_id": "9e1c3a82-3a1d-4f0c-9b71-3f7a1e8c0d11"
    }
  ],
  "pagination": {
    "page": 1,
    "per_page": 20,
    "total": 1,
    "total_pages": 1
  }
}

`avatar_media_id` is `null` when the user has no avatar.
Render via `https://media.ikavia.com/api/media/{id}` โ€” same
contract as `/me` and the s2s lookup endpoints.
GET /api/v1/accounts/{id}

Returns the public profile of the account with the given UUID. 404 when the account doesn't exist or has been soft-deleted; 403 when the caller is a human principal (use /api/v1/me instead); 422 when {id} isn't a UUID.

Example Response
{
  "success": true,
  "data": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "display_name": "Rian Saputra",
    "email": "rian@example.com",
    "avatar_media_id": "9e1c3a82-3a1d-4f0c-9b71-3f7a1e8c0d11",
    "phone_number": "+62812..."
  }
}
POST /api/v1/accounts/bulk-lookup

Batch variant. Send up to 100 UUIDs in one request and get back the matching profiles in a single DB round-trip. Use this when enriching a list response โ€” looping the single-id endpoint would N+1 the DB.

Behaviour:

  • Empty ids array โ†’ 200 with items: [] (no DB hit).
  • Duplicates in ids are de-duplicated; each account

appears at most once in items.

  • IDs with no matching account are silently omitted โ€”

the consumer can diff against their request to detect gaps.

  • Output order is not guaranteed to match input

order. Build a Map<id, profile> on the consumer side and look up by id.

Errors:

  • 403 โ€” caller is a human principal.
  • 422 โ€” ids has more than 100 entries, or any entry

is not a UUID. The whole request is rejected; we do not return partial results for a bad payload, so the caller fixes their data instead of silently dropping an id.

Request Body Fields

FieldTypeRequiredDescription
ids array<uuid> required Up to 100 account UUIDs to enrich. Duplicates are de-duplicated.
Example Request Body
{
  "ids": [
    "550e8400-e29b-41d4-a716-446655440000",
    "6ba7b810-9dad-11d1-80b4-00c04fd430c8"
  ]
}
Example Response
{
  "success": true,
  "data": {
    "items": [
      {
        "id": "550e8400-e29b-41d4-a716-446655440000",
        "display_name": "Rian Saputra",
        "email": "rian@example.com",
        "avatar_media_id": "9e1c3a82-3a1d-4f0c-9b71-3f7a1e8c0d11",
        "phone_number": "+62812..."
      }
    ]
  }
}
POST /api/v1/internal/accounts/{account_id}/hard-delete-recent

Lets a service principal undo a freshly-created account when its own outer transaction fails. Example flow: WACCA calls POST /api/v1/auth/register to provision a user, then its next onboarding step (e.g., creating a tenant row) fails โ€” WACCA hits this endpoint to discard the account before the user discovers it exists.

Strict guardrails:

  • Service-principal only. Human callers โ†’ 403. (Platform

admins use the existing 2-step POST /api/v1/admin/accounts/ {id}/hard-delete/prepare flow.)

  • Time-boxed: only accounts younger than

APP__SECURITY__RECENT_DELETE_WINDOW_MINS (default 3) qualify. Older โ†’ 403 with the suggestion to use the admin flow. The window is enforced server-side from accounts.created_at.

  • No reassignment. Within 3 minutes an account is normally

pristine; if it has already produced downstream state we won't silently undo (api_keys.created_by, invitations, service_accounts) the call returns 409 and the caller should NOT rollback โ€” there's real downstream state.

  • Personal-org sweep: the target's personal org (where it

is the lone owner) is deleted in the same transaction. A multi-member org owned by the target also returns 409.

Idempotency. Retrying on a target that no longer exists returns 404; treat that as "already rolled back" on the caller side.

Cascade. Account-side rows (sessions, audit_logs, memberships, password_reset_tokens, account_service_grants, api_keys[as resource]) cascade via the DB.

Audit log. An entry with action=DELETE, resource_type=ACCOUNT, actor_id=<calling service>, metadata.flow="transactional_rollback" is written best-effort (the delete commits regardless).

Rate limit: 10 / minute / IP (sensitive preset, fail-closed). 429 includes retry_after.

Errors:

  • 403 / 4004 โ€” caller is a human principal, OR account

exceeds the window.

  • 404 โ€” target does not exist (treat as already-deleted).
  • 409 โ€” target owns a multi-member org, or has FK-blocking

dependents (api_keys.created_by, invitations.invited_by, service_accounts.created_by_account_id) โ€” rollback unsafe.

  • 429 / 4008 โ€” rate-limit exceeded.
Example Response
{
  "success": true,
  "data": {
    "account_id": "550e8400-e29b-41d4-a716-446655440000",
    "email": "rolledback@example.com",
    "deleted": true
  }
}
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.

Optional platform field (non-breaking). Shapes the verification email's links:

  • omitted / any other value โ†’ web link only

https://account.ikavia.com/verify-email?token=โ€ฆ

  • "wacca-mobile" โ†’ primary button is a wacca:// deeplink

(opens the mobile app) and the https:// link is still shown below as a clickable fallback for desktop.

Also accepts the spelling flatform (serde alias). Callers that omit it are unaffected.

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
platform string optional Caller platform. wacca-mobile โ‡’ verification email gets a wacca:// deeplink button + https fallback. Alias: flatform. ex: wacca-mobile
Example Request Body
{
  "email": "user@example.com",
  "password": "SecurePass123!",
  "display_name": "Jane Doe",
  "timezone": "Asia/Jakarta",
  "language": "id",
  "platform": "wacca-mobile"
}
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/google

Exchange a Google ID token (the credential returned by the Google Identity Services button or One Tap) for our own access + refresh JWT pair. Response shape and cookies are identical to /auth/login, so a client can treat the two flows interchangeably.

Verification. The server validates the inbound JWT against Google's JWKS at https://www.googleapis.com/oauth2/v3/certs, checks iss โˆˆ {accounts.google.com, https://accounts.google.com}, aud == <APP__SECURITY__GOOGLE_OAUTH__CLIENT_ID>, and exp. The JWKS document is cached in Redis with TTL derived from Google's Cache-Control: max-age (fallback 1 h); a kid miss triggers a one-shot refresh.

Account resolution. Three branches, each surfaced to the client via the action field of the response so the FE can render the right copy without an extra round-trip:

1. By sub โ€” token's sub already linked to an account (subsequent logins of a previously-linked Google identity). Response carries action: "logged_in". 2. By email โ€” google_sub is empty but a row exists with this email. The row is auto-linked (Google has verified the address, so we trust the email match). If the row was still Pending it is promoted to Active in the same write. Response carries action: "linked" โ€” the FE typically toasts "We connected your Google account to your existing profile" so the user understands what changed. 3. Auto-signup โ€” no row matches sub or email. A new human account, personal organization, and the standard five system roles are created in one transaction. Email is marked verified at creation since Google already proved ownership. No password is set โ€” the schema chk_human_must_have_auth is satisfied because google_sub is non-null. The user can add a password later via the change-password endpoint (which detects the NULL hash and skips the current-password check). Response carries action: "registered".

created_now is the legacy boolean form โ€” equivalent to action == "registered" โ€” kept for clients that integrated before action existed.

Errors:

  • 503 โ€” feature disabled (operator left

APP__SECURITY__GOOGLE_OAUTH__CLIENT_ID empty).

  • 401 โ€” token signature invalid, wrong audience, expired,

or email_verified: false from Google.

  • 409 โ€” account already linked to a DIFFERENT Google sub.

Rate limit: 5 / minute / IP (same bucket as /auth/login).

Request Body Fields

FieldTypeRequiredDescription
credential string required JWT issued by Google Identity Services (the credential field of the GIS CredentialResponse callback).
persist_session bool optional Controls refresh-cookie persistence. true (default) = 30 d Max-Age, false = session-only. Cosmetic for mobile clients (they read tokens from the response body, not the cookie).
device_timezone string optional IANA timezone applied only on auto-signup (when this request triggers brand-new account creation). Length cap 64 chars; invalid values silently fall back to UTC. Mobile clients should send the device's current timezone; web normally omits.
device_language string optional ISO 639-1 code applied only on auto-signup. Length cap 8 chars; invalid values fall back to en.
Example Request Body
{
  "credential": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjE4Y2...",
  "device_timezone": "Asia/Jakarta",
  "device_language": "id"
}
Example Response
{
  "success": true,
  "data": {
    "account_id": "550e8400-e29b-41d4-a716-446655440000",
    "email": "user@gmail.com",
    "display_name": "Jane Doe",
    "access_token": "eyJhbGciOiJSUzI1NiIs...",
    "refresh_token": "eyJhbGciOiJSUzI1NiIs...",
    "expires_in": 7200,
    "organizations": [
      { "org_id": "...", "name": "Jane Doe's Personal", "slug": "jane-doe-personal-550e8400", "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,
    "created_now": true,
    "action": "registered",
    "csrf_token": "f3a1c0..."
  }
}
POST /api/v1/auth/firebase

Exchange a Firebase Authentication ID token for our own access + refresh JWT pair. Mobile clients that already use Firebase Auth on the device (for Firestore rules, Functions, Crashlytics user binding, Remote Config personalisation, etc.) can send the Firebase-issued token here directly rather than juggling a separate raw Google token.

Verification. The server validates the inbound JWT against Firebase's JWKS at https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com, checks iss == https://securetoken.google.com/<APP__SECURITY__FIREBASE_AUTH__PROJECT_ID>, aud == <APP__SECURITY__FIREBASE_AUTH__PROJECT_ID>, and exp. The JWKS is cached in Redis under auth:firebase:jwks with TTL derived from Google's Cache-Control header.

Account resolution. Three branches, mirroring /auth/google. Each branch surfaces an action field in the response so the client can tell what actually happened:

1. By firebase_uid โ€” token's sub already linked to an account. Fast path for returning users. Response carries action: "logged_in". 2. By email โ€” auto-link an existing row (created via /auth/register or /auth/google or password) when the Firebase token's verified email matches. Promotes a Pending row to Active in the same write. Response carries action: "linked". 3. Auto-signup โ€” fresh account + personal organization + 5 system roles. Response carries action: "registered".

created_now is the legacy boolean form โ€” equivalent to action == "registered" โ€” kept for clients that integrated before action existed.

Email required. Firebase Anonymous / custom-token sign-ins (no email) are rejected with 4003. If the mobile app needs anonymous flows, those will get a dedicated endpoint later.

Errors:

  • 503 โ€” feature disabled (operator left

APP__SECURITY__FIREBASE_AUTH__PROJECT_ID empty).

  • 401 โ€” token signature invalid, wrong audience, wrong

issuer, expired, no email, or email_verified: false.

  • 409 โ€” account already linked to a DIFFERENT Firebase

UID.

Rate limit: 5 / minute / IP (same bucket as /auth/login and /auth/google).

Request Body Fields

FieldTypeRequiredDescription
credential string required Firebase ID token (raw JWT). Typically obtained on the client via firebase.auth().currentUser.getIdToken() (Android/Web) or Auth.auth().currentUser.getIDToken(...) (iOS).
persist_session bool optional Cosmetic on mobile (no cookies read). Send true (default) for consistency.
device_timezone string optional IANA timezone, applied only on auto-signup. Length cap 64; invalid โ†’ UTC.
device_language string optional ISO 639-1 code, applied only on auto-signup. Length cap 8; invalid โ†’ en.
Example Request Body
{
  "credential": "eyJhbGciOiJSUzI1NiIsImtpZCI6Ijc2N...",
  "device_timezone": "Asia/Jakarta",
  "device_language": "id"
}
Example Response
{
  "success": true,
  "data": {
    "account_id": "550e8400-e29b-41d4-a716-446655440000",
    "email": "user@gmail.com",
    "display_name": "Jane Doe",
    "access_token": "eyJhbGciOiJSUzI1NiIs...",
    "refresh_token": "eyJhbGciOiJSUzI1NiIs...",
    "expires_in": 7200,
    "organizations": [
      { "org_id": "...", "name": "Jane Doe's Personal", "slug": "jane-doe-personal-550e8400", "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,
    "created_now": true,
    "action": "registered",
    "csrf_token": "f3a1c0..."
  }
}
POST /api/v1/admin/accounts/{account_id}/force-verify-email

Stamps email_verified_at = NOW() on the target account without requiring the user to click the verification link sent at registration. Use only when identity has been confirmed out-of-band โ€” for example, a user mistyped their email at signup and the team has since corrected it; an internal employee changed laptops and lost access to the original mailbox; a partner can't receive mail from the verification domain.

Behavior:

  • Platform-admin only (* permission).
  • Idempotent. Calling on an already-verified account

returns 200 with was_already_verified: true and does not bump updated_at.

  • Rejects service accounts (no email) with 422.
  • On change, emits an AccountUpdated event to the

account.events RabbitMQ exchange so downstream consumers see the new verified state.

Errors:

  • 403 โ€” caller lacks *.
  • 404 โ€” target account not found.
  • 422 โ€” target has no email (service account).

Audit: logged with admin_id, target_id, and the previous email_verified_at value (NULL on first verification).

Example Response
{
  "success": true,
  "data": {
    "account_id": "550e8400-e29b-41d4-a716-446655440000",
    "email": "alice@example.com",
    "email_verified": true,
    "was_already_verified": false
  }
}
POST /api/v1/admin/users/{user_id}/reset-password

Force-reset a user's password. Generates a temporary password per the deployment's policy, hashes it, persists with a TTL (login rejects the credential after expiry), and revokes every active session of the target. Audit-logged.

Authorization (strict per-org):

  • Platform-admin (* permission): may reset any user.

X-Organization-Id is optional and used only as the audit log's organization context.

  • Org owner / admin: MUST send X-Organization-Id; must

be owner or admin in that org; the target must be a member of the SAME org. Cross-org โ†’ 403.

  • Self-reset is refused (use the normal change-password flow

or have another admin do it) so the audit log can't be silently rotated.

Delivery modes:

  • delivery: "return" (default) โ€” plaintext temp password is

returned in the response once. The admin is expected to hand it to the user out-of-band. A subsequent reset issues a fresh password; the prior one is discarded.

  • delivery: "email" โ€” account-service emails the user the

temp password with instructions. Response omits the plaintext.

Policy is configurable per deployment: APP__SECURITY__TEMP_PASSWORD__LENGTH (default 12) and APP__SECURITY__TEMP_PASSWORD__TTL_HOURS (default 72). The charset is fixed (upper+lower+digits+symbols, confusable characters removed).

Rate limit: 10 / minute / IP (fail-closed). 429 includes retry_after.

Errors:

  • 400 / 4002 โ€” delivery not return|email,

X-Organization-Id not a UUID.

  • 403 / 4004 โ€” caller not owner/admin in target org, or

target is not a member of target org, or admin attempted self-reset.

  • 404 โ€” target user_id does not exist.
  • 422 โ€” service account (no email/password) target,

X-Organization-Id required for non-platform admin and absent.

  • 429 / 4008 โ€” rate limit exceeded.

The legacy platform-admin-only endpoint POST /api/v1/admin/accounts/{account_id}/reset-password remains in place unchanged (default behaviour preserved).

Headers: Authorization: Bearer <admin-jwt>. Optional X-Organization-Id: <uuid> โ€” REQUIRED for non-platform admins; optional (audit context only) for platform-admin. Sending it as anything but a UUID returns 422.

Request Body Fields

FieldTypeRequiredDescription
delivery string optional return (plaintext in response) or email (account-service emails the user). default: return
force_change_on_next_login boolean optional When true (default), user is forced into the change-password screen on first login. When false, they can keep using the temp until expires_at โ€” the deployment-level TTL still applies in both modes. To issue a non-expiring password, change it yourself via /me/password after logging in with the temp. default: true
Example Request Body
{ "delivery": "return", "force_change_on_next_login": true }
Example Response
{
  "success": true,
  "data": {
    "user_id": "550e8400-e29b-41d4-a716-446655440000",
    "temp_password": "Gk7#mPq2RwZ9",
    "expires_at": "2026-06-02T08:00:00Z",
    "must_change_on_next_login": true
  }
}

# For delivery=email, `temp_password` is null (it lives in the
# user's inbox, not in this response).
POST /api/v1/admin/accounts/{account_id}/hard-delete/prepare

Step 1 of the two-step hard delete flow. Issues a short alphanumeric confirmation code scoped to (admin_id, target_account_id) and returns it to the admin UI. Code lives 5 minutes in Redis and is single-use.

> For service-driven rollback (the calling service just > created an account and its outer transaction failed): use > POST /api/v1/internal/accounts/{account_id}/hard-delete-recent > instead โ€” it's service-principal-only, time-boxed by > APP__SECURITY__RECENT_DELETE_WINDOW_MINS (default 3 min), > and skips this two-step confirmation.

The UI's destructive-action modal displays this code and forces the admin to retype it before the actual delete is submitted to /hard-delete. Pure friction-against-fat- finger protection; the real authorization is the * permission check.

Pre-validates the target exists (404 if not) and that the admin is not targeting themselves (422). Each call replaces any previous code for the same (admin, target) pair โ€” re-opening the modal generates a fresh code.

Rate limit: general per-IP cap. No specific bucket.

Example Response
{
  "success": true,
  "data": {
    "confirmation_code": "7RP6JB3K",
    "expires_in_secs": 300
  }
}
POST /api/v1/admin/accounts/{account_id}/hard-delete

Step 2 of the hard delete flow. Verifies the confirmation_code against the value stashed by /prepare, then irreversibly removes the account row plus everything tied to it.

Cascade rules:

  • audit_logs, sessions, api_keys (where the row's

account_id is the target), account_service_grants, password_reset_tokens, organization_memberships (target-side), service_accounts (target-side): removed automatically via existing ON DELETE CASCADE.

  • Personal organization (target is owner AND only

active member): auto-deleted in the same transaction. Its memberships, roles, sessions and grants cascade via the FKs on organizations.id.

  • Multi-member organization the target owns: blocks

the delete. Response is 409 with blocking_organizations listing each org's name, slug, and active member count. Admin must transfer ownership (or delete the org) before retrying.

  • NOT-NULL FK columns without ON DELETE

(api_keys.created_by, invitations.invited_by, service_accounts.created_by_account_id): reassigned to the admin doing the delete so the rows stay valid. The audit-log entries covering the original creation events are gone, so use this knowing the "originally created by" attribution is lost.

Errors:

  • 404 โ€” target account not found (e.g. already deleted

in a parallel admin session).

  • 422 โ€” validation_failed: code mismatch, code

expired, or admin tried to delete themselves.

  • 409 โ€” blocking_organizations payload, see above.
  • 5xx โ€” DB transaction failure; nothing was committed.

Request Body Fields

FieldTypeRequiredDescription
confirmation_code string required The exact code returned by /prepare. Single-use; deleted from Redis on first match.
Example Request Body
{
  "confirmation_code": "7RP6JB3K"
}
Example Response
{
  "success": true,
  "data": {
    "account_id": "550e8400-e29b-41d4-a716-446655440000",
    "email": "user@example.com",
    "deleted_personal_org_ids": [
      "49f18abc-64c1-40db-830a-b7d191c4232f"
    ]
  }
}
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. The response is deliberately indistinguishable from the "email not found" case so a caller can't enumerate which addresses have an account โ€” both return 200 with the same generic message. A verified account also returns 200 (and quietly does nothing).

The FE surface for this endpoint is the Verify-email banner mounted in the dashboard layout (components/layout/verify-email-banner.tsx): a sticky yellow strip that shows for any user with email_verified=false and carries a "Resend email" button. Calling the endpoint manually is the path for unauthenticated callers (e.g. a "Didn't get the email?" link before sign-in).

Rate limit: 3 / minute / IP.

Request Body Fields

FieldTypeRequiredDescription
email string required Email to resend verification to.
platform string optional Optional. wacca-mobile makes the resent email use a wacca:// deeplink button (https link kept as fallback), matching register. Alias flatform accepted.
Example Request Body
{ "email": "user@example.com", "platform": "wacca-mobile" }
Example Response
{ "success": true, "code": 2000, "data": { "message": "If the email exists, a 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.

Optional platform field (non-breaking). Controls the scheme of the reset link in the emailed message:

  • omitted / any other value โ†’ web link

https://account.ikavia.com/reset-password?token=โ€ฆ

  • "wacca-mobile" โ†’ custom-scheme deeplink

wacca://account.ikavia.com/reset-password?token=โ€ฆ so the mobile OS opens the app instead of a browser.

The field also accepts the spelling flatform (serde alias) so the mobile client can send either key. Existing callers that send only email are unaffected.

Request Body Fields

FieldTypeRequiredDescription
email string required Email of the account to reset.
platform string optional Caller platform. wacca-mobile โ‡’ wacca:// deeplink reset URL. Alias: flatform. Omit for the normal web link.
Example Request Body
{
  "email": "user@example.com",
  "platform": "wacca-mobile"
}
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"
  }
}
POST /api/v1/auth/system-token

Network-reachable counterpart to the gRPC grant-management flow. A System account (basic-auth credential issued by a platform admin for a headless peer backend โ€” distinct from an org-owned service account) swaps its credentials for a short-lived RS256 service JWT (principal_type: service).

Pick which token-mint endpoint based on the caller principal:

Caller is โ€ฆUseAuth
An org-owned service account with an API key/auth/token-exchangeX-API-Key: <api-key> header
A platform System account (no org)/auth/system-token (this)Authorization: Basic name:password

Both endpoints return the same JWT shape โ€” same issuer, kid, RS256 key, principal_type: service. Peer services verify either token through the same JWKS / /auth/public-key path without any code change. The difference is who's calling and how their credentials are managed: service accounts are managed inside an organization, System accounts are managed by the platform admin under /admin/system-accounts.

The token is byte-for-byte compatible with every other token this service mints (same issuer, kid, RS256 key), so peer services (e.g. Media Service) verify it through their existing public-key / JWKS path with no change to their validation.

Permissions are admin-granted, never caller-chosen. The token's permissions come solely from the System account's token_permissions grant (set via the admin POST /api/v1/admin/system-accounts/{id}/permissions endpoint), e.g. ["media:upload"]. The request body carries no permission input. sub is the System account id; org_id is absent (System accounts have no organization); sid is nil.

TTL follows the access-token policy (APP__SECURITY__JWT__ACCESS_TOKEN_TTL_MINS, default 15 min).

Auth: Authorization: Basic base64(name:password). 401 for missing/invalid credentials (neutral body โ€” never reveals whether a name exists); 403 once the password is correct but the System account is revoked.

Example Request Body
# Basic auth โ€” the System account name + password issued by the
# platform admin. The permission set baked into the token is the
# admin-granted `token_permissions`, NOT anything sent here.
curl -X POST 'http://localhost:8874/api/v1/auth/system-token' \
  -u 'recruitment-backend:<system-account-password>' \
  -H 'Accept: application/json'
Example Response
{
  "success": true,
  "data": {
    "access_token": "eyJhbGciOiJSUzI1NiIs...",
    "token_type": "Bearer",
    "expires_in": 900
  }
}
GRPC account.v1.internal.InternalGrantService/GrantService

Issue a service-grant for account_id. Idempotent โ€” calling with the same (account_id, service, organization_id) tuple returns granted_now=false and the existing granted_at.

Scope.

  • Empty organization_id โ‡’ global grant. Visible in the

holder's JWT in every org context.

  • Non-empty organization_id โ‡’ org-scoped grant. Only

contributes to the JWT when the holder's current org matches.

JWT emission. Granting a service whose key is not in the built-in list (email, planner, survey, media, platform_admin) results in {service}:* being added to the holder's JWT permissions array on next token issuance. Granting wacca-tenant produces wacca-tenant:*.

Errors:

  • UNAUTHENTICATED โ€” no authorization metadata, wrong

scheme, unknown user, or wrong password.

  • PERMISSION_DENIED โ€” service not covered by the

caller's prefix allowlist.

  • INVALID_ARGUMENT โ€” account_id / organization_id not

a UUID, or service empty.

Example Request Body
# Pseudocode โ€” adapt to your gRPC client library
metadata.authorization = "Basic " + base64("wacca:<password>")
GrantServiceRequest {
  account_id: "550e8400-e29b-41d4-a716-446655440000",
  service: "wacca-tenant",
  organization_id: ""  // global
}
Example Response
GrantServiceResponse {
  granted_now: true,
  granted_at: "2026-05-21T10:50:00Z"
}
GRPC account.v1.internal.InternalGrantService/RevokeService

Revoke a previously-issued grant. Idempotent โ€” calling on a tuple that doesn't exist returns revoked=false. The scope (organization_id empty vs. non-empty) must match the row to be revoked; revoking a global grant with an organization_id set is a no-op.

Errors: Same set as GrantService. Notably, a caller can only revoke what they could grant โ€” revoking platform_admin from wacca's credential is PERMISSION_DENIED, not silent success.

Example Request Body
RevokeServiceRequest {
  account_id: "550e8400-...",
  service: "wacca-tenant",
  organization_id: ""
}
Example Response
RevokeServiceResponse { revoked: true }
GRPC account.v1.internal.InternalGrantService/ListGrantsForAccount

Enumerate the grants account_id holds within the caller's allowlist. Grants outside the allowlist (other services' keys, platform_admin) are filtered out โ€” wacca cannot use this to map who else has what.

service_prefix further narrows the result. If non-empty, the prefix must itself be allowed for the caller, otherwise PERMISSION_DENIED.

Example Request Body
ListGrantsForAccountRequest {
  account_id: "550e8400-...",
  service_prefix: "wacca-"
}
Example Response
ListGrantsForAccountResponse {
  grants: [
    { service: "wacca-tenant", organization_id: "", granted_at: "2026-05-21T10:50:00Z" }
  ]
}
GRPC account.v1.internal.InternalAccountService/GetAccount

Look up an account by id. Returns the public-mirror fields a sibling service usually wants for display / authorization: account_id, account_type ("human" | "service"), display_name, email (empty string for service accounts with no email), status, created_at (unix seconds).

A soft-deleted row (deleted_at IS NOT NULL) answers NOT_FOUND โ€” the row is logically gone for caller purposes.

Errors:

  • UNAUTHENTICATED โ€” no authorization metadata, wrong

scheme, unknown user, or wrong password.

  • INVALID_ARGUMENT โ€” account_id is not a UUID.
  • NOT_FOUND โ€” no live row matches the id.
Example Request Body
# Pseudocode โ€” adapt to your gRPC client library
metadata.authorization = "Basic " + base64("wacca:<password>")
GetAccountRequest {
  account_id: "0a2f6766-893b-49e6-9acb-9e503cd93e1e"
}
Example Response
GetAccountResponse {
  account_id: "0a2f6766-893b-49e6-9acb-9e503cd93e1e",
  account_type: "human",
  display_name: "Jane Doe",
  email: "jane@example.com",
  status: "active",
  created_at: 1735689600
}
GRPC account.v1.internal.InternalAccountService/AccountExists

Cheap existence probe. Use this when the caller only needs to confirm an account_id resolves (e.g., precondition before storing a foreign key) and doesn't need the rest of the account row. Same lookup semantics as GetAccount: the underlying repo filters WHERE deleted_at IS NULL, so both soft-deleted AND deactivated rows answer exists=false (deactivate sets deleted_at alongside status='deactivated', so the two states are colinear in practice โ€” there is no reachable row with deleted_at IS NULL AND status='deactivated').

status carries the account's status string only when exists=true (empty string otherwise). In practice the values you'll see are "active" or "pending" โ€” every row that's reached deactivated is also soft-deleted, and the probe returns false before it can surface that status.

Errors:

  • UNAUTHENTICATED โ€” see GetAccount.
  • INVALID_ARGUMENT โ€” account_id is not a UUID.
Example Request Body
AccountExistsRequest {
  account_id: "0a2f6766-893b-49e6-9acb-9e503cd93e1e"
}
Example Response
AccountExistsResponse {
  exists: true,
  status: "active"
}
GRPC account.v1.internal.InternalAccountService/EmailIsVerified

Cheap probe: is the account's email verified?

Use when a sibling service needs to gate on "email- verified user" before issuing some capability (sending a notification to the address, marking a profile public, allowing high-trust action) and wants a single boolean answer without fetching the full profile.

Response shape โ€” two booleans:

  • verified โ€” true iff the account exists, is live

(not soft-deleted), AND its email is verified. False covers four cases at once: unknown id, soft-deleted row, account has no email (some service accounts), email not yet verified. For most callers a single check on verified is sufficient: all four cases mean "do not trust this email".

  • account_exists โ€” true iff a live row matches

account_id, regardless of verification state. Lets a caller distinguish "no such user" from "user exists but unverified" when that matters (e.g. distinct error message in a UI).

Same WHERE deleted_at IS NULL filter as the other reads on this service โ€” soft-deleted / deactivated rows resolve to verified=false, account_exists=false.

Errors:

  • UNAUTHENTICATED โ€” see GetAccount.
  • INVALID_ARGUMENT โ€” account_id is not a UUID.
Example Request Body
EmailIsVerifiedRequest {
  account_id: "0a2f6766-893b-49e6-9acb-9e503cd93e1e"
}
Example Response
EmailIsVerifiedResponse {
  verified: true,
  account_exists: true
}
GRPC account.v1.internal.InternalAccountService/LookupAccount

Slim single-account resolver. Same lookup semantics as GetAccount (soft-deleted / deactivated โ†’ NOT_FOUND), but returns only the public-display projection: {id, display_name, email, avatar_media_id, phone_number}.

Use this for non-hot resolve paths โ€” replica backfill on cache miss, daily reconciliation. Don't reach for GetAccount if you only need display fields; the slim projection is the stable replica contract.

Empty string conventions on proto3:

  • email = "" when the row has no email (some service

accounts) โ€” treat as absent, not literal.

  • avatar_media_id = "" when unset.
  • phone_number = "" when unset.

Errors:

  • UNAUTHENTICATED โ€” see GetAccount.
  • INVALID_ARGUMENT โ€” account_id is not a UUID.
  • NOT_FOUND โ€” no live row matches the id.
  • RESOURCE_EXHAUSTED โ€” id-resolver rate-limit tripped.
Example Request Body
LookupAccountRequest {
  account_id: "0a2f6766-893b-49e6-9acb-9e503cd93e1e"
}
Example Response
LookupAccountResponse {
  account: {
    id: "0a2f6766-893b-49e6-9acb-9e503cd93e1e",
    display_name: "Jane Doe",
    email: "jane@example.com",
    avatar_media_id: "a8f3c2d1-...",
    phone_number: "+628111234567"
  }
}
GRPC account.v1.internal.InternalAccountService/BulkLookupAccounts

Batch resolve by account id. Server caps the slice length at APP__SECURITY__INTERNAL_LOOKUP__BATCH_CAP_IDS (default 100); going over โ†’ INVALID_ARGUMENT. The server also dedups the id slice before hitting the repo, so repeated ids in the request don't double-charge the backend or appear twice in the response.

Behaviour matches REST POST /accounts/bulk-lookup:

  • Missing ids (no live row) are silently OMITTED โ€” diff

against the request slice to detect gaps.

  • Order of accounts is NOT meaningful.
  • Soft-deleted / deactivated rows are NOT returned.

Errors:

  • UNAUTHENTICATED โ€” see GetAccount.
  • INVALID_ARGUMENT โ€” any element fails UUID parse, OR

slice length > the configured cap.

  • RESOURCE_EXHAUSTED โ€” id-resolver rate-limit tripped.
Example Request Body
BulkLookupAccountsRequest {
  account_ids: [
    "0a2f6766-893b-49e6-9acb-9e503cd93e1e",
    "ff000000-0000-0000-0000-000000000000",
    "1c37ff2c-2f42-4d86-94d3-a20163909ccf"
  ]
}
Example Response
# Two of three resolved; the unknown id (ffโ€ฆ) is omitted.
BulkLookupAccountsResponse {
  accounts: [
    { id: "0a2f...", display_name: "Jane Doe",   email: "jane@example.com",  avatar_media_id: "...", phone_number: "..." },
    { id: "1c37...", display_name: "John Smith", email: "john@example.com",  avatar_media_id: "",    phone_number: ""     }
  ]
}
GRPC account.v1.internal.InternalAccountService/LookupAccountsByEmail

Batch resolve by email. The email VO stores canonicalized lowercase, so matching is exact case-insensitive โ€” no fuzzy, no substring, no domain wildcards.

Tighter batch cap and tighter rate-limit bucket than the id resolver because email lookup is a higher enumeration risk surface. Defaults:

  • Batch cap 50 (APP__SECURITY__INTERNAL_LOOKUP__BATCH_CAP_EMAILS).
  • Rate limit 120/min/system-account

(__RATE_LIMIT_PER_MIN_EMAILS).

The server normalises every input (trim + lowercase) and dedups before hitting the repo. Empty-after-trim entries are dropped silently. A malformed address in the slice rejects the WHOLE batch (caller bug โ€” chunk + retry won't recover; fix the input).

Missing emails (no live row) are silently omitted โ€” same pattern as the id resolver. Diff against the input slice.

Soft-deleted / deactivated rows are NOT returned. The surface is service-principal only โ€” this is NOT a public email-existence oracle.

Errors:

  • UNAUTHENTICATED โ€” see GetAccount.
  • INVALID_ARGUMENT โ€” any element fails email parse, OR

slice length > the configured cap.

  • RESOURCE_EXHAUSTED โ€” email-resolver rate-limit tripped.
Example Request Body
LookupAccountsByEmailRequest {
  emails: [
    "Jane@Example.COM",
    "  john@example.com  ",
    "noone@example.com"
  ]
}
Example Response
# Two of three resolved; case + whitespace handled
# server-side. "noone@example.com" silently omitted.
LookupAccountsByEmailResponse {
  accounts: [
    { id: "0a2f...", display_name: "Jane Doe",   email: "jane@example.com",  avatar_media_id: "...", phone_number: "..." },
    { id: "1c37...", display_name: "John Smith", email: "john@example.com",  avatar_media_id: "",    phone_number: ""     }
  ]
}
GRPC account.v1.internal.InternalAuthService/VerifyPassword

Verify a user's plaintext password against the stored argon2 hash. Account is identified by UUID only (no email lookup โ€” keeps the RPC from doubling as an email enumeration probe).

Response shape:

  • valid=true โ€” argon2 matched. account_status carries

the row's status string ("active" / "pending" / "suspended"), so the caller can decide whether to proceed (e.g., refuse step-up auth on a "suspended" account even though the password was right).

  • valid=false โ€” password didn't match, OR the account

is soft-deleted, OR the account has no password set (SSO-only). account_status is empty so a caller can't probe state by toggling the password input.

Soft-deleted / unknown accounts short-circuit before argon2 runs, so the response time can't be used to probe state-of-existence via timing.

Errors:

  • UNAUTHENTICATED โ€” missing / invalid system-account

Basic credential.

  • INVALID_ARGUMENT โ€” account_id not a UUID, or

password empty.

  • RESOURCE_EXHAUSTED โ€” the shared brute-force counter

for this account_id has tripped; retry after the cooldown encoded in the status message.

Example Request Body
metadata.authorization = "Basic " + base64("wacca:<password>")
VerifyPasswordRequest {
  account_id: "0a2f6766-893b-49e6-9acb-9e503cd93e1e",
  password: "the-user-typed-this"
}
Example Response
VerifyPasswordResponse {
  valid: true,
  account_status: "active"
}
GET /api/v1/admin/system-accounts

All rows ordered by created_at desc, including revoked rows (greyed out in the UI). Hash and prefix allowlist are returned because the admin who sees this page already has full platform access; not exposing the hash wouldn't add security but would prevent the UI from showing whether a recent rotate succeeded.

POST /api/v1/admin/system-accounts

Creates a new credential and returns the plaintext password ONCE in the response. The hash is the only persisted form.

Validation:

  • name: ASCII letters, digits, -, or _. Unique

across active AND revoked rows (you cannot re-use a name once revoked).

  • allowed_prefixes: at least one non-empty entry.
  • description: optional free-text.

Errors:

  • 409 โ€” name already exists (active or revoked).
  • 422 โ€” validation failure on name or prefixes.

Request Body Fields

FieldTypeRequiredDescription
name string required Basic-auth username.
allowed_prefixes array<string> required Service-key prefixes this caller may grant via gRPC grant-management. Required, at least one entry.
description string optional Free-text note.
token_permissions array<string> optional Permission set embedded into bearer tokens minted via POST /auth/system-token (e.g. ["media:upload"]). Empty by default โ€” defer with the dedicated permissions endpoint below if you don't want to set on create. Trimmed + deduped server-side; order preserved.
Example Response
{
  "success": true,
  "data": {
    "account": {
      "id": "550e8400-...",
      "name": "careers-backend",
      "allowed_prefixes": ["*"],
      "token_permissions": ["media:upload"],
      "description": "Owned by careers-team",
      "status": "active",
      ...
    },
    "password": "K4j8m..."
  }
}
POST /api/v1/admin/system-accounts/{id}/permissions

Replace the token_permissions array on an active system account, without touching the password. Used to grant (or rescind) capabilities such as media:upload consumed by the /auth/system-token bearer-token flow.

Server-side normalisation: each entry is trimmed; empty entries are dropped; duplicates are folded while preserving first-seen order. Send [] to clear all permissions.

Cache discipline. Bearer-token minting reads token_permissions directly from Postgres on every call (the gRPC Redis cache deliberately omits this column), so a new grant takes effect on the next token mint. Tokens already in flight keep the permissions they were issued with until they expire (โ‰ค access-token TTL, default 15 min).

Errors:

  • 403 โ€” caller is not a platform admin.
  • 404 โ€” id unknown or row is revoked (revoked rows

cannot be re-permissioned; rotate or create fresh).

  • 422 โ€” token_permissions field missing from body.

Request Body Fields

FieldTypeRequiredDescription
token_permissions array<string> required Replacement permission set. Send [] to clear all permissions.
Example Request Body
{
  "token_permissions": ["media:upload", "media:read"]
}
Example Response
{
  "success": true,
  "data": {
    "id": "550e8400-...",
    "token_permissions": ["media:upload", "media:read"]
  }
}
POST /api/v1/admin/system-accounts/{id}/rotate-password

Generates a fresh 32-character password and replaces the stored hash atomically. Old password stops working on the next gRPC call (Redis cache is invalidated as part of the same handler).

Cannot be called on a revoked row โ€” use Create with a fresh name instead. Returns 422 in that case.

DELETE /api/v1/admin/system-accounts/{id}

Flips status to revoked, stamps revoked_at and revoked_by_account_id. Idempotent โ€” the response includes already_revoked: true if the row was already revoked. The row is never hard-deleted so the audit trail (when issued, by whom, when revoked) stays intact.

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/admin/registration-lock

Read the full lock row including audit columns. Used by the superadmin UI at /dashboard/admin-registration.

Example Response
{
  "success": true,
  "data": {
    "enabled": false,
    "message": null,
    "updated_at": "2026-05-22T10:00:00Z",
    "updated_by_account_id": "550e8400-..."
  }
}
PUT /api/v1/admin/registration-lock

Toggle the lock and optionally set a custom message. Idempotent โ€” calling with the same (enabled, message) twice is a no-op.

Body fields:

  • enabled (bool, required) โ€” true blocks, false

opens.

  • message (string, optional) โ€” shown to users on the

register page when locked. Omit or send empty string to clear and let the FE use its default copy.

Returns the new state; Redis cache is invalidated as part of the handler so the change is observable on the next request.

Request Body Fields

FieldTypeRequiredDescription
enabled boolean required Lock state to set.
message string optional Optional user-facing reason.
Example Request Body
{
  "enabled": true,
  "message": "Sedang migrasi infrastructure, registrasi dibuka kembali sekitar 16:00 WIB"
}
GET /api/v1/auth/registration-status

Public read of the current lock state. No auth required โ€” the register page calls this on first render so it can show the banner instead of letting the user fill out a form that's going to 423.

Only two fields are returned (locked, message); the audit columns stay private. Backed by the same 5-minute Redis cache as the gate check.

Example Response
{
  "success": true,
  "data": {
    "locked": true,
    "message": "Sedang migrasi infrastructure, registrasi dibuka kembali sekitar 16:00 WIB"
  }
}
GET /api/v1/admin/registration-requests

List requests filtered by status. Pagination is intentionally omitted โ€” queue depth is expected to stay small under realistic emergency conditions.

Query Parameters

ParamTypeRequiredDescription
status string optional One of pending, approved, rejected, completed. (default: pending)
Example Response
{
  "success": true,
  "data": [
    {
      "id": "550e8400-...",
      "email": "newuser@example.com",
      "display_name": "Alice",
      "timezone": "Asia/Jakarta",
      "language": "id",
      "source": "email_password",
      "ip_address": "203.0.113.42",
      "user_agent": null,
      "status": "pending",
      "requested_at": "2026-05-23T05:30:00Z",
      "reviewed_at": null,
      "reviewed_by_account_id": null,
      "review_note": null,
      "completed_account_id": null
    }
  ]
}
POST /api/v1/admin/registration-requests/{id}/approve

Mark a pending request approved + mint a 24-hour approval token. The plaintext token is returned ONCE in data.approval_url; share it with the user out-of-band (chat, manual email) until the email-event automation lands.

Errors:

  • 404 โ€” request not found.
  • 422 โ€” request is not in pending (already

approved/rejected/completed).

Request Body Fields

FieldTypeRequiredDescription
note string optional Internal review note (not shown to the user).
Example Response
{
  "success": true,
  "data": {
    "id": "550e8400-...",
    "approval_url": "https://account.ikavia.com/complete-registration?token=K4j8m...",
    "expires_at": "2026-05-24T05:30:00Z"
  }
}
POST /api/v1/admin/registration-requests/{id}/reject

Mark a pending request rejected. No notification is sent to the user automatically (intentional โ€” guards against probe-and-confirm by attackers).

Request Body Fields

FieldTypeRequiredDescription
note string optional Internal review note.
POST /api/v1/auth/complete-registration

Public endpoint โ€” the approval token IS the auth. Consume the token, set the password, create the account, auto-login. Same response shape as /auth/register so the FE can treat completion as a normal "you're logged in now".

Errors:

  • 422 โ€” token missing/invalid/expired, or password

too short (< 8 chars).

  • 429 โ€” rate-limit (3/min/IP, same as register).

Phase A limitation. SSO-source requests return 422 here โ€” only email_password source is finalised by this endpoint for now.

Request Body Fields

FieldTypeRequiredDescription
token string required Plaintext approval token.
password string required User's new password (min 8 chars).
GET /api/v1/admin/service-account-quotas

Returns both the currently effective defaults and the full list of override rows. global_override_active is true when a global-scope row exists; otherwise the defaults are the hardcoded values.

Example Response
{
  "success": true,
  "data": {
    "defaults": {
      "per_user": 10,
      "per_org": 50,
      "global_override_active": false
    },
    "overrides": [
      {
        "id": "...",
        "scope": "org",
        "target_id": "550e8400-...",
        "max_count": 100,
        "note": "INC-1234 raised limit",
        "updated_at": "2026-05-23T07:00:00Z",
        "updated_by_account_id": "..."
      }
    ]
  }
}
PUT /api/v1/admin/service-account-quotas

Create or update an override row. target_id is required for scope of org or user; ignored when scope=global. max_count must be >= 0 (zero effectively blocks all creation for that target).

Request Body Fields

FieldTypeRequiredDescription
scope string required One of: global, org, user.
target_id string optional UUID of the org or user. Required for non-global scopes.
max_count integer required Maximum active service accounts allowed at the target.
note string optional Free-text note shown only in the admin list view.
Example Request Body
{
  "scope": "org",
  "target_id": "550e8400-e29b-41d4-a716-446655440000",
  "max_count": 100,
  "note": "INC-1234 raised limit for prod rollout"
}
DELETE /api/v1/admin/service-account-quotas/{scope}/{target_id}

Remove a non-global override; the target falls back to the global override or hardcoded defaults. Use the separate global endpoint below for the global row.

DELETE /api/v1/admin/service-account-quotas/global

Remove the global override row; defaults fall back to the hardcoded values (DEFAULT_MAX_PER_USER = 10, DEFAULT_MAX_PER_ORG = 50).

STEP (client-side design)

A typical API Key Manager has three views:

  • List โ€” table of existing service accounts (bots) in

the user's current org, each with a chevron to expand its keys.

  • Create service account โ€” form with name + description.

Service accounts hold the keys; you cannot have a key without first having a service account to attach it to.

  • Create / revoke / delete API key โ€” under a service

account, list its keys (showing only key_prefix โ€” plaintext is never re-fetched) with create, revoke, and delete actions.

Mirror the layout of https://account.ikavia.com/dashboard/api-keys if you want a known-good reference; everything you see there is buildable from the public endpoints in this section.

GET /api/v1/me

Two things at once: check the user is in the allowed audience for this panel, and pick up the org context.

Audience check (do this before rendering the menu item):

  • Look at permissions in the user's access-token claims

(or call /me and inspect the org's role). The user must hold service_accounts:create and api_keys:create on the target org โ€” or your own service's superadmin role, if you have one.

  • If they don't, hide the menu entry. Don't render the

page just to show a 403 โ€” surface the capability only to people who can actually use it.

Org context: read current_organization_id from /me. If your service has its own concept of "the org this user is currently looking at", use that instead, as long as it matches one of organizations[] in the response. Every CRUD call below filters by this org.

GET /api/v1/service-accounts?organization_id={org_id}

Render this as the parent table. Each row's account_id is the parent for that bot's API keys (Step 5).

POST /api/v1/service-accounts

Required body: organization_id + display_name. Returns the new row; no API key is minted yet โ€” that's a separate call so the admin can decide permissions for each key independently.

Example Request Body
{
  "organization_id": "550e8400-...",
  "display_name": "Wacca tenant sync bot",
  "description": "Reads tenant rosters for nightly sync"
}
POST /api/v1/service-accounts/{account_id}/api-keys

Plaintext-key handling โ€” read carefully. The response field data.key is the ONLY time the raw key is ever exposed by account-service. Subsequent reads see only key_prefix (first 8 chars) for visual identification.

Your FE MUST render this in a "show-once modal":

  • Display data.key in a copyable field with a Copy

button.

  • Strongly word the warning that dismissing the modal

discards the plaintext.

  • Drop the value from in-memory state once the modal

closes. Do not store it in localStorage, sessionStorage, or send it to your own analytics โ€” treat it like a credit-card CVV.

Reference implementation: account.ikavia.com/dashboard/api-keys (open dev tools, watch the network tab as you create a key to see the exact response shape).

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

Revoke marks the key as inactive; the row stays around for audit. Use DELETE /api/v1/api-keys/{id} only if the user explicitly wants the audit history gone.

EXAMPLE (server-side pseudocode)

If you'd rather skip the CORS allowlist request, proxy the call through your own backend. The Bearer token you forward IS the user's token โ€” your BE doesn't need its own credential.

// wacca BE handler โ€” runs after wacca's own auth middleware
// has validated and exposed the user's JWT.
fn list_service_accounts(user_jwt: String, org_id: String) {
    let response = http_client
        .get(format!(
            "https://account.ikavia.com/api/v1/service-accounts?organization_id={}",
            org_id
        ))
        .header("Authorization", format!("Bearer {}", user_jwt))
        .send()?;
    return response.json();
}

Same pattern for every other call. The user's JWT is the only credential you carry; your service has no separate identity in this flow.

EXAMPLE (client-side pseudocode)

After your subdomain has been added to the CORS allowlist:

// wacca FE โ€” assumes you store the JWT in memory or a
// cookie scoped to .ikavia.com (the SSO shared cookie).
const res = await fetch(
    `https://account.ikavia.com/api/v1/service-accounts?organization_id=${orgId}`,
    {
        method: 'GET',
        headers: { Authorization: `Bearer ${jwt}` },
        credentials: 'omit',  // bearer header, no cookies
    },
);
const { data } = await res.json();

credentials: 'omit' is correct here โ€” the cookie path (.ikavia.com-scoped session) is for human flows on account.ikavia.com itself. From your origin, the bearer token is the auth.

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 }

๐Ÿ“ฑ 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-or-set-password

๐Ÿ“ฑ Sign in with Google (Mobile)

End-to-end integration of Google Sign-In for native Android and iOS apps targeting account.ikavia.com. The backend endpoint and account-resolution rules are identical to the web flow (see the Sign in with Google endpoint in the Authentication section); the platform-specific work is on the mobile side where each SDK mints a Google ID token, plus a small amount of setup in Google Cloud Console.

The BE verifier accepts ID tokens whose aud claim matches any of the configured client ids โ€” Web, Android, and iOS may each use their own. Mobile teams do NOT need to coordinate a single shared "server client id"; the BE handles a list of audiences from one Google Cloud project.

Status on account.ikavia.com: both /auth/google and /auth/firebase are currently live. Web + Android OAuth Client IDs are wired into /auth/google; Firebase project wacca-77277 is wired into /auth/firebase. iOS OAuth Client ID is the only piece still pending (waiting on bundle identifier from the mobile team). See Step 1 for the full provisioning table.

Firebase Auth โ€” two supported patterns

Mobile teams that use Firebase Auth on the client have two equally-valid paths:

  • Pattern A โ€” dual-consume the raw Google token. Sign in

natively (Credential Manager / GoogleSignIn-iOS), POST the raw Google idToken to our /auth/google, and ALSO hand the same token to Firebase locally via GoogleAuthProvider.credential(...). See the "Using Firebase Auth in parallel" subsections under steps 3 and 4.

  • Pattern B โ€” send the Firebase-issued token directly.

Sign in via Firebase (any provider: Google, Apple, Facebook, email/password), grab the Firebase ID token via firebase.auth().currentUser.getIdToken(), and POST it to the dedicated /auth/firebase endpoint. The BE verifies Firebase's signature, resolves the account via firebase_uid โ†’ email โ†’ auto-create, and returns the same LoginResponse shape as /auth/google. See step 14.

Pattern A is recommended when the only provider is Google โ€” it's simpler and avoids an extra Firebase initialization. Pattern B is recommended when the app supports multiple providers through Firebase (Apple Sign-In, Facebook, etc.) โ€” one endpoint covers all of them.

Sequence at a glance

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”   1. open app           โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  Mobile  โ”‚ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”‚  Local      โ”‚
โ”‚  App     โ”‚                         โ”‚  secure     โ”‚
โ”‚          โ”‚   2. try cached tokens  โ”‚  storage    โ”‚
โ”‚          โ”‚ โ—„โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”‚ (Keystore   โ”‚
โ”‚          โ”‚                         โ”‚  / Keychain)โ”‚
โ””โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”˜                         โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
     โ”‚
     โ”‚ 3. no token / expired โ†’ tap "Continue with Google"
     โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”   4. show account picker   โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ Google   โ”‚ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–บ โ”‚ User     โ”‚
โ”‚ SDK      โ”‚                            โ””โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”˜
โ”‚ (GIS)    โ”‚   5. pick account              โ”‚
โ”‚          โ”‚ โ—„โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
โ”‚          โ”‚   6. mint ID token (signed JWT)
โ””โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”˜
     โ”‚
     โ”‚ 7. POST /api/v1/auth/google { credential, ... }
     โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  account.ikavia.com      โ”‚
โ”‚  - verify JWT vs JWKS    โ”‚
โ”‚  - check iss + aud + exp โ”‚
โ”‚  - find or auto-link or  โ”‚
โ”‚    auto-create account   โ”‚
โ”‚  - issue our access +    โ”‚
โ”‚    refresh JWT pair      โ”‚
โ””โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
     โ”‚
     โ”‚ 8. response: { access_token, refresh_token, created_now, ... }
     โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  Mobile  โ”‚  9. persist tokens in Keystore/Keychain
โ”‚  App     โ”‚ 10. attach `Authorization: Bearer <access>` to all
โ”‚          โ”‚     subsequent API calls
โ”‚          โ”‚ 11. on 401 โ†’ POST /auth/refresh โ†’ retry once
โ”‚          โ”‚ 12. on logout โ†’ /auth/logout + clear local storage
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Step 1: Provision platform-specific OAuth client ids in Google Cloud

The Web Client ID for account.ikavia.com already exists. Mobile needs one additional Client ID per platform in the same Google Cloud project. Both are created from console.cloud.google.com/auth/clients โ†’ + Create client.

Android

  • Application type: Android
  • Package name: the Android app's package

(e.g. com.ikavia.app). Must match applicationId in build.gradle.

  • SHA-1 certificate fingerprint:
  • Debug keystore: keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android -keypass android โ†’ take the SHA1 line.
  • Release: same command pointing at your release keystore.
  • If using Play App Signing, take the SHA-1 from Play

Console โ†’ Setup โ†’ App signing โ†’ App signing key certificate.

  • Add both debug and release fingerprints โ€” or create

two separate Client IDs if you prefer environment isolation.

iOS

  • Application type: iOS
  • Bundle ID: the iOS app's bundle identifier

(e.g. com.ikavia.app). Must match Xcode โ†’ target โ†’ General โ†’ Identity โ†’ Bundle Identifier.

  • App Store ID (optional): the numeric id from App

Store Connect once published. Skip if pre-launch.

Send the resulting Client IDs to ops; they'll be added to APP__SECURITY__GOOGLE_OAUTH__CLIENT_IDS (comma-separated) alongside the existing Web id, and the BE restarted.

Currently provisioned audiences on production (as of this doc revision; ask ops for the live list):

SurfaceClient ID / projectEndpoint enabled
Web836801358076-lfhjvt3lโ€ฆapps.googleusercontent.com/auth/google โœ…
Android836801358076-74v49lgqโ€ฆapps.googleusercontent.com/auth/google โœ…
iOSpending โ€” mobile team to supply bundle id/auth/google (will accept once provisioned)
Firebaseproject wacca-77277/auth/firebase โœ…

Both endpoints (POST /api/v1/auth/google and POST /api/v1/auth/firebase) are live. Smoke-tested on account.ikavia.com โ€” bogus tokens get a clean 401 rather than a 503, and Firebase JWKS is being cached in our Redis. Mobile team can integrate against either or both at any time without further ops coordination.

Step 2: Backend โ€” multi-audience configuration

Operator updates the deployment env so the verifier accepts the mobile tokens. Both forms below are accepted; the plural form is preferred.

# singular (legacy, still honoured)
APP__SECURITY__GOOGLE_OAUTH__CLIENT_ID=<web-client-id>.apps.googleusercontent.com

# plural (preferred). Comma-separated.
APP__SECURITY__GOOGLE_OAUTH__CLIENT_IDS=<web-id>.apps.googleusercontent.com,<android-id>.apps.googleusercontent.com,<ios-id>.apps.googleusercontent.com

Both env vars are merged at startup โ€” supplying either is fine. The startup log line Sign-In-With-Google enabled carries count=<n> so ops can confirm the right number of audiences was loaded. Restart account-service after editing.

Step 3: Android โ€” request an ID token via Credential Manager

The current Google-recommended path on Android is the Credential Manager API (androidx.credentials:credentials). The deprecated GoogleSignInClient still works but should not be used in new code.

Gradle (app/build.gradle):

// Pin to a known-good range. As of May 2026 the latest
// stable line is 1.3.x; bump only after a smoke test.
implementation "androidx.credentials:credentials:1.3.0"
implementation "androidx.credentials:credentials-play-services-auth:1.3.0"
implementation "com.google.android.libraries.identity.googleid:googleid:1.1.1"

Kotlin โ€” initiate the picker, get the ID token:

val credentialManager = CredentialManager.create(context)
val googleIdOption = GetGoogleIdOption.Builder()
    // false = show every Google account on the device
    // (best UX for first-time signin). true = only show
    // accounts that have previously signed in to this app.
    .setFilterByAuthorizedAccounts(false)
    // ANDROID Client ID from step 1.
    .setServerClientId("836801358076-74v49lgqvi48lf47ff4h90uon8pt5e8c.apps.googleusercontent.com")
    // Nonce binds this credential to one signin attempt;
    // currently not validated server-side but cheap to
    // include and useful when we tighten validation later.
    .setNonce(java.util.UUID.randomUUID().toString())
    .build()

val request = GetCredentialRequest.Builder()
    .addCredentialOption(googleIdOption)
    .build()

try {
    val result = credentialManager.getCredential(activityContext, request)
    val credential = result.credential
    if (credential is CustomCredential
        && credential.type == GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL) {
        val googleIdTokenCredential = GoogleIdTokenCredential.createFrom(credential.data)
        val idToken: String = googleIdTokenCredential.idToken
        // POST idToken to account.ikavia.com (see step 5).
    }
} catch (e: NoCredentialException) {
    // Device has zero Google accounts, or user dismissed
    // the bottom sheet without picking. UX: surface a
    // friendly "Try a different way to sign in" message
    // and keep the email-password form available.
} catch (e: GetCredentialCancellationException) {
    // User explicitly tapped "Cancel". Treat as a no-op,
    // do not show an error toast.
} catch (e: GetCredentialException) {
    // Other transient SDK errors. Log + retry-friendly
    // toast: "Google sign-in temporarily unavailable.
    // Please try again."
}

Handling NoCredentialException. Common when the emulator has no signed-in Google account or the user wipes accounts in Settings. Don't crash; fall back gracefully โ€” the email/password form is still wired and is the only path for users who deliberately don't keep a Google account on the device.

Using Firebase Auth in parallel (optional)

If the Android app also needs Firebase Auth on the client (for Firestore security rules, Cloud Functions callable auth, Crashlytics user binding, Remote Config personalization, etc.), use the same idToken for BOTH paths โ€” our BE and Firebase. Account-service only accepts raw Google ID tokens; it does NOT verify Firebase-issued tokens (different iss, different JWKS, different aud format).

// 1. idToken from Credential Manager above. Send it to
//    our BE first so the network call is in-flight.
val ourBackendDeferred = scope.async {
    loginToIkaviaWithGoogle(idToken)  // POST /api/v1/auth/google
}

// 2. In parallel, hand the SAME idToken to Firebase
//    so client-side Firebase services know who the
//    user is. Firebase mints its own internal token
//    locally โ€” we don't send it anywhere.
val firebaseCredential = GoogleAuthProvider.getCredential(idToken, null)
FirebaseAuth.getInstance().signInWithCredential(firebaseCredential).await()

// 3. Both paths now reflect the same user.
val ourBackendResponse = ourBackendDeferred.await()

Key points:

  • The idToken is single-use against Google's verifier

but both consumers (our BE and Firebase) verify it independently โ€” passing it to one does not consume it for the other. They can run in any order or in parallel.

  • Firebase will mint a Firebase UID for the user that

is different from the account_id returned by our BE. Don't try to reconcile them; treat the two as orthogonal identities โ€” Firebase UID for client-side Firebase rules, our account_id for anything that talks to account.ikavia.com.

  • On logout, call FirebaseAuth.getInstance().signOut()

alongside the steps in section 11.

Step 4: iOS โ€” request an ID token via GoogleSignIn-iOS

Use the official GoogleSignIn-iOS Swift package. Add it to the project via File โ†’ Add Packages:

https://github.com/google/GoogleSignIn-iOS

Info.plist โ€” register the URL scheme Google uses to return to your app. The scheme is the iOS Client ID reversed (e.g. Client ID 123-abc.apps.googleusercontent.com becomes scheme com.googleusercontent.apps.123-abc):

<key>CFBundleURLTypes</key>
<array>
    <dict>
        <key>CFBundleURLSchemes</key>
        <array>
            <string>com.googleusercontent.apps.<reversed-ios-client-id></string>
        </array>
    </dict>
</array>

Recent GoogleSignIn-iOS releases (7.x+) use ASWebAuthenticationSession under the hood and may not strictly require the URL scheme for the basic ID-token flow, but registering it is still recommended โ€” it's the documented fallback path and costs nothing.

Swift โ€” initiate the sign-in. The caller must be a live UIViewController (or NSViewController on macOS Catalyst); pass it as presenting:

import GoogleSignIn

// AppDelegate / SceneDelegate โ€” handle the redirect back.
func application(_ app: UIApplication, open url: URL,
                 options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
    GIDSignIn.sharedInstance.handle(url)
}

// From any UIViewController (the one mounting the
// "Continue with Google" button is the natural choice):
let config = GIDConfiguration(clientID: "<ios-client-id>.apps.googleusercontent.com")
GIDSignIn.sharedInstance.configuration = config
GIDSignIn.sharedInstance.signIn(withPresenting: self) { result, error in
    if let error = error {
        // GIDSignInError.canceled is the "user dismissed"
        // case โ€” treat as a silent no-op. Other codes
        // (network, presenter-not-available) deserve a
        // toast.
        if (error as NSError).code == GIDSignInError.canceled.rawValue { return }
        // surface other errors to the user
        return
    }
    guard let user = result?.user,
          let idToken = user.idToken?.tokenString else { return }
    // POST idToken to account.ikavia.com (see step 5).
}

Using Firebase Auth in parallel (optional)

Same idea as Android: send idToken to our BE, and also hand it to Firebase if the iOS app uses any Firebase client SDK (Firestore, Functions, Crashlytics user binding, Remote Config). Our BE does NOT accept Firebase-issued tokens โ€” only raw Google ones.

import FirebaseAuth
import GoogleSignIn

GIDSignIn.sharedInstance.signIn(withPresenting: self) { result, error in
    guard error == nil,
          let user = result?.user,
          let idToken = user.idToken?.tokenString else { return }
    let accessToken = user.accessToken.tokenString

    // 1. POST to our BE (kick off network early).
    Task { await loginToIkaviaWithGoogle(idToken: idToken) }

    // 2. In parallel, sign the user into Firebase locally.
    //    Firebase verifies the SAME idToken against
    //    Google's JWKS and mints its own internal
    //    session โ€” we don't send the Firebase token
    //    anywhere.
    let credential = GoogleAuthProvider.credential(
        withIDToken: idToken,
        accessToken: accessToken,
    )
    Auth.auth().signIn(with: credential) { _, error in
        // handle error; the BE login can still succeed
        // independently if this fails.
    }
}

Same caveats as Android:

  • idToken can be verified by both Google (Firebase

does this) and our BE in any order; the two paths are independent.

  • The Firebase UID (Auth.auth().currentUser?.uid)

is not the same identifier as our account_id. Keep them in separate variables and send the right one to the right service.

  • On logout, call try? Auth.auth().signOut()

alongside the steps in section 11.

Step 5: POST the ID token to account-service

Identical contract for both platforms โ€” idToken is the raw JWT the SDK just returned. The response shape mirrors password login, with two mobile-relevant additions:

  • created_now โ€” true if THIS request created the

account. Use it to gate first-time onboarding (welcome screen, profile completion prompt). false for every subsequent login.

  • csrf_token โ€” **mobile clients should ignore this

field.** It exists for browser-side CSRF defence and is irrelevant when the caller authenticates with Authorization: Bearer ....

device_timezone / device_language are applied only on auto-signup (first-ever sign-in for that email). On subsequent logins they are ignored โ€” the user owns those settings via the profile endpoint.

POST /api/v1/auth/google

Service: account-service

Content-Type: application/json

Fields

FieldTypeRequiredDescription
credential string required The Google ID token (raw JWT) returned by the platform SDK.
persist_session bool optional Cosmetic on mobile โ€” mobile clients ignore the SSO refresh cookie this controls and just read access_token + refresh_token from the response body. Send true (default) to be consistent with web.
device_timezone string optional IANA timezone (e.g. Asia/Jakarta). Used as the default only when this request triggers auto-signup. Length cap 64 chars; invalid / overlong values silently fall back to UTC.
device_language string optional ISO 639-1 language code (e.g. id). Applied only on auto-signup. Length cap 8 chars; invalid / overlong fall back to en.
cURL Example
curl -X POST https://account.ikavia.com/api/v1/auth/google \
  -H 'Content-Type: application/json' \
  -d '{
    "credential": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjE4Y...",
    "device_timezone": "Asia/Jakarta",
    "device_language": "id"
  }'
Response Example
{
  "success": true,
  "data": {
    "account_id": "550e8400-e29b-41d4-a716-446655440000",
    "email": "user@gmail.com",
    "display_name": "Jane Doe",
    "access_token": "eyJ...",
    "refresh_token": "eyJ...",
    "expires_in": 7200,
    "organizations": [ { "...": "..." } ],
    "current_org_id": "550e8400-...",
    "requires_email_verification": false,
    "created_now": true,
    "csrf_token": "f3a1c0..."
  }
}

Step 6: Error responses and how to handle them

The endpoint returns one of these on failure. Always read code (numeric) rather than HTTP status โ€” the mapping is stable while HTTP status may collapse multiple codes onto the same number.

  • code: 4003 HTTP 401 โ€” invalid google credential.

Token signature failed, expired, has the wrong audience, wrong issuer, or email_verified=false from Google. UX: surface "We couldn't sign you in with Google. Please try again or use email/password." Do NOT retry automatically โ€” the token won't get better.

  • code: 4005 HTTP 409 โ€” google_sub duplicate.

The same Google identity is already linked to a different account. Almost certainly a developer-side mistake (testing with a stale build that hardcoded a sub). Surface "This Google account is already linked to another user." and log full response for triage.

  • code: 4008 HTTP 429 โ€” rate limit exceeded.

Five-per-minute-per-IP. The response body carries retry_after (seconds). UX: disable the button for retry_after seconds, show a countdown.

  • code: 5000 HTTP 503 โ€” feature disabled.

Operator hasn't configured any Client ID. Should never happen in prod once integrated; if it does, log and tell the user "Google sign-in is temporarily unavailable" โ€” this is an ops problem, not a user-recoverable error.

  • Network / 5xx. Treat as transient. Allow the user

to retry; consider a short auto-retry with exponential backoff (1s โ†’ 3s โ†’ 9s) before showing a permanent error.

Common Google-side issues that produce 4003:

  • Wrong serverClientId in the SDK call โ€” the BE doesn't

recognise that audience. Ops must add it to CLIENT_IDS.

  • User signed into a Google Workspace account whose admin

blocks third-party app access. Nothing the app can do; ask the user to try a personal Google account.

  • Clock skew on the device. The verifier tolerates 60s of

skew but anything beyond that breaks exp validation. Encourage user to enable automatic time on the device.

Step 7: Secure token storage

The access_token has a short lifetime (2 hours) but the refresh_token is long-lived (7 days; see the deployment's APP__SECURITY__JWT__REFRESH_TOKEN_TTL_DAYS) and grants the same authority as the user's password while it remains valid. It MUST NOT be stored in any plaintext key/value store.

Android โ€” use the Keystore-backed EncryptedSharedPreferences:

val masterKey = MasterKey.Builder(context)
    .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
    .build()
val prefs = EncryptedSharedPreferences.create(
    context,
    "ikavia_auth",
    masterKey,
    EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
    EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
prefs.edit()
    .putString("access_token", accessToken)
    .putString("refresh_token", refreshToken)
    .putLong("access_expires_at_epoch_ms",
             System.currentTimeMillis() + expiresInSeconds * 1000L)
    .apply()

For higher assurance (financial / health data), use the AndroidKeyStore directly with a hardware-backed key โ€” setUserAuthenticationRequired(true) forces a biometric unlock per use.

iOS โ€” use Keychain. The simplest path is the KeychainAccess wrapper. Pin a recent version (4.x).

let keychain = Keychain(service: "com.ikavia.app.auth")
    .accessibility(.whenUnlockedThisDeviceOnly)
try keychain.set(accessToken, key: "access_token")
try keychain.set(refreshToken, key: "refresh_token")
try keychain.set(String(accessExpiresAtUnix), key: "access_expires_at_unix")

whenUnlockedThisDeviceOnly prevents the token from syncing to iCloud Keychain or restoring to a different device โ€” the right default for an auth credential.

Never log refresh_token โ€” scrub it from analytics and crash reporting payloads. Configure Crashlytics / Sentry / Datadog to redact keys named refresh_token, access_token, credential, Authorization.

Step 8: Bootstrap on app launch

On every cold start, the app needs to decide whether the user is still signed in. The recommended algorithm:

1. Read access_token, refresh_token, access_expires_at_* from secure storage. 2. If no refresh_token โ†’ show the sign-in screen. Done. 3. If access_expires_at_* is in the future with at least 60s of headroom โ†’ user is "signed in"; mount the home screen and attach the cached access_token to the first protected call. 4. Otherwise โ†’ call POST /auth/refresh (step 9) before the first protected call. On success update storage and proceed. On failure (any error) โ†’ wipe local tokens and route to sign-in.

The 60-second headroom in step 3 prevents the rare race where an access_token is valid at the moment of the first request but expires mid-flight. Adjust if your backend latency is unusually high.

Step 9: Refresh-on-401 policy

On HTTP 401 from any protected endpoint, the app should:

1. Acquire a lock (mutex / coroutine actor). If a refresh is already in flight, wait for that result rather than firing a parallel refresh. Two parallel refreshes against the same refresh_token rotate it at the same time, then both retries 401-fail because the old refresh_token was just revoked by the first call. 2. Call /auth/refresh once. 3. On success โ€” update storage, retry the original request exactly once with the new access_token. If that also 401s, give up (the server thinks the user is gone; don't infinite-loop) โ€” wipe local tokens, route to sign-in. 4. On 401 from refresh itself โ€” the session was revoked server-side (logout-all, admin reset, refresh token theft detection). Wipe local tokens, route to sign-in. Do NOT call /auth/refresh again. 5. On network / 5xx from refresh โ€” surface the error; don't wipe tokens. The user retries the action and the refresh is attempted again.

POST /api/v1/auth/refresh

Service: account-service

Content-Type: application/json

Fields

FieldTypeRequiredDescription
refresh_token string required The refresh token stored in step 7. NOT the access token.
cURL Example
curl -X POST https://account.ikavia.com/api/v1/auth/refresh \
  -H 'Content-Type: application/json' \
  -d '{ "refresh_token": "eyJ..." }'
Response Example
{
  "success": true,
  "data": {
    "access_token": "eyJ...",
    "refresh_token": "eyJ...",
    "expires_in": 7200
  }
}

Step 10: Attach `Authorization: Bearer โ€ฆ` to every protected call

After login or refresh succeeds, every subsequent call to a protected endpoint (anything under /api/v1 that isn't /auth/...) must carry the access token in the Authorization header. There is no cookie path for mobile โ€” mobile clients do NOT participate in the SSO refresh-cookie flow that web uses.

Android (OkHttp interceptor):

class BearerAuthInterceptor(
    private val tokens: () -> String?, // reads from EncryptedSharedPreferences
) : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        val req = chain.request().newBuilder().apply {
            tokens()?.let { addHeader("Authorization", "Bearer $it") }
        }.build()
        return chain.proceed(req)
    }
}

iOS (URLSession):

var request = URLRequest(url: url)
if let token = keychain["access_token"] {
    request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}

Step 11: Logout (and the difference vs full Google revoke)

There are two distinct user actions, with two different server-side effects:

  • "Log out" โ€” sign out of this app, but keep the

Google account linked. Next time the user opens the app and taps "Continue with Google", they get the same account back with one tap (Google remembers them).

  • "Disconnect Google account" โ€” fully revoke the

authorization grant on Google's side. The next sign-in attempt re-prompts for consent. Rare; offer it as a buried option in account settings.

The flow for plain logout:

1. Call /auth/logout to revoke the session on the BE. 2. Wipe local secure storage. 3. Call the platform SDK's local-sign-out so the next Credential Manager / GIDSignIn invocation re-prompts:

  • Android (Credential Manager):

credentialManager.clearCredentialState(ClearCredentialStateRequest())

  • iOS (GoogleSignIn):

GIDSignIn.sharedInstance.signOut()

For "disconnect", also call GIDSignIn.sharedInstance.disconnect { โ€ฆ } on iOS, or revoke via the Google account-settings web link on Android (the Credential Manager API has no equivalent revoke call as of May 2026).

POST /api/v1/auth/logout

Service: account-service

Content-Type: application/json

Fields

FieldTypeRequiredDescription
refresh_token string required The refresh token to revoke. After this call the JTI is added to the revocation list and the session row is marked inactive.
cURL Example
curl -X POST https://account.ikavia.com/api/v1/auth/logout \
  -H 'Authorization: Bearer <access_token>' \
  -H 'Content-Type: application/json' \
  -d '{ "refresh_token": "eyJ..." }'

Step 12: Testing checklist

Run through this checklist on each platform before shipping the integration. None of these scenarios require coordinating with ops โ€” just one debug build and a couple of Google accounts.

Happy path

  • Sign in with a brand-new Google account whose email is

NOT in our DB. Expect: created_now: true, display_name matches the Google account name, timezone / language reflect what the device sent.

  • Sign out, sign in again with the same Google account.

Expect: created_now: false, same account_id as before, same display_name.

  • Sign in with a Google account whose email IS already in

our DB (register that email first via the web flow, then sign in with Google on mobile). Expect: created_now: false, same account_id the web account has, and from that point the user can sign in with EITHER password or Google.

Edge cases

  • Device with zero Google accounts โ†’ NoCredentialException

(Android) or signIn error (iOS). UI should not crash and should keep email/password available.

  • Tap "Cancel" on the account picker โ†’ silent no-op, no

error toast, no log.

  • Airplane mode during sign-in โ†’ SDK error before the BE

call. Toast should be retry-friendly.

  • Airplane mode during the /auth/google POST โ†’ network

error after the SDK succeeded. The Google credential is already consumed; the UI should let the user retry the same login (the SDK will mint a fresh credential).

  • Sign-in with the same Google account on two devices โ†’

both should succeed and own independent sessions. Logging out on device A must NOT log out device B.

  • /auth/refresh after the refresh-token TTL (currently

7 days) of inactivity โ†’ expected to fail with 401. Confirm the app routes to sign-in without crashing.

Security checks

  • Inspect the app's EncryptedSharedPreferences /

Keychain entries with the debugger. Confirm refresh_token is unreadable from a non-app process.

  • Inspect Crashlytics / Sentry payloads on a forced

crash. Confirm no auth field is present.

  • Run frida or equivalent against a release build with

ProGuard / R8 on. Confirm Client IDs are not the only secret protecting the flow (they aren't โ€” Google's signature is).

Step 13: Operational notes

A small grab-bag of facts that catch teams off-guard once they've shipped:

  • Same-project requirement. Android / iOS / Web

Client IDs must live in the same Google Cloud project. A token minted in project A cannot be verified by a key from project B.

  • email_verified=false is rejected. If Google can't

confirm the user owns the email, the BE returns 401. This happens for federated Workspace accounts that haven't completed verification on Google's side; the user should resolve it with their admin, not retry.

  • Account linking is automatic by email. If the

user's Google email already has a password row in our DB (created via /auth/register), the first Google sign-in auto-links โ€” sets google_sub on the existing row. No conflict prompt; Google's email verification is considered sufficient proof of ownership.

  • Account creation is automatic. If the email is new,

account-service creates a row + personal organization + five system roles in one transaction. The response's created_now is true and requires_email_verification is false.

  • sub is the stable identifier. Email can change

owner inside a Google Workspace; sub never does. The BE persists accounts.google_sub on first link and uses it as the fast path on subsequent logins, falling back to email match only when google_sub is null.

  • JWKS is cached. The BE caches Google's signing

keys in Redis (TTL โ‰ˆ 6 hours by default, derived from Google's Cache-Control header). A key rotation forces a one-shot refresh on the next kid miss. Operators normally don't need to do anything around rotation.

Step 14: Alternative endpoint: POST a Firebase token to /auth/firebase

When the app uses Firebase Auth as its primary client-side auth layer โ€” typically to unlock multi-provider sign-in (Apple, Facebook, Microsoft, โ€ฆ) without writing a verifier per provider โ€” send the Firebase-issued ID token to the dedicated /auth/firebase endpoint instead of unwrapping to a raw Google token.

Acquiring the Firebase ID token (Android):

// After FirebaseAuth.getInstance().signInWithCredential(...)
// completes successfully:
val firebaseUser = FirebaseAuth.getInstance().currentUser
    ?: return  // shouldn't happen if signIn succeeded
firebaseUser.getIdToken(/* forceRefresh = */ false)
    .addOnSuccessListener { result ->
        val firebaseIdToken = result.token  // <-- send THIS
        // POST to /api/v1/auth/firebase
    }

Acquiring the Firebase ID token (iOS):

// After Auth.auth().signIn(with: credential) completes:
Auth.auth().currentUser?.getIDToken { idToken, error in
    guard let firebaseIdToken = idToken else { return }
    // POST firebaseIdToken to /api/v1/auth/firebase
}

Account resolution differs from /auth/google in one key way: the link column on accounts is firebase_uid (Firebase's UID), NOT google_sub. A user who signs in via BOTH endpoints ends up with both columns populated on the same row, linked by their verified email โ€” Step 13 (Operational notes) covers the linking semantics in detail.

The wire format is identical to /auth/google:

POST /api/v1/auth/firebase

Service: account-service

Content-Type: application/json

Fields

FieldTypeRequiredDescription
credential string required Firebase ID token (raw JWT). iss=https://securetoken.google.com/<project_id>, aud=<project_id>. Anonymous / custom-token sign-ins (no email) are rejected with 4003.
persist_session bool optional Cosmetic on mobile; send true (default) for consistency with web.
device_timezone string optional Applied only on auto-signup. Length cap 64, invalid โ†’ UTC.
device_language string optional Applied only on auto-signup. Length cap 8, invalid โ†’ en.
cURL Example
curl -X POST https://account.ikavia.com/api/v1/auth/firebase \
  -H 'Content-Type: application/json' \
  -d '{
    "credential": "eyJhbGciOiJSUzI1NiIsImtpZCI6Ijc2N...",
    "device_timezone": "Asia/Jakarta",
    "device_language": "id"
  }'
Response Example
{
  "success": true,
  "data": {
    "account_id": "550e8400-...",
    "email": "user@gmail.com",
    "display_name": "Jane Doe",
    "access_token": "eyJ...",
    "refresh_token": "eyJ...",
    "expires_in": 7200,
    "organizations": [ { "...": "..." } ],
    "current_org_id": "550e8400-...",
    "requires_email_verification": false,
    "created_now": false,
    "csrf_token": "f3a1c0..."
  }
}

๐Ÿš€ 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 }

๐Ÿ“ก Event contract โ€” quick map for replica consumers

One-page checklist for sibling services that maintain a local read-only projection of account state. Each row maps to the detail section below. If you skim only one block in this page, read this one.

Got a specific "is this delta or snapshot / does X event fire / how do I order / how do I dedup" question? Jump to replica-consumer-faq โ€” that section answers the exact questions we receive from consumer teams, in their phrasing, with the authoritative answer inline.

Status against the standard replica checklist

NeedStatusWhere it lives
Payload schema for every event touching account dataโœ…Per-event sections below. Field list with type + required-ness for all 25 events.
account.account.createdโœ…Section Account Created (outbound). Body has account_id, account_type, email, organization_id, timestamp.
account.account.deletedโœ…Section Account Deleted (outbound). Body has account_id, deleted_by, was_email, timestamp. Fires from both admin two-step hard-delete AND service-initiated transactional rollback.
account.account.deactivated (soft-close tombstone hint)โœ…Section Account Deactivated (outbound). Row stays queryable (status='deactivated'). Use this OR account.account.deleted per your tombstone semantics.
event_id โ€” idempotency keyโœ…Every event body has event_id (UUID). Mirrored to AMQP header x-event-id so middleware can dedup without parsing JSON.
occurred_at โ€” ordering markerโœ…Every event body has timestamp (RFC3339, or joined_at/changed_at/left_at on a few). Mirrored to AMQP header x-occurred-at. Discard inbound when x-occurred-at < replica.last_applied_at.
schema_version on the envelopeโœ…AMQP header x-schema-version: "1". Bumped on a breaking body change for a given routing key. Stringly typed so cross-language clients parse uniformly.
Delivery semantics documentedโœ…Section Delivery semantics + AMQP headers. TL;DR: best-effort publish, at-least-once consume, ordering not globally guaranteed, persistent (delivery_mode=2), DB is source of truth.
Final exchange + routing keysโœ…Exchange account.events (topic, durable). All routing keys in section Events catalogue.

Minimum-viable consumer (pseudocode)

on_message(headers, body):
    eid = headers["x-event-id"]                          # dedup key
    if already_applied(eid): return ack()
    occurred_at = parse_rfc3339(headers["x-occurred-at"])
    rk = headers["x-event-type"]                         # e.g. "account.updated"
    if rk == "account.deleted":
        tombstone(body["AccountDeleted"]["account_id"])
        mark_applied(eid); return ack()
    if rk == "account.deactivated":
        mark_inactive(body["AccountDeactivated"]["account_id"])
        mark_applied(eid); return ack()
    if rk == "account.updated" or rk == "account.created":
        account_id = first_value_of(body)["account_id"]
        if occurred_at < replica.last_applied_at(account_id):
            return ack()                                 # stale, skip
        refetch_via_rest_and_upsert(account_id, occurred_at)
        mark_applied(eid); return ack()
    # ...other events handled per business need

What replicas should NOT depend on

  • Per-routing-key ordering โ€” multiple publisher workers can

reorder; always trust x-occurred-at.

  • Field values for fields NOT in this contract. Existing field

types/names are stable; net-new optional fields may be added without a version bump.

  • The wire format of these variants โ€” they are declared in the

DomainEvent enum but never emitted: auth.login_failed, auth.logout, auth.session_revoked. Don't build handlers for them; we'll announce + version-bump if that changes.

REST companion for backfill / reconciliation

Pair this with the S2S lookup endpoints when bootstrapping a replica or running periodic reconciliation:

  • GET /api/v1/accounts/{id} โ†’ single fetch.
  • POST /api/v1/accounts/bulk-lookup (โ‰ค 100 ids/request) โ†’ batch refresh.

Daily reconciliation: enumerate every account_id your replica holds โ†’ bulk-lookup โ†’ drop entries that 404 โ†’ upsert the rest.

๐Ÿ“ก Event contract โ€” frequently-asked semantics

The exact questions consumer teams send us when wiring up an account-events replica. Each answer is authoritative โ€” bookmark this section if your team is at the "do I trust this assumption?" stage of integration. Cross-references jump to the detail section that backs the answer.

Q: Is account.account.updated delta or snapshot?

A: Delta-by-default with explicit clear-to-null signal. The payload is not a snapshot of the account row; it carries the changed fields, no more. Read updated_fields[] FIRST โ€” that array is authoritative on what changed in this commit.

Three-state decision per mirror field (display_name, bio, phone_number, timezone, language, avatar_media_id, username):

Field name in updated_fields[]?Field key present in body?Meaning
No(irrelevant)Unchanged โ€” don't touch
YesYesNew value โ€” overwrite
YesNoCleared to null โ€” null out

The third row is the one most consumers miss. Absent fields are omitted by the wire-format and that absence is meaningful when the name appears in updated_fields[]. Treating "absent = unchanged" without the array check will silently keep stale values when a user clears their avatar / bio / phone.

email never appears here โ€” email changes ride a dedicated account.account.email_changed event with both old_email and new_email. Detail: account-updated.

Q: Does a deactivated account ever come back to Active?

A: No. Deactivated is terminal for the account_id. There is no reactivation transition, and no event is published for one. A replica that tombstones the account_id on account.account.deactivated is correct and complete for that identity.

If the same human comes back, they re-register with the same email. That produces a fresh row with a new account_id (different UUID) and a fresh account.account.created event. Email matches the old account; account_id does not.

Pattern for replicas that want to detect re-registration:

1. On account.account.deactivated โ†’ tombstone the old account_id permanently. 2. Subscribe account.account.created and key the handler on the body's email in addition to account_id. 3. If the email matches a tombstoned row, record a bridge (old_account_id โ†’ new_account_id) in your own table. Do not reuse the old account_id.

Detail: account-deactivated.

Q: My replica missed events during a deploy / outage. How do I catch up?

A: Pair the event stream with the S2S REST resolvers.

Events are best-effort publish, at-least-once consume โ€” a broker outage on our side or a deploy on yours can drop or delay messages. The DB is the source of truth; the events are the fast path. Reconcile periodically:

  • GET /api/v1/accounts/{id} โ€” single fetch when a specific

replica entry looks stale.

  • POST /api/v1/accounts/bulk-lookup (โ‰ค 100 ids/request) โ€”

batch refresh.

  • For replicas with > 100 accounts: chunk the bulk-lookup at

100 per request.

Daily reconciliation pattern: enumerate every account_id your replica holds โ†’ bulk-lookup โ†’ drop entries that 404 โ†’ upsert the rest. Catches anything the publisher dropped during a broker outage. Detail: delivery-semantics.

Q: Can I bind to wildcards (e.g., account.account.*)?

A: Yes. The exchange account.events is a topic exchange. Bind your queue with any RabbitMQ topic wildcard:

  • account.account.* โ€” every account-lifecycle event.
  • account.org.* โ€” every org-lifecycle event.
  • account.# โ€” every event we publish (broad, mostly for

debugging โ€” production consumers should bind narrowly).

Wildcard binding doesn't change delivery semantics; you still get at-least-once with the same headers per event.

Q: How do I order events when two arrive out of sequence?

A: Use the x-occurred-at AMQP header. Per-routing-key ordering is NOT guaranteed (multiple publisher workers can reorder). Keep a last_applied_occurred_at per record in your replica; discard inbound events whose x-occurred-at is older.

Don't trust delivery order, even within the same account_id.

Q: How do I dedup at-least-once redeliveries?

A: Use the x-event-id AMQP header. Every event carries a unique UUID stamped both in the body (event_id) and at the AMQP envelope level (x-event-id). Persist (routing_key, event_id) per applied event and skip duplicates.

Header form is the easier integration point โ€” middleware can dedup without parsing the JSON body. Detail: delivery-semantics.

Q: How will I know when the event contract changes?

A: The AMQP header x-schema-version is the version marker. Today every event ships x-schema-version: "1". Additive changes (new optional fields) do NOT bump the version โ€” consumers tolerating unknown fields keep working. Breaking changes (field renamed, type changed, semantic changed) DO bump it. Compare on string equality and fail- closed on an unrecognised value.

Q: Which variants in the DomainEvent enum are NOT emitted today?

A: Three variants are reserved but never published โ€” auth.login_failed, auth.logout, auth.session_revoked. Don't build handlers for them; we'll announce + version-bump if any of those start firing. auth.login_success IS emitted but only on the SSO login paths (Google / Firebase) โ€” password login writes only to audit_logs.

๐Ÿ“ก Events catalogue

Every domain event the service publishes (and the one it consumes) at a glance. Exchange: account.events (topic). Every emitted event's routing key is prefixed with account. by the publisher, so the table below shows the wire key (what consumers bind to), not the raw event_type string in the envelope.

The envelope is always {"<VariantName>": { ... }} โ€” that's the DomainEvent enum tag plus the per-event payload. Bind to account.account.* for every account-lifecycle event, account.org.* for every org-lifecycle event, etc., or to a specific key like account.account.updated for one event.

Detailed payload schemas are in the sections below the catalogue.

Account lifecycle (outbound)

Routing keyEmitted when
account.account.createdA new account is registered (human via /register or service via admin UI).
account.account.activatedAccount transitions Pending โ†’ Active (verify-email click, SSO activation).
account.account.deactivatedUser calls POST /me/deactivate. Soft close (row still queryable).
account.account.deletedAccount row is physically removed (admin two-step hard-delete or service-initiated transactional rollback). Replicas should tombstone immediately.
account.account.email_changedEmail change confirm flow completes. Old + new email in payload.
account.account.password_changedPassword is changed (self via /me/password, or admin reset).
account.account.updatedMirror-field change (display_name / bio / phone / timezone / language / avatar).

Auth (outbound)

Routing keyEmitted when
account.auth.login_successSSO login completes (Google / Firebase). NOT emitted by the password login path today โ€” that path writes only to audit_logs.

The following auth variants exist in the DomainEvent enum but are not currently emitted by any code path: auth.login_failed, auth.logout, auth.session_revoked. Reserved for future use โ€” don't build consumers expecting them.

Organization lifecycle (outbound)

Routing keyEmitted when
account.org.createdNew organization is created.
account.org.updatedOrganization profile fields change.
account.org.deletedOrganization is deleted (admin hard-delete cascade).
account.org.member_joinedAccount joins an org (via invitation accept or signup).
account.org.member_leftAccount leaves an org (self or admin removal).
account.org.role_changedA member's role changes inside an org.
account.org.member_invitedAn invitation is issued.
account.org.invitation_acceptedInvitation is accepted (companion to org.member_joined).
account.org.invitation_cancelledInvitation is cancelled by the inviter / admin.

Service-account + API-key lifecycle (outbound)

Routing keyEmitted when
account.service_account.createdAn org creates a service account.
account.service_account.updatedService-account profile/config changes.
account.service_account.pausedService account is paused.
account.service_account.resumedService account is resumed.
account.service_account.archivedService account is archived (irreversible).
account.api_key.createdAPI key is issued for a service account.
account.api_key.revokedAPI key is revoked.

Email delivery (outbound)

Routing keyEmitted when
account.email.requestedService needs an outbound email sent (verification, reset, invitation, etc.). Consumed by email-service. See the detailed section below.

Inbound

Routing keyConsumed when
account.profile.update_requestedA sibling service wants to update a user's profile (username, display_name, bio, timezone, language) without holding the user's JWT.

๐Ÿ“ก Delivery semantics + AMQP headers

The contract sibling services should code against. Stable; changes to this block constitute a wire-level event.

Exchange + persistence

  • Exchange: account.events โ€” topic, durable. Bind your

queue with the routing keys from the catalogue above (or wildcards like account.account.*).

  • Persistence: every message is published with

delivery_mode=2 (persistent). Events survive a broker restart in the queue they're delivered to.

  • Best-effort publish: the publisher is in-process and

bounded. If the broker is down, the user-facing request that triggered the event still succeeds โ€” the publish is queued for retry (default 3 attempts with backoff) and dropped if retries are exhausted. The DB row is the source of truth. Consumer reconciliation jobs should periodically diff replicas against the REST S2S lookup for state that matters.

  • At-least-once delivery: the broker stores until the

consumer acks. A consumer crash or nack triggers redelivery, so handlers MUST be idempotent (the x-event-id / event_id makes this easy โ€” see "Headers" below).

  • Ordering is NOT globally guaranteed. Per-routing-key

ordering is also not guaranteed: events that fan-out across publisher worker threads can arrive out-of-order. Use x-occurred-at (or body.timestamp) at the consumer to decide whether to apply an event โ€” discard an event whose occurred_at is older than the replica's last-applied timestamp for that record.

Envelope shape

The JSON body is the externally-tagged DomainEvent enum:

{ "AccountUpdated": { "event_id": "...", "account_id": "...", ... } }

The only exception is email.requested โ€” it serializes the inner EmailRequestedEvent flat (no enum wrapper) because email-service was built against that shape long before this catalogue existed. Don't depend on flat-vs-wrapped consistency across all events.

AMQP message headers

Every published message carries the following AMQP headers (in BasicProperties.headers). Consumers can read these without parsing the JSON body โ€” useful for routing, dedup, and ordering decisions in middleware.

HeaderTypePurpose
x-event-idstring (UUID)Unique per event. Mirror of body.event_id. Use as your dedup key for at-least-once redelivery.
x-event-typestringRouting key without the account. prefix (e.g. account.updated, org.member_joined).
x-occurred-atstring (RFC3339)Wall-clock time the event happened. Use for out-of-order discard.
x-schema-versionstring (currently "1")Bumped on a breaking change to the event body for that routing key. If you see a higher value than you understand, stop applying and alert.
x-trace-idstring (UUID)Trace id from the request that produced the event. Carry into your handler's tracing context for end-to-end correlation.

Headers are stringly typed across the board so cross-language AMQP clients (Go / Python / Node) deserialize them uniformly without integer-type juggling.

Bumping x-schema-version

"1" is the current value for every event in this catalogue. Additive payload changes (new optional fields) do NOT bump the version โ€” consumers tolerate unknown fields. Removing or renaming a field, changing a field type, or changing the envelope shape DOES bump it. Consumers should compare on string equality and fail-closed on an unrecognised version.

Reconciliation guidance for replicas

A consumer that maintains a local read-only projection of account state should:

1. Idempotency: persist (routing_key, x-event-id) per applied event. Skip duplicates. 2. Ordering: store last_applied_occurred_at per record; discard inbound events older than that. 3. Tombstone fast: on account.account.deleted mark the local row tombstoned in the same transaction that records the event id. On account.account.deactivated, tombstone (or mark inactive) โ€” the row is terminal: the domain rejects any Deactivated โ†’ Active transition with AlreadyDeactivated, so the same account_id will not come back. If the same user later re-registers with the same email, that's a brand-new row with a brand-new account_id (account.account.created fires) โ€” see "Reactivation" in account-deactivated. 4. Reconcile periodically: a daily job that fetches every live account_id via the S2S lookup catches anything the publisher dropped on a broker outage. Without this, gaps can persist indefinitely.

๐Ÿ“ก 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
}

๐Ÿ“ก Profile Update Requested (inbound)

Inbound channel: other services can ask account-service to edit a user's username and / or display_name on their behalf. The account service owns the canonical row; this event is how a caller (planner, email, โ€ฆ) proposes a change without holding the user's JWT.

Trust model is intra-cluster โ€” the broker runs on the host network and only local services hold credentials. Every published message is accepted as authoritative; include actor and correlation_id so audits and cross-system traces stay joinable.

Validation happens at the consumer. Username rules: 3-64 chars, charset [A-Za-z0-9._@+\-], case-insensitive uniqueness. Failures classify as either poison (validation, not-found, conflict โ€” message is dropped, not requeued) or transient (DB hiccup โ€” requeued with exponential backoff). A successful apply triggers an account.account.updated publish (see below) so subscribers learn the new value without an HTTP round-trip.

Omitted / explicit-null fields are no-ops โ€” the consumer never wipes a column from a missing key. Sending the message with all editable fields null is silently ack'd (treated as a no-op).

Protocol: amqp
Address: exchange: account.events / routing key: account.profile.update_requested / queue: account.profile.update_requested

Inbound profile-update request

The account-service runs a single durable consumer on the account.profile.update_requested queue (bound to the account.events topic exchange with the matching routing key). Prefetch = 8.

Payload

FieldTypeRequiredDescription
account_id string (uuid) yes Target account whose profile is being edited.
username string no New handle. 3-64 chars, charset [A-Za-z0-9._@+\-]. Omit / null to leave unchanged.
display_name string no New display name. Omit / null to leave unchanged.
bio string no Optional bio update.
timezone string no Optional IANA timezone.
language string no Optional ISO 639-1 code.
actor string no Free-form audit hint, e.g. planner.assign-task. Logged, not persisted.
correlation_id string no Caller-chosen id echoed in logs so the caller's trace + ours are joinable.

Example

{
  "account_id": "1c37ff2c-2f42-4d86-94d3-a20163909ccf",
  "username": "jane.d",
  "display_name": "Jane D.",
  "actor": "planner.assign-task",
  "correlation_id": "3f8a-1c2b-9912"
}

๐Ÿ“ก Account Updated (outbound)

Emitted after any successful change to a profile row โ€” whether the trigger was the HTTP PUT /api/v1/me endpoint, the avatar change, or the inbound account.profile.update_requested event. Downstream services use this to refresh their cached mirror of the user's profile without an HTTP round-trip.

The payload carries the new value of every changed mirror field: username, display_name, bio, phone_number, timezone, language, avatar_media_id.

Consumer contract. updated_fields is authoritative โ€” it lists exactly what changed. For each field named there, read the same-named key for the new value. A field that IS in updated_fields but whose key is absent was cleared to null (e.g. avatar removed, phone cleared) โ€” null values are omitted via skip_serializing_if to keep the payload small. A field NOT in updated_fields did not change: ignore it, do not wipe your cached value.

email is not carried here โ€” email changes ship via the dedicated account.account.email_changed event. metadata is also omitted (can be large / service-account-only); fetch via HTTP if you mirror it.

Routing: published with key account.account.updated (the publisher prefixes every event_type with account.). Bind your queue to account.account.updated for this single event or account.account.* to receive every account-lifecycle change.

Protocol: amqp
Address: exchange: account.events / routing key: account.account.updated

publish Profile mirror update

Best-effort: a broker outage logs and continues โ€” the DB row is the source of truth. Consumers should be idempotent (events may be redelivered after a retry).

The outer JSON envelope is the DomainEvent enum tag ({"AccountUpdated": { ... }}), matching every other non-email account event published by this service.

Payload

FieldTypeRequiredDescription
AccountUpdated.event_id string (uuid) yes Unique id for this emission. Use it for at-least-once dedup.
AccountUpdated.account_id string (uuid) yes Subject account.
AccountUpdated.updated_fields string[] yes Authoritative list of fields that changed (e.g. ["display_name", "avatar_media_id"]).
AccountUpdated.username string no New username. Present when updated_fields includes username.
AccountUpdated.display_name string no New display name. Present when updated_fields includes display_name.
AccountUpdated.bio string no New bio. Present when changed; absent-but-listed = cleared.
AccountUpdated.phone_number string no New phone number. Present when changed; absent-but-listed = cleared.
AccountUpdated.timezone string no New IANA timezone. Present when updated_fields includes timezone.
AccountUpdated.language string no New ISO 639-1 language. Present when updated_fields includes language.
AccountUpdated.avatar_media_id string (uuid) no New avatar media id. Present when changed; absent-but-listed = avatar cleared.
AccountUpdated.timestamp string (rfc3339) yes When the change committed.

Example

{
  "AccountUpdated": {
    "event_id": "c96d13d6-6e31-4eea-9038-881899623f74",
    "account_id": "1c37ff2c-2f42-4d86-94d3-a20163909ccf",
    "updated_fields": ["display_name", "avatar_media_id", "phone_number"],
    "display_name": "Muhamiyan",
    "avatar_media_id": "9e1c3a82-3a1d-4f0c-9b71-3f7a1e8c0d11",
    "timestamp": "2026-05-19T16:46:32.001Z"
  }
}

# Note: phone_number is in updated_fields but absent above โ€”
# that means it was cleared to null in this commit.

๐Ÿ“ก Account Email Changed (outbound)

Emitted after a user confirms an email change (the verify-new-email flow: POST /api/v1/me/email, then the /confirm-email-change link). Carries both the old and new address so downstream services can update their mirror and re-key any email-indexed records.

Email is deliberately not part of account.account.updated โ€” it gets this dedicated event because the change is a verified two-step flow and the old value matters to consumers. The new_email is already verified at emission time (clicking the confirm link proved ownership).

Routing: published with key account.account.email_changed. Bind to it directly, or to account.account.* for every account-lifecycle change.

Protocol: amqp
Address: exchange: account.events / routing key: account.account.email_changed

publish User email changed (verified)

Best-effort publish (the DB row is the source of truth; consumers must be idempotent โ€” redelivery is possible). The outer JSON envelope is the DomainEvent enum tag ({"AccountEmailChanged": { ... }}), like every other non-email account event.

Payload

FieldTypeRequiredDescription
AccountEmailChanged.event_id string (uuid) yes Unique id for this emission. Use it for at-least-once dedup.
AccountEmailChanged.account_id string (uuid) yes Subject account.
AccountEmailChanged.old_email string no Previous email. Absent/null if the account had none (rare).
AccountEmailChanged.new_email string yes New, already-verified email. Mirror this.
AccountEmailChanged.timestamp string (rfc3339) yes When the change committed.

Example

{
  "AccountEmailChanged": {
    "event_id": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
    "account_id": "1c37ff2c-2f42-4d86-94d3-a20163909ccf",
    "old_email": "old@example.com",
    "new_email": "new@example.com",
    "timestamp": "2026-05-29T03:50:00Z"
  }
}

๐Ÿ“ก Account Created (outbound)

Emitted right after a new account row is persisted โ€” via human registration (POST /auth/register), SSO bootstrap (Google / Firebase first-login), or admin-driven creation. Carries the personal org id so a consumer can stand up tenant-side state in the same hop.

Protocol: amqp
Address: exchange: account.events / routing key: account.account.created

publish New account provisioned

Envelope is {"AccountCreated": { ... }}. Best-effort publish โ€” consumers should be idempotent on event_id.

Payload

FieldTypeRequiredDescription
AccountCreated.event_id string (uuid) yes Unique id for this emission.
AccountCreated.account_id string (uuid) yes Newly created account.
AccountCreated.account_type string yes human or service.
AccountCreated.email string no Email (absent for service accounts that have none).
AccountCreated.organization_id string (uuid) yes Default org. For human accounts this is the personal org; for service accounts it's the owning org.
AccountCreated.timestamp string (rfc3339) yes When created.

Example

{
  "AccountCreated": {
    "event_id": "c96d13d6-6e31-4eea-9038-881899623f74",
    "account_id": "1c37ff2c-2f42-4d86-94d3-a20163909ccf",
    "account_type": "human",
    "email": "jane@example.com",
    "organization_id": "f2c355f1-82aa-4d72-b729-1a17be6216ef",
    "timestamp": "2026-06-01T08:14:22Z"
  }
}

๐Ÿ“ก Account Activated (outbound)

Status transitions Pending โ†’ Active. Triggered by the verify-email link click, by an SSO login that implicitly verifies the account, or by the platform-admin force-verify endpoint.

Protocol: amqp
Address: exchange: account.events / routing key: account.account.activated

publish Account is now usable

Envelope {"AccountActivated": { ... }}. Consumers gating access on status=active should refresh their mirror.

Payload

FieldTypeRequiredDescription
AccountActivated.event_id string (uuid) yes Unique id.
AccountActivated.account_id string (uuid) yes Activated account.
AccountActivated.timestamp string (rfc3339) yes When activated.

Example

{ "AccountActivated": { "event_id": "...", "account_id": "...", "timestamp": "..." } }

๐Ÿ“ก Account Deactivated (outbound)

User self-deactivated via POST /me/deactivate. The row's status is set to deactivated AND deleted_at is stamped, so the email frees up for re-registration and every active session is revoked. Consumers that mirror user state should mark the account closed.

Reactivation โ€” important for replica consumers

Deactivated is a terminal state for this account_id. The domain entity.activate() method rejects any Deactivated โ†’ Active transition with AlreadyDeactivated, so:

  • There is no account.account.activated event for a

deactivated row. Don't wait for one.

  • There is no "undeactivate" RPC. The same account_id

will never come back to Active.

  • If the same human user comes back, they re-register with

the same email. Because the uniqueness check filters deleted_at IS NULL, the soft-deleted row does not block the new registration. The new registration produces:

  • A new account_id (different UUID).
  • A fresh account.account.created event whose

email matches the old account but whose account_id does not.

Replica strategy: on account.account.deactivated mark the old account_id tombstoned. To detect "user came back", subscribe to account.account.created and key on email โ€” a fresh AccountCreated whose email matches a previously tombstoned row is a re-registration. Bridge identity in your own table; do NOT try to revive the old account_id.

Protocol: amqp
Address: exchange: account.events / routing key: account.account.deactivated

publish Account closed by user

Envelope {"AccountDeactivated": { ... }}. reason is free text from the user (where the UI captures it) or absent.

Payload

FieldTypeRequiredDescription
AccountDeactivated.event_id string (uuid) yes Unique id.
AccountDeactivated.account_id string (uuid) yes Deactivated account.
AccountDeactivated.reason string no Optional user-supplied reason.
AccountDeactivated.timestamp string (rfc3339) yes When closed.

๐Ÿ“ก Account Deleted (outbound)

Account row is physically gone โ€” admin two-step hard-delete cascade, or service-initiated transactional rollback within the APP__SECURITY__RECENT_DELETE_WINDOW_MINS window. Distinct from Account Deactivated: deactivation soft- closes (status='deactivated' AND deleted_at stamped โ€” both set in the same transaction; the row is queryable by admins but the domain treats deactivated as terminal and rejects any reactivation attempt). This event signals the row no longer exists, period.

Replicas should tombstone on receipt โ€” there is no row to reconcile against; an HTTP GET /api/v1/accounts/{id} will answer 404.

Protocol: amqp
Address: exchange: account.events / routing key: account.account.deleted

publish Account row physically removed

Envelope {"AccountDeleted": { ... }}. Best-effort publish after the DB commit, so a broker outage doesn't leave an orphan row โ€” but it can leave a stale replica until your reconciliation cron runs. Plan for that.

deleted_by provenance: the admin's account id for the two-step hard-delete, or the calling service-principal's id for the transactional rollback. Cross-reference the audit_logs.metadata.flow field for the exact path (admin_hard_delete vs transactional_rollback).

was_email carries the deleted row's email โ€” handy for consumers indexing on email, since the account is now gone and a follow-up HTTP fetch would 404. null for service accounts that had no email.

Payload

FieldTypeRequiredDescription
AccountDeleted.event_id string (uuid) yes Unique id.
AccountDeleted.account_id string (uuid) yes Account that was deleted.
AccountDeleted.deleted_by string (uuid) yes Admin (two-step) or service-principal (transactional rollback) that ran the delete.
AccountDeleted.was_email string no Email the row had before deletion. Null for service accounts without an email.
AccountDeleted.timestamp string (rfc3339) yes When deleted.

Example

{
  "AccountDeleted": {
    "event_id": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
    "account_id": "1c37ff2c-2f42-4d86-94d3-a20163909ccf",
    "deleted_by": "a2f32c84-639c-4778-8d30-2cc1ba3ca427",
    "was_email": "deleted@example.com",
    "timestamp": "2026-06-04T08:14:22Z"
  }
}

๐Ÿ“ก Account Password Changed (outbound)

Password rotated โ€” whether the user changed it via POST /me/password, or an admin force-reset via either reset endpoint. Carries the org context so consumers that mirror per-org security posture can audit it.

Protocol: amqp
Address: exchange: account.events / routing key: account.account.password_changed

publish Password rotation

Envelope {"AccountPasswordChanged": { ... }}. The new password hash is not in the payload โ€” only the fact of rotation. The LOGIN_FAIL / LOGIN audit-log rows have per-attempt details if you need them.

Payload

FieldTypeRequiredDescription
AccountPasswordChanged.event_id string (uuid) yes Unique id.
AccountPasswordChanged.account_id string (uuid) yes Subject account.
AccountPasswordChanged.account_type string yes human or service.
AccountPasswordChanged.organization_id string (uuid) yes Account's default org (00000000-... if none).
AccountPasswordChanged.changed_at string (rfc3339) yes When rotated.

๐Ÿ“ก Auth Login Success (outbound)

Emitted only by the SSO login paths (POST /auth/google and POST /auth/firebase) for now. The password-login path (POST /auth/login) writes a LOGIN row to audit_logs but does not publish to RabbitMQ โ€” historical asymmetry, watch out if you're building a "login activity" mirror.

Protocol: amqp
Address: exchange: account.events / routing key: account.auth.login_success

publish SSO login succeeded

Envelope {"LoginSuccess": { ... }}. ip_address is the peer IP as seen by the backend; for production traffic through nginx that's typically 127.0.0.1 (the nginx loopback proxy), not the public client IP โ€” fix that at the publish site if you depend on it.

Payload

FieldTypeRequiredDescription
LoginSuccess.event_id string (uuid) yes Unique id.
LoginSuccess.account_id string (uuid) yes Account that logged in.
LoginSuccess.session_id string (uuid) yes Session minted by this login.
LoginSuccess.ip_address string yes Peer IP at the backend (see note above).
LoginSuccess.user_agent string yes User-Agent from the request.
LoginSuccess.timestamp string (rfc3339) yes When the login completed.

๐Ÿ“ก Organization Created (outbound)

Emitted when an organization row is persisted. Includes the personal-org bootstrap created on every human registration, as well as explicit org creations via the admin API.

Protocol: amqp
Address: exchange: account.events / routing key: account.org.created

publish New organization provisioned

Envelope {"OrganizationCreated": { ... }}.

Payload

FieldTypeRequiredDescription
OrganizationCreated.event_id string (uuid) yes Unique id.
OrganizationCreated.organization_id string (uuid) yes Newly created org.
OrganizationCreated.owner_id string (uuid) yes Account that owns / created the org.
OrganizationCreated.timestamp string (rfc3339) yes When created.

๐Ÿ“ก Organization Updated (outbound)

Org profile fields change. updated_fields lists the keys that moved (e.g. name, slug, plan). Field values aren't carried in the payload โ€” fetch the org via HTTP if your mirror needs the new values.

Protocol: amqp
Address: exchange: account.events / routing key: account.org.updated

publish Org profile mutated

Envelope {"OrganizationUpdated": { ... }}.

Payload

FieldTypeRequiredDescription
OrganizationUpdated.event_id string (uuid) yes Unique id.
OrganizationUpdated.organization_id string (uuid) yes Subject org.
OrganizationUpdated.updated_fields string[] yes Field names that changed in this commit.
OrganizationUpdated.timestamp string (rfc3339) yes When committed.

๐Ÿ“ก Organization Deleted (outbound)

Organization is hard-deleted. Today this fires only as part of the admin-hard-delete cascade (a deleted account that owns a personal org takes that org down with it). Consumers that mirror org-keyed state should drop the row.

Protocol: amqp
Address: exchange: account.events / routing key: account.org.deleted

publish Org row gone

Envelope {"OrganizationDeleted": { ... }}.

Payload

FieldTypeRequiredDescription
OrganizationDeleted.event_id string (uuid) yes Unique id.
OrganizationDeleted.organization_id string (uuid) yes Org being removed.
OrganizationDeleted.deleted_by string (uuid) yes Admin / actor that performed the delete.
OrganizationDeleted.timestamp string (rfc3339) yes When deleted.

๐Ÿ“ก Org Member Joined (outbound)

Account becomes an active member of an organization โ€” through invitation acceptance, personal-org bootstrap at register time, or an admin add.

Protocol: amqp
Address: exchange: account.events / routing key: account.org.member_joined

publish Membership created

Envelope {"MemberJoined": { ... }}.

Payload

FieldTypeRequiredDescription
MemberJoined.event_id string (uuid) yes Unique id.
MemberJoined.organization_id string (uuid) yes Org joined.
MemberJoined.account_id string (uuid) yes Account joining.
MemberJoined.role_id string (uuid) yes Role assigned at join time.
MemberJoined.invited_by string (uuid) no Account that issued the invitation (absent for self-bootstrap).
MemberJoined.joined_at string (rfc3339) yes When joined.

๐Ÿ“ก Org Member Left (outbound)

Member leaves an org โ€” self via the leave endpoint, or removed by an admin.

Protocol: amqp
Address: exchange: account.events / routing key: account.org.member_left

publish Membership ended

Envelope {"MemberLeft": { ... }}.

Payload

FieldTypeRequiredDescription
MemberLeft.event_id string (uuid) yes Unique id.
MemberLeft.organization_id string (uuid) yes Org left.
MemberLeft.account_id string (uuid) yes Account that left.
MemberLeft.left_at string (rfc3339) yes When left.

๐Ÿ“ก Org Role Changed (outbound)

A member's role inside an org changes โ€” promotion, demotion, or lateral move. Both old and new role ids are included so audit consumers can capture the transition without a follow-up fetch.

Protocol: amqp
Address: exchange: account.events / routing key: account.org.role_changed

publish Membership role changed

Envelope {"RoleChanged": { ... }}.

Payload

FieldTypeRequiredDescription
RoleChanged.event_id string (uuid) yes Unique id.
RoleChanged.organization_id string (uuid) yes Org context.
RoleChanged.account_id string (uuid) yes Member whose role changed.
RoleChanged.old_role_id string (uuid) yes Role id before the change.
RoleChanged.new_role_id string (uuid) yes Role id after the change.
RoleChanged.changed_by string (uuid) yes Actor that made the change.
RoleChanged.timestamp string (rfc3339) yes When changed.

๐Ÿ“ก Org Member Invited (outbound)

An invitation is created. Companion to org.invitation_accepted / org.invitation_cancelled (state transitions on the same invitation row).

Protocol: amqp
Address: exchange: account.events / routing key: account.org.member_invited

publish Invitation issued

Envelope {"MemberInvited": { ... }}. email is the recipient address; invitation_id is the row that subsequent accept/cancel events reference.

Payload

FieldTypeRequiredDescription
MemberInvited.event_id string (uuid) yes Unique id.
MemberInvited.invitation_id string (uuid) yes Invitation row id.
MemberInvited.organization_id string (uuid) yes Org being invited into.
MemberInvited.email string yes Email of the invitee.
MemberInvited.role_id string (uuid) yes Role to grant on accept.
MemberInvited.invited_by string (uuid) yes Account that issued the invitation.
MemberInvited.timestamp string (rfc3339) yes When issued.

๐Ÿ“ก Org Invitation Accepted (outbound)

Invitation transitions to accepted. Always emitted alongside org.member_joined; the two events carry overlapping but not identical info โ€” bind to whichever fits your projection.

Protocol: amqp
Address: exchange: account.events / routing key: account.org.invitation_accepted

publish Invitation accepted

Envelope {"InvitationAccepted": { ... }}.

Payload

FieldTypeRequiredDescription
InvitationAccepted.event_id string (uuid) yes Unique id.
InvitationAccepted.invitation_id string (uuid) yes Invitation row id.
InvitationAccepted.organization_id string (uuid) yes Org joined.
InvitationAccepted.account_id string (uuid) yes Account that accepted.
InvitationAccepted.email string yes Email used to accept (matches the invitation).
InvitationAccepted.timestamp string (rfc3339) yes When accepted.

๐Ÿ“ก Org Invitation Cancelled (outbound)

Invitation row is cancelled โ€” by the inviter, an admin, or automatic expiry cleanup.

Protocol: amqp
Address: exchange: account.events / routing key: account.org.invitation_cancelled

publish Invitation cancelled

Envelope {"InvitationCancelled": { ... }}.

Payload

FieldTypeRequiredDescription
InvitationCancelled.event_id string (uuid) yes Unique id.
InvitationCancelled.invitation_id string (uuid) yes Invitation row id.
InvitationCancelled.organization_id string (uuid) yes Org context.
InvitationCancelled.cancelled_by string (uuid) yes Actor that cancelled.
InvitationCancelled.timestamp string (rfc3339) yes When cancelled.

๐Ÿ“ก Service Account Created (outbound)

A new service-account row is provisioned inside an org.

Protocol: amqp
Address: exchange: account.events / routing key: account.service_account.created

publish Service account provisioned

Envelope {"ServiceAccountCreated": { ... }}.

Payload

FieldTypeRequiredDescription
ServiceAccountCreated.event_id string (uuid) yes Unique id.
ServiceAccountCreated.account_id string (uuid) yes New service-account id.
ServiceAccountCreated.organization_id string (uuid) yes Owning org.
ServiceAccountCreated.created_by string (uuid) yes Actor that created it.
ServiceAccountCreated.timestamp string (rfc3339) yes When created.

๐Ÿ“ก Service Account Updated (outbound)

Service-account profile / config mutation (display_name, metadata, capabilities).

Protocol: amqp
Address: exchange: account.events / routing key: account.service_account.updated

publish Service account updated

Envelope {"ServiceAccountUpdated": { ... }}. Lean payload โ€” this event carries no per-field snapshot. Fetch via HTTP if your mirror needs the new values.

Payload

FieldTypeRequiredDescription
ServiceAccountUpdated.event_id string (uuid) yes Unique id.
ServiceAccountUpdated.account_id string (uuid) yes Subject service-account.
ServiceAccountUpdated.organization_id string (uuid) yes Owning org.
ServiceAccountUpdated.timestamp string (rfc3339) yes When updated.

๐Ÿ“ก Service Account Paused (outbound)

Service account status flipped to paused โ€” temporarily disabled without revoking its API keys. Consumers that gate on status=active should stop honoring the principal.

Protocol: amqp
Address: exchange: account.events / routing key: account.service_account.paused

publish Service account paused

Envelope {"ServiceAccountPaused": { ... }}.

Payload

FieldTypeRequiredDescription
ServiceAccountPaused.event_id string (uuid) yes Unique id.
ServiceAccountPaused.account_id string (uuid) yes Paused account.
ServiceAccountPaused.organization_id string (uuid) yes Owning org.
ServiceAccountPaused.timestamp string (rfc3339) yes When paused.

๐Ÿ“ก Service Account Resumed (outbound)

Service account status flipped back to active from paused. Companion to service_account.paused.

Protocol: amqp
Address: exchange: account.events / routing key: account.service_account.resumed

publish Service account resumed

Envelope {"ServiceAccountResumed": { ... }}.

Payload

FieldTypeRequiredDescription
ServiceAccountResumed.event_id string (uuid) yes Unique id.
ServiceAccountResumed.account_id string (uuid) yes Resumed account.
ServiceAccountResumed.organization_id string (uuid) yes Owning org.
ServiceAccountResumed.timestamp string (rfc3339) yes When resumed.

๐Ÿ“ก Service Account Archived (outbound)

Service account is archived โ€” irreversible terminal state. All API keys are implicitly revoked. Consumers should drop the principal from any access path.

Protocol: amqp
Address: exchange: account.events / routing key: account.service_account.archived

publish Service account archived

Envelope {"ServiceAccountArchived": { ... }}.

Payload

FieldTypeRequiredDescription
ServiceAccountArchived.event_id string (uuid) yes Unique id.
ServiceAccountArchived.account_id string (uuid) yes Archived account.
ServiceAccountArchived.organization_id string (uuid) yes Owning org.
ServiceAccountArchived.timestamp string (rfc3339) yes When archived.

๐Ÿ“ก API Key Created (outbound)

A new API key is minted for a service account. The plaintext key is not in the payload โ€” only the api_key_id (the row id). Plaintext is returned once at create time on the HTTP response.

Protocol: amqp
Address: exchange: account.events / routing key: account.api_key.created

publish API key issued

Envelope {"ApiKeyCreated": { ... }}.

Payload

FieldTypeRequiredDescription
ApiKeyCreated.event_id string (uuid) yes Unique id.
ApiKeyCreated.api_key_id string (uuid) yes API key row id (NOT the plaintext key).
ApiKeyCreated.account_id string (uuid) yes Service-account the key belongs to.
ApiKeyCreated.created_by string (uuid) yes Actor that issued the key.
ApiKeyCreated.timestamp string (rfc3339) yes When issued.

๐Ÿ“ก API Key Revoked (outbound)

API key row is revoked โ€” manually or as part of a parent service-account archive. Authentication using the key starts failing immediately (cache invalidated as part of the same handler).

Protocol: amqp
Address: exchange: account.events / routing key: account.api_key.revoked

publish API key revoked

Envelope {"ApiKeyRevoked": { ... }}.

Payload

FieldTypeRequiredDescription
ApiKeyRevoked.event_id string (uuid) yes Unique id.
ApiKeyRevoked.api_key_id string (uuid) yes API key row id.
ApiKeyRevoked.account_id string (uuid) yes Owning service-account.
ApiKeyRevoked.revoked_by string (uuid) yes Actor that revoked the key.
ApiKeyRevoked.timestamp string (rfc3339) yes When revoked.

๐Ÿ” Credentials

Konfigurasi credentials untuk testing API

Authorization: Bearer <value>
X-API-Key: <value>
Authorization: <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 (current production TTL: 8 hours; tunable per-deployment via APP__SECURITY__JWT__ACCESS_TOKEN_TTL_MINS) issued by /auth/login, /auth/register, /auth/refresh, /auth/google, /auth/firebase, or /auth/token-exchange. RS256-signed; verifiers can fetch the public key from /auth/public-key.

Note: Refresh token (current production TTL: 7 days; tunable via APP__SECURITY__JWT__REFRESH_TOKEN_TTL_DAYS) is sent separately as an HttpOnly cookie or in the /auth/refresh body. Refresh rotates on every call but a 30-second grace window accepts a recently-rotated token to avoid revoking sessions on benign cross-tab races.

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.

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.