๐ 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/loginwith email + password.
The historical / default path. Subject to brute-force protection.
- Human via Sign-In-With-Google โ
POST /auth/googlewith 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/firebasewith 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_idbecomes the current org. - Use
POST /organizations/{org_id}/switchto issue a new token bound to a different org. The session id is preserved across the switch.
๐ค 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": { ... } }codeis omitted (or absent) on most happy paths and is filled in for created (2010) and a few special cases.datacarries the endpoint's actual payload.
Failure responses:
{ "success": false, "code": 4003, "message": "...", "trace_id": "..." }codefollows the error-code table below โ always check it before falling back to HTTP status.trace_idis set on errors that originate from a use case so support can correlate logs.
๐ง 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_verifiedonGET /api/v1/meโ the
canonical boolean. Read this from any authenticated client; the dashboard verify-email banner is wired this way.
data.requires_email_verificationon 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
permissionsclaim 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
permissionsclaim.
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
| Code | HTTP | Meaning |
|---|---|---|
2010 | 201 | Resource created (used on /auth/register). |
4000 | 400 | Bad request (missing required header, malformed body). |
4002 | 422 | Validation failed (e.g. invalid email format, weak password, phone-number charset). |
4003 | 401 | Authentication / authorization failure (invalid token, missing perm, revoked session). |
4004 | 404 | Resource not found (account, organization, role, session). |
4005 | 409 | Duplicate entry (email already exists; Google sub / Firebase uid already linked to another account; org slug collision). |
4008 | 429 | Rate limit exceeded โ retry_after field is included. |
4009 | 409 | Conflict โ 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. |
4030 | 403 | CSRF validation failed. |
5000 | 500 | Internal error โ usually transient (DB / Redis / RabbitMQ blip). Safe to retry with backoff. |
Validation errors carry the offending field name in message (e.g. "email: invalid format").
๐ช Cookies + SSO
Two cookies are issued by /auth/login, /auth/register, and rotated on /auth/refresh. Both are cleared by /auth/logout and /auth/logout-all.
| Cookie | Purpose | Attributes |
|---|---|---|
refresh_token | Carries the refresh JWT for the SSO subdomain flow. | HttpOnly, Path=/, Max-Age matches the refresh-token TTL, SameSite={configured}, Secure (configurable, default ON in prod), Domain={configured} (e.g. .example.com). |
csrf_token | Random URL-safe base64 token used by the double-submit pattern. | Not HttpOnly (frontend JS reads it), Path=/, Max-Age mirrors refresh_token, SameSite/Secure/Domain inherited from the refresh cookie config. |
- Domain is
{configurable per-deployment}โ set once at the parent SSO domain so a login onaccount.example.comis honoured byapp.example.com. - The login response body also carries
access_tokenandrefresh_tokenfor non-browser clients (mobile, CLI, server-to-server) โ these are the same values written to the cookies.
๐ CSRF middleware
The middleware enforces the double-submit-cookie pattern. It runs only when ALL of the following hold: 1. Method is mutating (POST, PUT, PATCH, DELETE). 2. No Authorization header is present (Bearer/JWT flows are exempt). 3. No X-API-Key header is present (service-account flows are exempt). 4. The request actually carries the csrf_token cookie.
When the gate is active the request must echo the cookie value back in the X-CSRF-Token header (configurable per-deployment). The comparison is constant-time. A mismatch returns:
{ "success": false, "code": 4030, "message": "Invalid CSRF token" }Mobile/CLI/SDK clients that authenticate with Authorization: Bearer ... or X-API-Key: ... never see this middleware. CSRF is a browser concern.
โฑ๏ธ Rate limits
Limits are configured per-deployment. Defaults at the time of writing:
| Endpoint family | Limit |
|---|---|
/auth/register, /auth/resend-verification | 3 / minute |
/auth/login, /me/password | 5 / minute |
/auth/verify | 100 / minute |
| All other endpoints | bound by general per-IP cap |
On exceed:
{
"success": false,
"code": 4008,
"message": "Rate limit exceeded. Try again in 30 seconds.",
"retry_after": 30,
"limit": 5,
"window": "60s"
}Honour retry_after (seconds) before retrying. The login flow ALSO applies per-account brute-force counters with progressive delay independent of this rate limit โ a 200 OK can still be slow if the account is under attack.
๐ Pagination
Every list endpoint accepts:
pageโ 1-indexed (default1).per_pageโ page size (default20, hard cap100).
Response shape (inside data):
{
"items": [ ... ],
"total": 42,
"page": 1,
"per_page": 20,
"total_pages": 3
}total_pages is ceil(total / per_page). There is no next_cursor โ use page + 1 until page > total_pages.
๐ gRPC parity
Every documented HTTP endpoint here delegates to an application use case. The gRPC delivery (account.v1.AuthService, account.v1.AccountService, account.v1.OrgService, account.v1.ServiceAccountService) wraps the same use cases โ request and response shapes are derived from proto/account/v1/*.proto.
Use HTTP for browser/mobile/web clients. Use gRPC for service-to-service when you control both ends and want the typed contract. Both paths share session storage and the same revocation list.
๐ OpenAPI / Swagger UI
- OpenAPI 3.x JSON:
GET /openapi.jsonโ generated fromutoipaannotations on the route handlers, always in sync with what the server actually exposes. - Swagger UI:
GET /docs(path is configurable per-deployment) renders an interactive client againstopenapi.json.
The OpenAPI spec is the source of truth for client SDK generation; this docs page is the narrative companion.
๐ JWT Bearer Authentication Flow
Step-by-step authentication menggunakan JWT Bearer
POST /auth/register (new user) or POST /auth/login (existing). Both return access_token, refresh_token, and an HttpOnly refresh cookie.
Send Authorization: Bearer <access_token> on every protected request. CSRF middleware does not apply when this header is present.
Call POST /auth/refresh (body or cookie carries the refresh token) to receive a fresh pair. The old refresh token is revoked atomically โ replaying it later kills the session.
POST /organizations/{org_id}/switch reissues a token bound to the new org. The session id is preserved.
POST /auth/logout revokes the current session and adds the access JTI to the revocation list. POST /auth/logout-all sweeps every active session for the account.
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
Owner of the org calls POST /service-accounts/{account_id}/api-keys. The plaintext key is returned ONCE โ store it client-side immediately.
Set X-API-Key: <key> as a header. The middleware accepts it on any protected endpoint.
POST /auth/token-exchange with X-API-Key returns a 1-hour service JWT (principal_type=service). Verifiers can validate it locally with /auth/public-key, no per-request roundtrip to account-service.
POST /api-keys/{api_key_id}/revoke marks the key dead immediately. Use DELETE /api-keys/{api_key_id} for permanent removal.
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)
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.
Google returns a CredentialResponse containing a signed ID-token JWT in response.credential.
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.
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
Return the authenticated principal's profile (account details + email verification status). Works for both human and service principals.
{
"success": true,
"data": {
"account_id": "550e8400-...",
"account_type": "human",
"email": "user@example.com",
"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).
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
| Field | Type | Required | Description |
|---|---|---|---|
| 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).
|
{
"username": "jane.d",
"display_name": "Jane D.",
"bio": "Building things.",
"phone_number": "+62 812-3456-7890",
"timezone": "Asia/Singapore",
"language": "en"
}
{ "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 } }
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
| Field | Type | Required | Description |
|---|---|---|---|
| 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. |
{
"current_password": "OldPass123!",
"new_password": "NewSecurePass456!"
}
{ "success": true, "data": { "success": true } }
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_emailinvalid, orcurrent_passwordmissing
on a password account.
401โcurrent_passwordincorrect.409โnew_emailalready 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
| Field | Type | Required | Description |
|---|---|---|---|
| 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. |
{
"new_email": "new.address@example.com",
"current_password": "CurrentPass123!"
}
{
"success": true,
"data": {
"message": "Confirmation link sent to your new email address. The change takes effect once you click it."
}
}
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
| Field | Type | Required | Description |
|---|---|---|---|
| token | string | required | Plaintext confirmation token from the email link. |
{
"success": true,
"data": {
"account_id": "550e8400-e29b-41d4-a716-446655440000",
"email": "new.address@example.com"
}
}
List every active session for the authenticated user. The session backing the current request is flagged with is_current: true. Also available at /api/v1/sessions (legacy path).
{
"success": true,
"data": {
"sessions": [
{
"session_id": "550e8400-...",
"ip_address": "203.0.113.42",
"user_agent": "Mozilla/5.0 ...",
"device_fingerprint": "",
"created_at": 1735689600,
"last_used_at": 1735693200,
"expires_at": 1738281600,
"is_current": true
}
]
}
}
Revoke a specific session by id. The user can revoke any of their own sessions, including the current one (which terminates the calling session). Also available at /api/v1/sessions/{session_id}.
{ "success": true, "data": null }
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.
{
"success": true,
"data": {
"providers": [
{
"provider": "google",
"linked": true,
"linked_email": "arian.personal@gmail.com"
}
],
"can_disconnect_google": true
}
}
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,expmust all
check out (same JWKS-verified path as /auth/google).
- Google
email_verifiedmust NOT befalse. - 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
| Field | Type | Required | Description |
|---|---|---|---|
| credential | string | required | Raw Google ID token (the same JWT shape /auth/google accepts). |
{ "credential": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjE4Y..." }
{
"success": true,
"data": {
"providers": [
{
"provider": "google",
"linked": true,
"linked_email": "arian.personal@gmail.com"
}
],
"can_disconnect_google": true
}
}
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.
{
"success": true,
"data": {
"providers": [
{ "provider": "google", "linked": false }
],
"can_disconnect_google": false
}
}
Audit-log entries where the authenticated user is the actor. Paged. The actor_id filter is forced server-side to the calling user, so callers cannot pivot to another user's feed.
Action vocabulary (dot-notation, used in action filter and action field of returned items):
account.profile_updated,account.accessedauth.login,auth.login_failed,auth.logout,
auth.token_refreshed, auth.password_changed, auth.password_reset
member.invited,member.removedinvitation.cancelledservice_account.archived,service_account.paused,
service_account.resumed
api_key.created,api_key.revoked
New actions may be added without a docs revision; consumers should tolerate unknown values (display as-is, don't reject).
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
| page | integer | optional | 1-indexed page number. (default: 1) |
| per_page | integer | optional | Page size, max 100. (default: 20) |
| since | string | optional | ISO-8601 โ filter created_at >= since. |
| until | string | optional | ISO-8601 โ filter created_at < until. |
| action | string | optional | Dot-notation filter, e.g. member.invited. |
| org_id | string | optional | Optional org scope. Org-side activities are also available at /organizations/{org_id}/activities. |
{
"success": true,
"data": {
"items": [
{
"id": "550e8400-...",
"actor_id": "550e8400-...",
"actor_email": "user@example.com",
"actor_display_name": null,
"organization_id": "550e8400-...",
"organization_name": null,
"action": "organization.created",
"target_type": "organization",
"target_id": "550e8400-...",
"target_label": null,
"metadata": {},
"ip_address": "203.0.113.42",
"user_agent": "Mozilla/5.0 ...",
"created_at": 1735689600
}
],
"total": 42,
"page": 1,
"per_page": 20,
"total_pages": 3
}
}
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_pageis 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โqshorter than the configured minimum.429 / 4008โ rate-limit hit, includesretry_after.
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
| 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) |
{
"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.
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.
{
"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..."
}
}
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
idsarray โ 200 withitems: [](no DB hit). - Duplicates in
idsare 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โidshas 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
| Field | Type | Required | Description |
|---|---|---|---|
| ids | array<uuid> | required | Up to 100 account UUIDs to enrich. Duplicates are de-duplicated. |
{
"ids": [
"550e8400-e29b-41d4-a716-446655440000",
"6ba7b810-9dad-11d1-80b4-00c04fd430c8"
]
}
{
"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..."
}
]
}
}
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.
{
"success": true,
"data": {
"account_id": "550e8400-e29b-41d4-a716-446655440000",
"email": "rolledback@example.com",
"deleted": true
}
}
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 awacca://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
| Field | Type | Required | Description |
|---|---|---|---|
| string | required | Valid email address. Must be unique across all accounts. | |
| password | string | required | Minimum 8 chars, must include upper, lower, digit, and special character. |
| display_name | string | required | Free-form. Used for the personal organization name and greeting. |
| timezone | string | optional |
IANA timezone. Defaults to 'UTC'.
ex: Asia/Jakarta
|
| language | string | optional |
ISO 639-1 code. Defaults to 'en'.
ex: id
|
| platform | string | optional |
Caller platform. wacca-mobile โ verification email gets a wacca:// deeplink button + https fallback. Alias: flatform.
ex: wacca-mobile
|
{
"email": "user@example.com",
"password": "SecurePass123!",
"display_name": "Jane Doe",
"timezone": "Asia/Jakarta",
"language": "id",
"platform": "wacca-mobile"
}
{
"success": true,
"code": 2010,
"data": {
"account_id": "550e8400-e29b-41d4-a716-446655440000",
"email": "user@example.com",
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"refresh_token": "eyJhbGciOiJSUzI1NiIs...",
"expires_in": 900,
"personal_org": {
"org_id": "550e8400-e29b-41d4-a716-446655440001",
"name": "Jane Doe's Personal",
"slug": "jane-doe-personal-550e8400",
"status": "active",
"owner_account_id": "550e8400-e29b-41d4-a716-446655440000",
"plan": "free",
"created_at": 1735689600,
"updated_at": 1735689600,
"my_role": "owner",
"my_permissions": ["*"]
}
}
}
Authenticate with email + password. Returns access + refresh tokens and the user's full organization list. Sets refresh + CSRF cookies. Brute-force protection: per-IP and per-account counters with progressive delay and lockout. Rate limit: 5 / minute / IP.
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| string | required | Account email. | |
| password | string | required | Account password. |
{
"email": "user@example.com",
"password": "SecurePass123!"
}
{
"success": true,
"data": {
"account_id": "550e8400-e29b-41d4-a716-446655440000",
"email": "user@example.com",
"display_name": "Jane Doe",
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"refresh_token": "eyJhbGciOiJSUzI1NiIs...",
"expires_in": 900,
"organizations": [
{ "org_id": "...", "name": "Jane Doe's Personal", "slug": "jane-doe-personal-...", "status": "active", "owner_account_id": "...", "plan": "free", "created_at": 1735689600, "updated_at": 1735689600, "my_role": "owner", "my_permissions": ["*"] }
],
"current_org_id": "550e8400-e29b-41d4-a716-446655440001",
"requires_email_verification": false,
"csrf_token": "f3a1c0..."
}
}
Exchange a 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 Googlesub.
Rate limit: 5 / minute / IP (same bucket as /auth/login).
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| 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.
|
{
"credential": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjE4Y2...",
"device_timezone": "Asia/Jakarta",
"device_language": "id"
}
{
"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..."
}
}
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
| Field | Type | Required | Description |
|---|---|---|---|
| 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.
|
{
"credential": "eyJhbGciOiJSUzI1NiIsImtpZCI6Ijc2N...",
"device_timezone": "Asia/Jakarta",
"device_language": "id"
}
{
"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..."
}
}
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
AccountUpdatedevent 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).
{
"success": true,
"data": {
"account_id": "550e8400-e29b-41d4-a716-446655440000",
"email": "alice@example.com",
"email_verified": true,
"was_already_verified": false
}
}
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โdeliverynotreturn|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
| Field | Type | Required | Description |
|---|---|---|---|
| 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
|
{ "delivery": "return", "force_change_on_next_login": true }
{
"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).
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.
{
"success": true,
"data": {
"confirmation_code": "7RP6JB3K",
"expires_in_secs": 300
}
}
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_organizationspayload, see above.5xxโ DB transaction failure; nothing was committed.
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| confirmation_code | string | required | The exact code returned by /prepare. Single-use; deleted from Redis on first match. |
{
"confirmation_code": "7RP6JB3K"
}
{
"success": true,
"data": {
"account_id": "550e8400-e29b-41d4-a716-446655440000",
"email": "user@example.com",
"deleted_personal_org_ids": [
"49f18abc-64c1-40db-830a-b7d191c4232f"
]
}
}
Exchange a refresh token for a new access + refresh pair. The old refresh token's hash is rotated server-side; the old JTI is added to the revocation list once rotation is persisted. Replaying the old refresh token after this call kills the session (reuse detection). Refresh token may be in the body or in the refresh_token cookie (SSO flow).
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| refresh_token | string | optional |
Optional in body. Falls back to the refresh_token cookie when omitted.
|
{ "refresh_token": "eyJhbGciOiJSUzI1NiIs..." }
{
"success": true,
"data": {
"account_id": "...",
"email": "user@example.com",
"display_name": "Jane Doe",
"access_token": "eyJhbGciOiJSUzI1NiIs... (new)",
"refresh_token": "eyJhbGciOiJSUzI1NiIs... (new)",
"expires_in": 900,
"organizations": [ ... ],
"current_org_id": "...",
"requires_email_verification": false,
"csrf_token": "..."
}
}
Revoke the current session. The access token's JTI is also added to the revocation list so a token stolen before logout stops validating immediately. Cookies are always cleared on the response, even if the server-side revoke fails. Service principals cannot call this endpoint โ they have no session.
{ "success": true, "data": { "success": true } }
Revoke every active session for the calling account. Useful for "I lost my phone" or post-password-change cleanups. Cookies are rotated/cleared on the response.
{ "success": true, "data": { "success": true } }
Re-emit the verification email for an unverified account. 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
| Field | Type | Required | Description |
|---|---|---|---|
| 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.
|
{ "email": "user@example.com", "platform": "wacca-mobile" }
{ "success": true, "code": 2000, "data": { "message": "If the email exists, a verification email has been sent" } }
Initiate a password reset. Always returns success, even when the email is unknown, to prevent email enumeration. Generates a 1-hour JWT and emits an email.requested event with the reset link.
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
| Field | Type | Required | Description |
|---|---|---|---|
| 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.
|
{
"email": "user@example.com",
"platform": "wacca-mobile"
}
{ "success": true, "data": null }
Complete a password reset using the JWT from the email link. Validates the token (signature + jti not previously used), updates the password hash, and revokes all sessions for the account.
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| token | string | required | Reset token from the email link (JWT). |
| new_password | string | required | Same complexity rules as registration. |
{
"token": "eyJhbGciOiJSUzI1NiIs...",
"new_password": "NewSecurePass456!"
}
{ "success": true, "data": { "success": true } }
Introspect an access token (signature + exp + revocation). Used by downstream services that prefer a server-side check over local public-key verification. Returns the claims as-is: permissions come from the token, not re-derived from current state. Use /auth/refresh if you need fresh permissions. Rate limit: 100 / minute / IP.
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| token | string | required | Access token to introspect. |
{ "token": "eyJhbGciOiJSUzI1NiIs..." }
{
"success": true,
"data": {
"valid": true,
"account_id": "550e8400-...",
"organization_id": "550e8400-...",
"permissions": ["*"],
"expires_at": 1735693200,
"session_id": "550e8400-..."
}
}
Return the principal identity carried by the request โ exactly what the middleware extracted, with no extra DB lookups. Useful for SDKs and gateways debugging auth wiring.
{
"success": true,
"data": {
"account_id": "550e8400-...",
"principal_type": "human",
"session_id": "550e8400-...",
"current_org_id": "550e8400-...",
"organizations": [ { "id": "550e8400-...", "role": "owner" } ],
"permissions": ["*"]
}
}
Return the RS256 public key (PEM) for local JWT verification by downstream services. Cache this for the lifetime of expires_at (default 90 days).
Key rotation strategy:
- Each issued JWT carries a
kidclaim in its header naming the
key that signed it (e.g. key-2026-03-01-v1).
- Verifiers should cache by
kid. On a cache miss (unfamiliar
kid), re-fetch this endpoint to learn the new key.
- During rotation, account-service serves the new key here
but continues to accept tokens signed by the previous key until those tokens naturally exp. Verifiers should retain the previous key entry until its tokens have aged out.
expires_atis the rough cache horizon โ refresh before that
even without a kid mismatch to pick up scheduled rotations.
{
"success": true,
"data": {
"key_id": "key-2026-03-01-v1",
"algorithm": "RS256",
"public_key": "-----BEGIN PUBLIC KEY-----\nMIIB...\n-----END PUBLIC KEY-----\n",
"expires_at": 1743465600
}
}
Validate the supplied X-API-Key header and return a 1-hour service JWT (principal_type: service). Lets a service principal authenticate downstream calls with a verifiable JWT instead of re-presenting the API key on every hop. The JWT carries the service account's capabilities as permissions and nil as sid.
{
"success": true,
"data": {
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 3600,
"account_id": "550e8400-...",
"organization_id": "550e8400-...",
"permissions": ["museum:read", "artifact:write"],
"principal_type": "service"
}
}
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 โฆ | Use | Auth |
|---|---|---|
| An org-owned service account with an API key | /auth/token-exchange | X-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.
# 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'
{
"success": true,
"data": {
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 900
}
}
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โ noauthorizationmetadata, wrong
scheme, unknown user, or wrong password.
PERMISSION_DENIEDโservicenot covered by the
caller's prefix allowlist.
INVALID_ARGUMENTโaccount_id/organization_idnot
a UUID, or service empty.
# 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
}
GrantServiceResponse {
granted_now: true,
granted_at: "2026-05-21T10:50:00Z"
}
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.
RevokeServiceRequest {
account_id: "550e8400-...",
service: "wacca-tenant",
organization_id: ""
}
RevokeServiceResponse { revoked: true }
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.
ListGrantsForAccountRequest {
account_id: "550e8400-...",
service_prefix: "wacca-"
}
ListGrantsForAccountResponse {
grants: [
{ service: "wacca-tenant", organization_id: "", granted_at: "2026-05-21T10:50:00Z" }
]
}
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โ noauthorizationmetadata, wrong
scheme, unknown user, or wrong password.
INVALID_ARGUMENTโaccount_idis not a UUID.NOT_FOUNDโ no live row matches the id.
# Pseudocode โ adapt to your gRPC client library
metadata.authorization = "Basic " + base64("wacca:<password>")
GetAccountRequest {
account_id: "0a2f6766-893b-49e6-9acb-9e503cd93e1e"
}
GetAccountResponse {
account_id: "0a2f6766-893b-49e6-9acb-9e503cd93e1e",
account_type: "human",
display_name: "Jane Doe",
email: "jane@example.com",
status: "active",
created_at: 1735689600
}
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โ seeGetAccount.INVALID_ARGUMENTโaccount_idis not a UUID.
AccountExistsRequest {
account_id: "0a2f6766-893b-49e6-9acb-9e503cd93e1e"
}
AccountExistsResponse {
exists: true,
status: "active"
}
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โtrueiff 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โtrueiff 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โ seeGetAccount.INVALID_ARGUMENTโaccount_idis not a UUID.
EmailIsVerifiedRequest {
account_id: "0a2f6766-893b-49e6-9acb-9e503cd93e1e"
}
EmailIsVerifiedResponse {
verified: true,
account_exists: true
}
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โ seeGetAccount.INVALID_ARGUMENTโaccount_idis not a UUID.NOT_FOUNDโ no live row matches the id.RESOURCE_EXHAUSTEDโ id-resolver rate-limit tripped.
LookupAccountRequest {
account_id: "0a2f6766-893b-49e6-9acb-9e503cd93e1e"
}
LookupAccountResponse {
account: {
id: "0a2f6766-893b-49e6-9acb-9e503cd93e1e",
display_name: "Jane Doe",
email: "jane@example.com",
avatar_media_id: "a8f3c2d1-...",
phone_number: "+628111234567"
}
}
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
accountsis NOT meaningful. - Soft-deleted / deactivated rows are NOT returned.
Errors:
UNAUTHENTICATEDโ seeGetAccount.INVALID_ARGUMENTโ any element fails UUID parse, OR
slice length > the configured cap.
RESOURCE_EXHAUSTEDโ id-resolver rate-limit tripped.
BulkLookupAccountsRequest {
account_ids: [
"0a2f6766-893b-49e6-9acb-9e503cd93e1e",
"ff000000-0000-0000-0000-000000000000",
"1c37ff2c-2f42-4d86-94d3-a20163909ccf"
]
}
# 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: "" }
]
}
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โ seeGetAccount.INVALID_ARGUMENTโ any element fails email parse, OR
slice length > the configured cap.
RESOURCE_EXHAUSTEDโ email-resolver rate-limit tripped.
LookupAccountsByEmailRequest {
emails: [
"Jane@Example.COM",
" john@example.com ",
"noone@example.com"
]
}
# 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: "" }
]
}
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_statuscarries
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_idnot 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.
metadata.authorization = "Basic " + base64("wacca:<password>")
VerifyPasswordRequest {
account_id: "0a2f6766-893b-49e6-9acb-9e503cd93e1e",
password: "the-user-typed-this"
}
VerifyPasswordResponse {
valid: true,
account_status: "active"
}
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.
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โnamealready exists (active or revoked).422โ validation failure on name or prefixes.
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| 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.
|
{
"success": true,
"data": {
"account": {
"id": "550e8400-...",
"name": "careers-backend",
"allowed_prefixes": ["*"],
"token_permissions": ["media:upload"],
"description": "Owned by careers-team",
"status": "active",
...
},
"password": "K4j8m..."
}
}
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_permissionsfield missing from body.
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| token_permissions | array<string> | required |
Replacement permission set. Send [] to clear all permissions.
|
{
"token_permissions": ["media:upload", "media:read"]
}
{
"success": true,
"data": {
"id": "550e8400-...",
"token_permissions": ["media:upload", "media:read"]
}
}
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.
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.
Lightweight liveness check. Returns 200 with a small JSON when the process is up. Does not touch DB / Redis / RabbitMQ โ use /health/detailed for that. Suitable for k8s livenessProbe.
{ "status": "ok" }
Per-dependency health snapshot. Probes Postgres, Redis, and RabbitMQ and returns each dependency's state. A degraded dependency yields HTTP 503 so a load balancer can drop the pod from rotation. Heavier than /health โ call from readinessProbe, not livenessProbe.
{
"status": "ok",
"dependencies": {
"postgres": { "status": "ok", "latency_ms": 3 },
"redis": { "status": "ok", "latency_ms": 1 },
"rabbitmq": { "status": "ok", "latency_ms": 5 }
},
"version": "1.0.0",
"uptime_seconds": 12345
}
Prometheus exposition format. Includes HTTP request counters / latency histograms (sanitized path labels โ id segments collapsed to :id so cardinality stays bounded), gRPC counters, brute-force counters, rate-limiter counters, and Redis connection pool stats. Should be firewalled to the metrics-collector subnet, not exposed publicly.
# HELP http_requests_total Total HTTP requests
# TYPE http_requests_total counter
http_requests_total{method="GET",path="/api/v1/me",status="200"} 142
...
Lightweight existence + display info for an organization. Used by frontends to validate org-scoped invitation links before login. Returns minimal data โ no member or role info is leaked.
{
"success": true,
"data": {
"org_id": "550e8400-...",
"name": "Acme Corp",
"created_at": 1735689600
}
}
List organizations the authenticated principal is a member of. Each entry includes the principal's role in that org.
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
| page | integer | optional | 1-indexed page number. (default: 1) |
| per_page | integer | optional | Page size, max 100. (default: 20) |
{
"success": true,
"data": {
"items": [
{ "org_id": "...", "name": "Acme Corp", "slug": "acme-corp", "description": null, "status": "active", "owner_id": "...", "created_at": 1735689600, "updated_at": 1735689600, "member_count": 12 }
],
"total": 1,
"page": 1,
"per_page": 20,
"total_pages": 1
}
}
Create a new organization with the caller as owner. Slug is auto-derived from the name when omitted; if the slug collides a 4xx is returned and the caller should retry with an explicit slug.
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| name | string | required | Display name of the organization. |
| slug | string | optional | URL-safe slug. Auto-generated from name when omitted. |
{ "name": "Acme Corp", "slug": "acme-corp" }
{
"success": true,
"data": {
"org_id": "550e8400-...",
"name": "Acme Corp",
"slug": "acme-corp",
"description": null,
"status": "active",
"owner_id": "550e8400-...",
"created_at": 1735689600,
"updated_at": 1735689600,
"member_count": 1
}
}
Fetch a single organization by id. Caller must be a member.
{ "success": true, "data": { "org_id": "...", "name": "Acme Corp", "slug": "acme-corp", "description": null, "status": "active", "owner_id": "...", "created_at": 1735689600, "updated_at": 1735689600, "member_count": 12 } }
Update the organization's name or slug.
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| name | string | optional | New display name. |
| slug | string | optional | New URL-safe slug. |
{ "name": "Acme Inc.", "slug": "acme-inc" }
{ "success": true, "data": { "org_id": "...", "name": "Acme Inc.", "slug": "acme-inc", "status": "active", "owner_id": "...", "created_at": 1735689600, "updated_at": 1735693200, "member_count": 12 } }
Soft-delete (archive) an organization. Owner-only โ protected by the owner role's * wildcard rather than a per-action permission. Members lose access on next token rotation; all sessions remain intact for other orgs the user belongs to.
{ "success": true, "data": null }
Issue a new token pair bound to a different organization. The existing session id is reused, so refresh continuity is preserved. The caller must already be a member of the target org.
{
"success": true,
"data": {
"account_id": "...",
"email": "user@example.com",
"display_name": "Jane Doe",
"access_token": "eyJ...new...",
"refresh_token": "eyJ...new...",
"expires_in": 900,
"organizations": [ { "org_id": "...", "name": "Acme Corp", "slug": "acme-corp", "status": "active", "owner_account_id": "...", "plan": "free", "created_at": 1735689600, "updated_at": 1735689600, "my_role": "owner", "my_permissions": ["*"] } ],
"current_org_id": "550e8400-...new..."
}
}
Remove the caller's own membership. Owners cannot leave โ transfer ownership first or delete the organization.
{ "success": true, "data": null }
Paged member list with role + permissions.
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
| page | integer | optional | 1-indexed page. (default: 1) |
| per_page | integer | optional | Page size, max 100. (default: 20) |
{
"success": true,
"data": {
"items": [
{ "account_id": "...", "email": "user@example.com", "display_name": "Jane Doe", "role": "owner", "permissions": ["*"], "joined_at": 1735689600 }
],
"total": 1,
"page": 1,
"per_page": 20,
"total_pages": 1
}
}
Send an invitation to an email address. If a role is omitted the org's default-member role is assigned on accept. The recipient gets an email via the email.requested event.
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| string | required | Invitee email. | |
| role_id | string | optional | Role id to assign on accept. Defaults to org default member role. |
{ "email": "newmember@example.com", "role_id": "550e8400-..." }
{
"success": true,
"data": {
"invitation_id": "550e8400-...",
"message": "Invitation sent to newmember@example.com"
}
}
Remove a member from the org. Cannot remove the owner.
{ "success": true, "data": null }
Reassign a member to a different role. New role takes effect on their next token rotation.
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| role_id | string | required | Target role id. |
{ "role_id": "550e8400-..." }
{ "success": true, "data": null }
List roles defined in the org (system + custom).
{
"success": true,
"data": {
"items": [
{ "role_id": "...", "name": "owner", "description": "Organization owner with full access", "permissions": ["*"], "is_system": true, "created_at": 1735689600, "updated_at": 1735689600 }
],
"total": 1,
"page": 1,
"per_page": 20,
"total_pages": 1
}
}
Create a custom role. Permissions are free-form strings (the permission service interprets them); see the Permissions section for the canonical names used by account-service itself.
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| name | string | required | Display name (unique within the org). |
| description | string | optional | Optional description. |
| permissions | string[] | required | Permission strings granted by this role. |
{
"name": "auditor",
"description": "Read-only access to audit logs",
"permissions": ["read:organizations", "audit_logs:read"]
}
{ "success": true, "data": { "role_id": "...", "name": "auditor", "description": "Read-only access to audit logs", "permissions": ["read:organizations","audit_logs:read"], "is_system": false, "created_at": 1735689600, "updated_at": 1735689600 } }
Fetch a single role.
{ "success": true, "data": { "role_id": "...", "name": "auditor", "description": "Read-only access to audit logs", "permissions": ["read:organizations","audit_logs:read"], "is_system": false, "created_at": 1735689600, "updated_at": 1735689600 } }
Patch a role. System roles (e.g. owner) are read-only.
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| name | string | optional | New name. |
| description | string | optional | New description (pass null to clear). |
| permissions | string[] | optional | New permissions list (replaces, not merges). |
{ "permissions": ["read:organizations", "audit_logs:read", "members:invite"] }
{ "success": true, "data": { "role_id": "...", "name": "auditor", "permissions": ["read:organizations","audit_logs:read","members:invite"], "is_system": false, "created_at": 1735689600, "updated_at": 1735693200 } }
Delete a custom role. Fails if any member is currently assigned to it โ reassign them first. System roles cannot be deleted.
{ "success": true, "data": null }
Org-wide audit feed. Same shape as /me/activities but scoped to all actors within the organization. Requires audit_logs:read permission in the org.
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
| page | integer | optional | 1-indexed page. (default: 1) |
| per_page | integer | optional | Page size, max 100. (default: 20) |
| since | string | optional | ISO-8601. |
| until | string | optional | ISO-8601. |
| action | string | optional | Dot-notation action filter. |
{
"success": true,
"data": {
"items": [
{ "id": "...", "actor_id": "...", "actor_email": "user@example.com", "actor_display_name": null, "organization_id": "...", "organization_name": null, "action": "member.invited", "target_type": "member", "target_id": "...", "target_label": null, "metadata": {}, "ip_address": "203.0.113.42", "user_agent": "...", "created_at": 1735689600 }
],
"total": 1, "page": 1, "per_page": 20, "total_pages": 1
}
}
Read the full lock row including audit columns. Used by the superadmin UI at /dashboard/admin-registration.
{
"success": true,
"data": {
"enabled": false,
"message": null,
"updated_at": "2026-05-22T10:00:00Z",
"updated_by_account_id": "550e8400-..."
}
}
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) โtrueblocks,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
| Field | Type | Required | Description |
|---|---|---|---|
| enabled | boolean | required | Lock state to set. |
| message | string | optional | Optional user-facing reason. |
{
"enabled": true,
"message": "Sedang migrasi infrastructure, registrasi dibuka kembali sekitar 16:00 WIB"
}
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.
{
"success": true,
"data": {
"locked": true,
"message": "Sedang migrasi infrastructure, registrasi dibuka kembali sekitar 16:00 WIB"
}
}
List requests filtered by status. Pagination is intentionally omitted โ queue depth is expected to stay small under realistic emergency conditions.
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
| status | string | optional | One of pending, approved, rejected, completed. (default: pending) |
{
"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
}
]
}
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 inpending(already
approved/rejected/completed).
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| note | string | optional | Internal review note (not shown to the user). |
{
"success": true,
"data": {
"id": "550e8400-...",
"approval_url": "https://account.ikavia.com/complete-registration?token=K4j8m...",
"expires_at": "2026-05-24T05:30:00Z"
}
}
Mark a pending request rejected. No notification is sent to the user automatically (intentional โ guards against probe-and-confirm by attackers).
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| note | string | optional | Internal review note. |
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
| Field | Type | Required | Description |
|---|---|---|---|
| token | string | required | Plaintext approval token. |
| password | string | required | User's new password (min 8 chars). |
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.
{
"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": "..."
}
]
}
}
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
| Field | Type | Required | Description |
|---|---|---|---|
| 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. |
{
"scope": "org",
"target_id": "550e8400-e29b-41d4-a716-446655440000",
"max_count": 100,
"note": "INC-1234 raised limit for prod rollout"
}
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.
Remove the global override row; defaults fall back to the hardcoded values (DEFAULT_MAX_PER_USER = 10, DEFAULT_MAX_PER_ORG = 50).
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.
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
permissionsin 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.
Render this as the parent table. Each row's account_id is the parent for that bot's API keys (Step 5).
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.
{
"organization_id": "550e8400-...",
"display_name": "Wacca tenant sync bot",
"description": "Reads tenant rosters for nightly sync"
}
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.keyin a copyable field with aCopy
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).
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.
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.
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.
List service accounts in an organization. The org is supplied as a query parameter (not a path segment) because callers commonly enumerate across orgs they manage.
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
| organization_id | string | required | Organization id to filter by. |
| status | string | optional | One of active, paused, archived. |
| page | integer | optional | 1-indexed page. (default: 1) |
| per_page | integer | optional | Page size, max 100. (default: 20) |
{
"success": true,
"data": {
"items": [
{ "account_id": "...", "display_name": "Render bot", "status": "active", "capabilities": ["museum:read"], "last_used_at": 1735693200, "created_at": 1735689600 }
],
"total": 1, "page": 1, "per_page": 20, "total_pages": 1
}
}
Create a new service account scoped to an organization. The returned account has no API key yet โ call POST /service-accounts/{account_id}/api-keys afterwards.
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| organization_id | string | required | Organization to attach the service account to. |
| display_name | string | required | Human-readable name. |
| description | string | optional | Optional description. |
| capabilities | string[] | required | Capabilities granted (resource:action format). |
| allowed_tools | string[] | required | Subset of tool ids the service may invoke. Empty list = none. |
| rate_limit_per_minute | integer | optional | Per-key rate cap; clamped to 1-10000. Default 60. |
{
"organization_id": "550e8400-...",
"display_name": "Render bot",
"description": "Generates thumbnail previews",
"capabilities": ["museum:read", "artifact:write"],
"allowed_tools": ["thumbnailer"],
"rate_limit_per_minute": 120
}
{
"success": true,
"data": {
"account_id": "550e8400-...",
"organization_id": "550e8400-...",
"display_name": "Render bot",
"description": "Generates thumbnail previews",
"status": "active",
"capabilities": ["museum:read", "artifact:write"],
"allowed_tools": ["thumbnailer"],
"rate_limit_per_minute": 120,
"last_used_at": null,
"created_at": 1735689600,
"updated_at": 1735689600
}
}
Fetch one service account by id.
{
"success": true,
"data": { "account_id": "...", "organization_id": "...", "display_name": "Render bot", "status": "active", "capabilities": ["museum:read","artifact:write"], "allowed_tools": ["thumbnailer"], "rate_limit_per_minute": 120, "last_used_at": 1735693200, "created_at": 1735689600, "updated_at": 1735689600 }
}
Patch metadata, capabilities, or rate limit. Permission changes take effect on the next /auth/token-exchange call (or immediately for raw X-API-Key flows).
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| organization_id | string | required | Org id (used as a safety check that the caller scoped the request correctly). |
| display_name | string | optional | New display name. |
| description | string | optional | New description. |
| capabilities | string[] | optional | Replacement capabilities list. |
| allowed_tools | string[] | optional | Replacement allowed-tools list. |
| rate_limit_per_minute | integer | optional | New rate limit. |
{
"organization_id": "550e8400-...",
"display_name": "Render bot v2",
"rate_limit_per_minute": 240
}
{ "success": true, "data": { "account_id": "...", "display_name": "Render bot v2", "rate_limit_per_minute": 240, "status": "active", "capabilities": ["museum:read","artifact:write"], "allowed_tools": ["thumbnailer"], "organization_id": "...", "created_at": 1735689600, "updated_at": 1735693200 } }
Soft-delete (archive) a service account. All of its API keys are revoked. Existing service JWTs continue to validate until their natural exp (max 1 hour); add the JTI to the revocation list via re-issued tokens to shorten that further.
{ "success": true, "data": null }
Temporarily disable a service account. API keys stop authenticating until resumed; metadata and capabilities are preserved.
{ "success": true, "data": null }
Re-enable a paused service account.
{ "success": true, "data": null }
List metadata for every key on a service account. The plaintext is never returned here โ only key_prefix for identification.
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
| page | integer | optional | 1-indexed page. (default: 1) |
| per_page | integer | optional | Page size, max 100. (default: 20) |
{
"success": true,
"data": {
"items": [
{ "api_key_id": "...", "key_prefix": "ak_live_a1b2", "name": "prod render bot", "permissions": ["museum:read"], "expires_at": 1751155200, "last_used_at": 1735693200, "created_at": 1735689600, "is_revoked": false, "is_expired": false }
],
"total": 1, "page": 1, "per_page": 20, "total_pages": 1
}
}
Mint a new API key. The key field in the response is the only time the plaintext is ever exposed โ store it client-side immediately. Subsequent calls see only the hash and key_prefix.
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| name | string | optional | Free-form label for the key. |
| permissions | string[] | required | Permission strings granted to this key (subset of the service account's capabilities). |
| expires_in_days | integer | optional | TTL in days. Omit for no expiry. |
{
"name": "prod render bot",
"permissions": ["museum:read"],
"expires_in_days": 180
}
{
"success": true,
"data": {
"api_key_id": "...",
"key": "ak_live_a1b2c3d4e5f6...REDACTED_RAW_KEY...",
"key_prefix": "ak_live_a1b2",
"name": "prod render bot",
"permissions": ["museum:read"],
"expires_at": 1751155200,
"created_at": 1735689600
}
}
Mark a key as revoked. The key continues to exist for audit purposes but stops authenticating immediately.
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| reason | string | optional | Optional human-readable reason recorded on the audit log. |
{ "reason": "leaked in public repo" }
{ "success": true, "data": null }
Permanently remove a key (hard delete). Prefer revoke for auditable history.
{ "success": true, "data": null }
๐ฑ Lost device / session recovery
What to do when a user reports a lost or stolen device, or just wants to audit where they're signed in. Three patterns: review, revoke-one, revoke-all.
Step 1: Sign in from a trusted device
The user logs in from a device they still control. This issues a fresh access + refresh pair on a new session id. Existing sessions are not affected โ the lost device's session is still alive at this point.
Service: account-service
Fields
| Field | Type | Required | Description |
|---|---|---|---|
| string | required | Account email. | |
| password | string | required | Account password. |
curl -X POST https://account.example.com/api/v1/auth/login \
-H 'Content-Type: application/json' \
-d '{ "email": "user@example.com", "password": "SecurePass123!" }'
Step 2: Review active sessions
Lists every active session on the account, with last-used IP and user-agent so the user can spot the rogue one. The session backing the current request is flagged with is_current: true.
Service: account-service
curl https://account.example.com/api/v1/me/sessions \
-H 'Authorization: Bearer eyJ...'
{
"success": true,
"data": {
"sessions": [
{ "session_id": "550e8400-...", "ip_address": "203.0.113.42", "user_agent": "Mozilla/5.0 (iPhone)", "created_at": 1735689600, "last_used_at": 1735693200, "expires_at": 1738281600, "is_current": false },
{ "session_id": "660e8400-...", "ip_address": "198.51.100.7", "user_agent": "Mozilla/5.0 (Macintosh)", "created_at": 1735693300, "last_used_at": 1735693300, "expires_at": 1738285200, "is_current": true }
]
}
}
Step 3: Revoke just the lost device's session (option A)
Targeted revoke. Pass the suspected session_id from the list. The next time that device tries to refresh its access token, it will get 4003 Session has been revoked or expired and be forced back to the login screen.
Service: account-service
curl -X DELETE https://account.example.com/api/v1/me/sessions/550e8400-... \
-H 'Authorization: Bearer eyJ...'
{ "success": true, "data": null }
Step 4: Or revoke EVERYTHING (option B โ paranoid)
Sweep every active session for the account, including the one making the request. Use when the user can't tell which session is the rogue one, or after a credential leak. The browser cookies are also cleared on the response.
After this, the user is logged out everywhere โ they'll need to re-login on each device they actually want to keep.
Service: account-service
curl -X POST https://account.example.com/api/v1/auth/logout-all \
-H 'Authorization: Bearer eyJ...'
{ "success": true, "data": { "success": true } }
Next Steps
Endpoint: #change-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):
| Surface | Client ID / project | Endpoint enabled |
|---|---|---|
| Web | 836801358076-lfhjvt3lโฆapps.googleusercontent.com | /auth/google โ
|
| Android | 836801358076-74v49lgqโฆapps.googleusercontent.com | /auth/google โ
|
| iOS | pending โ mobile team to supply bundle id | /auth/google (will accept once provisioned) |
| Firebase | project 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.comBoth 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
idTokenis 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-iOSInfo.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:
idTokencan 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โtrueif 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.
Service: account-service
Content-Type: application/json
Fields
| Field | Type | Required | Description |
|---|---|---|---|
| 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 -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"
}'
{
"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: 4003HTTP 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: 4005HTTP 409 โgoogle_subduplicate.
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: 4008HTTP 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: 5000HTTP 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
serverClientIdin 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.
Service: account-service
Content-Type: application/json
Fields
| Field | Type | Required | Description |
|---|---|---|---|
| refresh_token | string | required | The refresh token stored in step 7. NOT the access token. |
curl -X POST https://account.ikavia.com/api/v1/auth/refresh \
-H 'Content-Type: application/json' \
-d '{ "refresh_token": "eyJ..." }'
{
"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).
Service: account-service
Content-Type: application/json
Fields
| Field | Type | Required | Description |
|---|---|---|---|
| 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 -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/googlePOST โ 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/refreshafter 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
fridaor 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=falseis 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.
subis 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:
Service: account-service
Content-Type: application/json
Fields
| Field | Type | Required | Description |
|---|---|---|---|
| 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 -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"
}'
{
"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.
Service: account-service
Fields
| Field | Type | Required | Description |
|---|---|---|---|
| string | required | Unique across all accounts. | |
| password | string | required | Min 8, must include upper/lower/digit/special. |
| display_name | string | required | Used for the personal org name. |
| timezone | string | optional | IANA, default UTC. |
| language | string | optional | ISO 639-1, default en. |
curl -X POST https://account.example.com/api/v1/auth/register \
-H 'Content-Type: application/json' \
-d '{
"email": "user@example.com",
"password": "SecurePass123!",
"display_name": "Jane Doe",
"timezone": "Asia/Jakarta",
"language": "id"
}'
{
"success": true, "code": 2010,
"data": {
"account_id": "550e8400-...",
"email": "user@example.com",
"access_token": "eyJ...",
"refresh_token": "eyJ...",
"expires_in": 900,
"personal_org": { "org_id": "550e8400-...", "name": "Jane Doe's Personal", "slug": "jane-doe-personal-550e8400", "my_role": "owner", "my_permissions": ["*"], "...": "..." }
}
}
Step 2: Verify email (out-of-band)
The user clicks the link in the verification email. Account service hosts the verification page itself at GET /verify-email (HTML, outside /api/v1). Once verified, the user's NEXT issued token (login, register, or refresh) carries permissions: ["*"] instead of the read-only fallback. Existing tokens issued before verification keep their reduced perms until refreshed.
If the email never arrived:
Service: account-service
Fields
| Field | Type | Required | Description |
|---|---|---|---|
| string | required | Email to resend to. Always returns success regardless of existence. |
curl -X POST https://account.example.com/api/v1/auth/resend-verification \
-H 'Content-Type: application/json' \
-d '{ "email": "user@example.com" }'
{ "success": true, "data": { "message": "If the account exists and is unverified, a new verification email has been sent." } }
Step 3: Refresh after verification
To upgrade an in-flight session from read-only to full perms without forcing the user to re-login, hit /auth/refresh. The new access token will reflect the now-verified status.
Service: account-service
Fields
| Field | Type | Required | Description |
|---|---|---|---|
| refresh_token | string | optional | Falls back to refresh_token cookie when omitted. |
curl -X POST https://account.example.com/api/v1/auth/refresh \
-H 'Content-Type: application/json' \
-d '{ "refresh_token": "eyJ..." }'
{
"success": true,
"data": {
"access_token": "eyJ...new_with_full_perms...",
"refresh_token": "eyJ...",
"expires_in": 900,
"...": "..."
}
}
Step 4: Create a team org (optional)
The personal org is private to the user. To collaborate, create a shared org. The caller becomes its owner.
Service: account-service
Fields
| Field | Type | Required | Description |
|---|---|---|---|
| name | string | required | Display name. |
| slug | string | optional | URL-safe; auto-derived if omitted. |
curl -X POST https://account.example.com/api/v1/organizations \
-H 'Authorization: Bearer eyJ...' \
-H 'Content-Type: application/json' \
-d '{ "name": "Acme Corp", "slug": "acme-corp" }'
{
"success": true,
"data": { "org_id": "550e8400-...", "name": "Acme Corp", "slug": "acme-corp", "owner_id": "550e8400-...", "status": "active", "...": "..." }
}
Step 5: Invite first teammate
Sends an invitation email. Recipient accepts via the email link (which lives on the frontend; account-service emits the email.requested event with the token). Requires members:invite permission in the org.
Service: account-service
Fields
| Field | Type | Required | Description |
|---|---|---|---|
| string | required | Invitee email. | |
| role_id | string | optional | Assign on accept; defaults to org default member role. |
curl -X POST https://account.example.com/api/v1/organizations/550e8400-.../members \
-H 'Authorization: Bearer eyJ...' \
-H 'Content-Type: application/json' \
-d '{ "email": "teammate@example.com", "role_id": "550e8400-..." }'
{ "success": true, "data": { "invitation_id": "550e8400-...", "message": "Invitation sent to teammate@example.com" } }
Step 6: Switch to the new org (when ready)
The invitation creator's tokens still point at their personal org. Switching reissues the token bound to the team org without re-login. Session id is preserved across the switch.
Service: account-service
curl -X POST https://account.example.com/api/v1/organizations/550e8400-.../switch \
-H 'Authorization: Bearer eyJ...'
{
"success": true,
"data": { "access_token": "eyJ...new...", "refresh_token": "eyJ...new...", "current_org_id": "550e8400-...", "...": "..." }
}
๐ Password reset (forgot password)
End-to-end password reset for users who can't log in. Two endpoints and one out-of-band email step. Both endpoints always return success to prevent account enumeration โ meaningful failures (invalid token, weak password) are surfaced only on the confirm call when the user actually has a valid token in hand.
Step 1: Request the reset
Generates a 1-hour JWT carrying permissions: ["password:reset"] and emits the email.requested event. The token's hash is stored in Redis so it can only be used once.
Always returns success regardless of whether the email exists โ checking the existence here would leak account membership to anyone who can reach the endpoint.
Service: account-service
Fields
| Field | Type | Required | Description |
|---|---|---|---|
| string | required | Account email. |
curl -X POST https://account.example.com/api/v1/auth/password-reset \
-H 'Content-Type: application/json' \
-d '{ "email": "user@example.com" }'
{ "success": true, "data": null }
Step 2: User clicks link in email (out-of-band)
The email contains a frontend URL with the token as a query parameter (or fragment, depending on frontend convention). The frontend renders a "set new password" form. Account-service itself also hosts a fallback HTML form at GET /reset-password (outside /api/v1) for out-of-the-box use.
Token TTL is 1 hour. After expiry the user must restart from step 1.
Step 3: Confirm with new password
Validates the token (signature + jti not previously used + not expired), updates the password hash, and revokes every active session for the account so any in-flight access tokens stop validating immediately. The user must log in again with the new password.
Service: account-service
Fields
| Field | Type | Required | Description |
|---|---|---|---|
| token | string | required | JWT from the email link. |
| new_password | string | required | Same complexity rules as registration. |
curl -X POST https://account.example.com/api/v1/auth/password-reset/confirm \
-H 'Content-Type: application/json' \
-d '{
"token": "eyJ...reset_token...",
"new_password": "NewSecurePass456!"
}'
{ "success": true, "data": { "success": true } }
Next Steps
Endpoint: #login
๐ค Service-to-service authentication
How a non-human consumer (job runner, ML pipeline, internal automation) authenticates against your APIs. Covers creating a service account, minting an API key, exchanging it for a JWT, and revoking on leak.
Step 1: Create the service account
A service account lives inside one organization and has capabilities (e.g. museum:read, artifact:write) that bound what its keys may grant. Requires service_accounts:create in the target org.
Service: account-service
Fields
| Field | Type | Required | Description |
|---|---|---|---|
| organization_id | string | required | Org to attach to. |
| display_name | string | required | Human-readable name. |
| description | string | optional | Optional. |
| capabilities | string[] | required | resource:action format. |
| allowed_tools | string[] | required | Subset of tool ids the service may invoke. |
| rate_limit_per_minute | integer | optional | 1-10000, default 60. |
curl -X POST https://account.example.com/api/v1/service-accounts \
-H 'Authorization: Bearer eyJ...' \
-H 'Content-Type: application/json' \
-d '{
"organization_id": "550e8400-...",
"display_name": "Render bot",
"capabilities": ["museum:read", "artifact:write"],
"allowed_tools": ["thumbnailer"],
"rate_limit_per_minute": 120
}'
{ "success": true, "data": { "account_id": "550e8400-...", "status": "active", "capabilities": ["museum:read","artifact:write"], "...": "..." } }
Step 2: Mint an API key
The plaintext key is returned only here, only once โ store it client-side immediately (e.g. in a secret manager). Subsequent list/get calls expose only key_prefix for identification. Requires api_keys:create.
Service: account-service
Fields
| Field | Type | Required | Description |
|---|---|---|---|
| name | string | optional | Free-form label. |
| permissions | string[] | required | Subset of the service account's capabilities. |
| expires_in_days | integer | optional | Omit for no expiry. |
curl -X POST https://account.example.com/api/v1/service-accounts/550e8400-.../api-keys \
-H 'Authorization: Bearer eyJ...' \
-H 'Content-Type: application/json' \
-d '{
"name": "prod render bot",
"permissions": ["museum:read"],
"expires_in_days": 180
}'
{
"success": true,
"data": {
"api_key_id": "550e8400-...",
"key": "ak_live_a1b2c3d4...REDACTED...",
"key_prefix": "ak_live_a1b2",
"name": "prod render bot",
"permissions": ["museum:read"],
"expires_at": 1751155200,
"created_at": 1735689600
}
}
Step 3: Use the key directly (option A)
Cheapest integration: send X-API-Key on every protected request. The middleware accepts it identically to a Bearer JWT. Suitable for low-throughput automations.
Service: account-service
curl https://account.example.com/api/v1/me \
-H 'X-API-Key: ak_live_a1b2c3d4...'
{ "success": true, "data": { "account_id": "550e8400-...", "account_type": "service", "display_name": "Render bot", "...": "..." } }
Step 4: Or exchange for a JWT (option B โ recommended)
For high-throughput callers, swap the key for a 1-hour service JWT and validate locally with /auth/public-key. Reduces account-service load from per-request to ~once an hour per pod. The JWT carries principal_type: service and sid: <nil-uuid>.
Service: account-service
curl -X POST https://account.example.com/api/v1/auth/token-exchange \
-H 'X-API-Key: ak_live_a1b2c3d4...'
{
"success": true,
"data": {
"access_token": "eyJ...service_jwt...",
"token_type": "Bearer",
"expires_in": 3600,
"account_id": "550e8400-...",
"organization_id": "550e8400-...",
"permissions": ["museum:read"],
"principal_type": "service"
}
}
Step 5: Call downstream services with the JWT
Downstream services fetch /auth/public-key once, cache by kid, then verify the JWT locally on every request โ no roundtrip to account-service per call. The permissions claim is derived from the API key's permissions at exchange time.
Service: downstream-service
curl https://media.example.com/v1/artifacts/123 \
-H 'Authorization: Bearer eyJ...service_jwt...'
Step 6: Revoke on leak
When a key leaks (committed to a public repo, posted in a Slack channel, etc.), revoke immediately. The key continues to exist for audit/forensics but stops authenticating. Use DELETE to hard- remove (loses audit trail).
Service: account-service
Fields
| Field | Type | Required | Description |
|---|---|---|---|
| reason | string | optional | Recorded on the audit log. |
curl -X POST https://account.example.com/api/v1/api-keys/550e8400-.../revoke \
-H 'Authorization: Bearer eyJ...' \
-H 'Content-Type: application/json' \
-d '{ "reason": "leaked in public repo" }'
{ "success": true, "data": null }
๐ก 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
| Need | Status | Where 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 needWhat 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 |
| Yes | Yes | New value โ overwrite |
| Yes | No | Cleared 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 key | Emitted when |
|---|---|
account.account.created | A new account is registered (human via /register or service via admin UI). |
account.account.activated | Account transitions Pending โ Active (verify-email click, SSO activation). |
account.account.deactivated | User calls POST /me/deactivate. Soft close (row still queryable). |
account.account.deleted | Account row is physically removed (admin two-step hard-delete or service-initiated transactional rollback). Replicas should tombstone immediately. |
account.account.email_changed | Email change confirm flow completes. Old + new email in payload. |
account.account.password_changed | Password is changed (self via /me/password, or admin reset). |
account.account.updated | Mirror-field change (display_name / bio / phone / timezone / language / avatar). |
Auth (outbound)
| Routing key | Emitted when |
|---|---|
account.auth.login_success | SSO 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 key | Emitted when |
|---|---|
account.org.created | New organization is created. |
account.org.updated | Organization profile fields change. |
account.org.deleted | Organization is deleted (admin hard-delete cascade). |
account.org.member_joined | Account joins an org (via invitation accept or signup). |
account.org.member_left | Account leaves an org (self or admin removal). |
account.org.role_changed | A member's role changes inside an org. |
account.org.member_invited | An invitation is issued. |
account.org.invitation_accepted | Invitation is accepted (companion to org.member_joined). |
account.org.invitation_cancelled | Invitation is cancelled by the inviter / admin. |
Service-account + API-key lifecycle (outbound)
| Routing key | Emitted when |
|---|---|
account.service_account.created | An org creates a service account. |
account.service_account.updated | Service-account profile/config changes. |
account.service_account.paused | Service account is paused. |
account.service_account.resumed | Service account is resumed. |
account.service_account.archived | Service account is archived (irreversible). |
account.api_key.created | API key is issued for a service account. |
account.api_key.revoked | API key is revoked. |
Email delivery (outbound)
| Routing key | Emitted when |
|---|---|
account.email.requested | Service needs an outbound email sent (verification, reset, invitation, etc.). Consumed by email-service. See the detailed section below. |
Inbound
| Routing key | Consumed when |
|---|---|
account.profile.update_requested | A 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.
| Header | Type | Purpose |
|---|---|---|
x-event-id | string (UUID) | Unique per event. Mirror of body.event_id. Use as your dedup key for at-least-once redelivery. |
x-event-type | string | Routing key without the account. prefix (e.g. account.updated, org.member_joined). |
x-occurred-at | string (RFC3339) | Wall-clock time the event happened. Use for out-of-order discard. |
x-schema-version | string (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-id | string (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.
amqpexchange: account.events / routing key: email.requestedpublish Outbound email request
One event per outbound mail. The email service is the only documented subscriber today.
Payload
| Field | Type | Required | Description |
|---|---|---|---|
event_type |
string | yes | Always 'email.requested' for this channel. |
to |
string | yes | Recipient email address. |
template |
string | yes | One of: email_verification, password_reset. |
display_name |
string | no | Used for greeting line. |
token |
string | yes | Verification or reset token (raw โ hash is what's stored server-side). |
base_url |
string | yes | Frontend base URL used to construct the click-through link. |
is_resend |
bool | no | Only set for verification template; false on first send. |
Example
{
"event_type": "email.requested",
"to": "user@example.com",
"template": "email_verification",
"display_name": "Jane Doe",
"token": "8x...redacted...",
"base_url": "https://app.example.com",
"is_resend": false
}
๐ก 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).
amqpexchange: account.events / routing key: account.profile.update_requested / queue: account.profile.update_requestedsubscribe 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
| Field | Type | Required | Description |
|---|---|---|---|
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.
amqpexchange: account.events / routing key: account.account.updatedpublish 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
| Field | Type | Required | Description |
|---|---|---|---|
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.
amqpexchange: account.events / routing key: account.account.email_changedpublish 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
| Field | Type | Required | Description |
|---|---|---|---|
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.
amqpexchange: account.events / routing key: account.account.createdpublish New account provisioned
Envelope is {"AccountCreated": { ... }}. Best-effort publish โ consumers should be idempotent on event_id.
Payload
| Field | Type | Required | Description |
|---|---|---|---|
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.
amqpexchange: account.events / routing key: account.account.activatedpublish Account is now usable
Envelope {"AccountActivated": { ... }}. Consumers gating access on status=active should refresh their mirror.
Payload
| Field | Type | Required | Description |
|---|---|---|---|
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.activatedevent 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.createdevent 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.
amqpexchange: account.events / routing key: account.account.deactivatedpublish Account closed by user
Envelope {"AccountDeactivated": { ... }}. reason is free text from the user (where the UI captures it) or absent.
Payload
| Field | Type | Required | Description |
|---|---|---|---|
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.
amqpexchange: account.events / routing key: account.account.deletedpublish 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
| Field | Type | Required | Description |
|---|---|---|---|
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.
amqpexchange: account.events / routing key: account.account.password_changedpublish 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
| Field | Type | Required | Description |
|---|---|---|---|
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.
amqpexchange: account.events / routing key: account.auth.login_successpublish 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
| Field | Type | Required | Description |
|---|---|---|---|
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.
amqpexchange: account.events / routing key: account.org.createdpublish New organization provisioned
Envelope {"OrganizationCreated": { ... }}.
Payload
| Field | Type | Required | Description |
|---|---|---|---|
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.
amqpexchange: account.events / routing key: account.org.updatedpublish Org profile mutated
Envelope {"OrganizationUpdated": { ... }}.
Payload
| Field | Type | Required | Description |
|---|---|---|---|
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.
amqpexchange: account.events / routing key: account.org.deletedpublish Org row gone
Envelope {"OrganizationDeleted": { ... }}.
Payload
| Field | Type | Required | Description |
|---|---|---|---|
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.
amqpexchange: account.events / routing key: account.org.member_joinedpublish Membership created
Envelope {"MemberJoined": { ... }}.
Payload
| Field | Type | Required | Description |
|---|---|---|---|
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.
amqpexchange: account.events / routing key: account.org.member_leftpublish Membership ended
Envelope {"MemberLeft": { ... }}.
Payload
| Field | Type | Required | Description |
|---|---|---|---|
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.
amqpexchange: account.events / routing key: account.org.role_changedpublish Membership role changed
Envelope {"RoleChanged": { ... }}.
Payload
| Field | Type | Required | Description |
|---|---|---|---|
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).
amqpexchange: account.events / routing key: account.org.member_invitedpublish Invitation issued
Envelope {"MemberInvited": { ... }}. email is the recipient address; invitation_id is the row that subsequent accept/cancel events reference.
Payload
| Field | Type | Required | Description |
|---|---|---|---|
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.
amqpexchange: account.events / routing key: account.org.invitation_acceptedpublish Invitation accepted
Envelope {"InvitationAccepted": { ... }}.
Payload
| Field | Type | Required | Description |
|---|---|---|---|
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.
amqpexchange: account.events / routing key: account.org.invitation_cancelledpublish Invitation cancelled
Envelope {"InvitationCancelled": { ... }}.
Payload
| Field | Type | Required | Description |
|---|---|---|---|
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.
amqpexchange: account.events / routing key: account.service_account.createdpublish Service account provisioned
Envelope {"ServiceAccountCreated": { ... }}.
Payload
| Field | Type | Required | Description |
|---|---|---|---|
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).
amqpexchange: account.events / routing key: account.service_account.updatedpublish 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
| Field | Type | Required | Description |
|---|---|---|---|
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.
amqpexchange: account.events / routing key: account.service_account.pausedpublish Service account paused
Envelope {"ServiceAccountPaused": { ... }}.
Payload
| Field | Type | Required | Description |
|---|---|---|---|
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.
amqpexchange: account.events / routing key: account.service_account.resumedpublish Service account resumed
Envelope {"ServiceAccountResumed": { ... }}.
Payload
| Field | Type | Required | Description |
|---|---|---|---|
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.
amqpexchange: account.events / routing key: account.service_account.archivedpublish Service account archived
Envelope {"ServiceAccountArchived": { ... }}.
Payload
| Field | Type | Required | Description |
|---|---|---|---|
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.
amqpexchange: account.events / routing key: account.api_key.createdpublish API key issued
Envelope {"ApiKeyCreated": { ... }}.
Payload
| Field | Type | Required | Description |
|---|---|---|---|
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).
amqpexchange: account.events / routing key: account.api_key.revokedpublish API key revoked
Envelope {"ApiKeyRevoked": { ... }}.
Payload
| Field | Type | Required | Description |
|---|---|---|---|
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
Cara Menggunakan
๐ Authentication
Methods and permissions for API access
Authentication Methods
JWT Bearer
Header: Authorization: Bearer <access_token>
Source: account-service
Short-lived access token (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.
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
| Permission | Description |
|---|---|
* |
Wildcard โ all permissions. Carried by verified human access tokens. Membership/role grants in the org also use * for the system 'owner' role. |
read:profile |
Read your own account profile. Issued on tokens for unverified-email users. |
read:organizations |
List the organizations you belong to. Issued on tokens for unverified-email users. |
read:sessions |
List your own active sessions. Issued on tokens for unverified-email users. |
members:invite |
POST /organizations/{org_id}/members. |
members:remove |
DELETE /organizations/{org_id}/members/{account_id}. |
members:update_role |
PUT /organizations/{org_id}/members/{account_id}/role. |
invitations:cancel |
Cancel a pending invitation (gRPC only today). |
organizations:update |
PUT /organizations/{org_id}. |
roles:create |
POST /organizations/{org_id}/roles. |
roles:update |
PUT /organizations/{org_id}/roles/{role_id}. |
roles:delete |
DELETE /organizations/{org_id}/roles/{role_id}. |
service_accounts:create |
POST /service-accounts. |
service_accounts:read |
GET /service-accounts and GET /service-accounts/{id}. |
service_accounts:update |
PUT /service-accounts/{id}. |
service_accounts:archive |
DELETE /service-accounts/{id}. |
service_accounts:pause |
POST /service-accounts/{id}/pause. |
service_accounts:resume |
POST /service-accounts/{id}/resume. |
api_keys:create |
POST /service-accounts/{id}/api-keys. |
api_keys:read |
GET /service-accounts/{id}/api-keys. |
api_keys:revoke |
POST /api-keys/{id}/revoke. |
api_keys:delete |
DELETE /api-keys/{id}. |
audit_logs:read |
GET /organizations/{org_id}/activities. The /me/activities feed is filtered to the calling user and needs no extra permission. |
password:reset |
Embedded only in the JWT issued by /auth/password-reset (1 h TTL). Not user-grantable. |