Malan HTTP API
June 10, 2026 · View on GitHub
Malan is a Phoenix 1.8 (app version 0.1.0) authentication service that issues API tokens and manages users, sessions, and audit logs. The OpenAPI 3.1 spec in priv/openapi/openapi.yaml is generated from the router as of December 23, 2025.
Base URLs
- Same-origin (Swagger UI):
/ - Production:
https://accounts.ameelio.org - Staging:
https://accounts.ameelio.xyz - Development:
http://localhost:4000
Authentication & Tokens
- Pass the token returned from POST /api/sessions as
Authorization: Bearer <token>. - Most
/apiroutes require a token; unauthenticated exceptions are called out below. Login failures return403(ForbiddenAuth) and locked accounts return423. - Terms of Service and Privacy Policy acceptance may be enforced depending on deployment configuration. If you receive HTTP 461 or 462, update your user with
accept_tos: trueand/oraccept_privacy_policy: true.
Common Response Shape
Successful responses:
{ "ok": true, "code": 200, "data": { ... } }
Error responses:
{ "ok": false, "code": 422, "detail": "Unprocessable Entity", "message": "...", "errors": {...} }
Error Codes
- 400 Bad Request
- 401 Unauthorized (authenticated but not allowed; e.g., not owner/admin)
- 403 Forbidden (missing/invalid token, expired/revoked session)
- 403 ForbiddenAuth (invalid username/password/location on login)
- 404 Not Found
- 413 Request Entity Too Large (request body exceeds the 1 MB cap)
- 422 Unprocessable Entity (validation/pagination errors)
- 423 Locked (locked user)
- 429 Too Many Requests (rate limits)
- 461 Terms of Service Required
- 462 Privacy Policy Required
- 500 Internal Server Error
Pagination
Endpoints that list records accept page_num (default 0) and page_size (default 10, max 100 where enforced). User and session-extension list responses include these values; other lists return just the data array.
Rate Limits
- Login: 429 when the credential rate limiter is exceeded. Limited per username (default 5 per minute, env vars
LOGIN_LIMIT_*) and per source IP (defaults 60 per minute and 2000 per 24 hours, env varsLOGIN_IP_LOWER_LIMIT_MSECS,LOGIN_IP_LOWER_LIMIT_COUNT,LOGIN_IP_UPPER_LIMIT_MSECS,LOGIN_IP_UPPER_LIMIT_COUNT). The per-IP limits are deliberately generous so many users behind one NAT don't trip them. - User registration: 429 when the per-IP limiter is exceeded (defaults 15 per minute and 250 per 24 hours, env vars
REGISTRATION_IP_*). - Password reset requests: 1 every 3 minutes, up to 3 per 24 hours per user (configurable via env vars
PASSWORD_RESET_*), plus per-IP limits (env varsPASSWORD_RESET_IP_*). - Per-IP limits (login, registration, password reset) do not apply to private source addresses (RFC 1918, loopback, link-local, and their IPv6 equivalents). Deployed environments are only reachable from the internet through Cloudflare — which always sets
CF-Connecting-IPto the visitor's public address — so a private address can only belong to a cluster-internal caller (e.g., an upstream service that funnels many users through one pod IP). Per-username / per-user limits still apply to that traffic. - General request rate limiting is backed by Hammer; Redis can be used in production (
HAMMER_REDIS_URL).
Public Endpoints (no token required)
Create User
POST /api/users
Body:
{
"user": {
"email": "user@example.com",
"username": "user@example.com",
"password": "password123",
"first_name": "Jane",
"last_name": "Doe",
"display_name": "Jane Doe",
"nick_name": "JD",
"gender": "Non-binary",
"race": ["Asian"],
"ethnicity": "Hispanic or Latinx",
"preferences": {
"theme": "light",
"display_name_pref": "full_name",
"display_middle_initial_only": false
},
"custom_attrs": { "source": "signup-form" },
"approved_ips": ["1.2.3.4"],
"addresses": [{
"name": "Home",
"line_1": "123 Main St",
"line_2": "Apt 5",
"city": "Anytown",
"state": "CA",
"postal": "12345",
"country": "US",
"primary": true
}],
"phone_numbers": [{
"number": "+14155550123",
"primary": true
}]
}
}
- Required fields:
username,email,password,first_name,last_name. - Rate limited per source IP (defaults 15 per minute and 250 per 24 hours, env vars
REGISTRATION_IP_LOWER_LIMIT_MSECS,REGISTRATION_IP_LOWER_LIMIT_COUNT,REGISTRATION_IP_UPPER_LIMIT_MSECS,REGISTRATION_IP_UPPER_LIMIT_COUNT); exceeding it returns429 Too Many Requests. - Optional profile data: middle/suffix/prefix names,
display_name,nick_name,birthday,sex,gender(enumerated; e.g., Cis Female, Trans Man, Non-binary),race(American Indian or Alaska Native, Asian, Black or African American, Native Hawaiian or Other Pacific Islander, White),ethnicity(Hispanic or Latinx / Not Hispanic or Latinx),weight,height. - Preferences enum:
theme(light|dark),display_name_pref(full_name|nick_name|custom),display_middle_initial_only(bool). - You may inline
addressesandphone_numbersduring creation; address fields requireline_2in this API.
Response (201 Created):
{
"ok": true,
"code": 201,
"data": {
"id": "c0c7d53e-7a76-4f4f-9f1e-e5a0f6e9c8b1",
"username": "user@example.com",
"email": "user@example.com",
"roles": ["user"],
"latest_tos_accept_ver": null,
"latest_pp_accept_ver": null,
"preferences": { ... },
"approved_ips": ["1.2.3.4"]
}
}
- Additional fields present on the
dataobject:email_verifiedtimestamp (nullable),locked_at/locked_bywhen locked,tos_accepted/privacy_policy_accepted, acceptance event histories, and anycustom_attrsyou provided.
Create Session (Login)
POST /api/sessions
Body:
{
"session": {
"username": "user@example.com",
"password": "password123",
"location": "nyc-lab", // optional audit tag
"never_expires": false, // optional, default false
"expires_in_seconds": 3600, // optional, default 7 days (604800)
"extendable_until_seconds": 2419200, // optional, default 28 days
"max_extension_secs": 604800, // optional, default 7 days
"valid_only_for_ip": false,
"valid_only_for_approved_ips": false
}
}
Response (201 Created):
{
"ok": true,
"code": 201,
"data": {
"id": "c2f8c1aa-9a3e-4d58-9f3b-e7f0c2c94a10",
"user_id": "c0c7d53e-7a76-4f4f-9f1e-e5a0f6e9c8b1",
"api_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"authenticated_at": "2024-12-16T12:00:00Z",
"ip_address": "203.0.113.10",
"location": null,
"expires_at": "2025-01-01T12:00:00Z",
"extendable_until": "2025-01-29T12:00:00Z",
"max_extension_secs": 604800,
"valid_only_for_ip": false,
"valid_only_for_approved_ips": false,
"is_valid": true
}
}
api_tokenis only present in the creation response; subsequent session fetches omit it.- Invalid credentials return
403 ForbiddenAuth; locked users return423; excessive login attempts return429 Too Many Requests(limited per username and per source IP). never_expires: trueissues a non-expiring session that can still be revoked;valid_only_for_ipandvalid_only_for_approved_ipsfurther restrict token use.
Who Am I
GET /api/users/whoami
Returns the token context if present; otherwise 403. Requires a bearer token even though the route is on the unauthenticated pipeline.
{
"ok": true,
"code": 200,
"data": {
"user_id": "c0c7d53e-7a76-4f4f-9f1e-e5a0f6e9c8b1",
"session_id": "c2f8c1aa-9a3e-4d58-9f3b-e7f0c2c94a10",
"ip_address": "127.0.0.1",
"valid_only_for_ip": false,
"user_roles": ["user"],
"expires_at": "2025-01-01T12:00:00Z",
"terms_of_service": 2,
"privacy_policy": 1
}
}
Password Reset
POST /api/users/:id/reset_password(id or username) — issues a reset token and emails it.PUT /api/users/:id/reset_password/:tokenPUT /api/users/reset_password/:token
Body for token exchange:
{ "new_password": "newpassword123" }
Success: {"ok": true, "code": 200}. Invalid/missing/expired tokens return 401 with an error message.
Notes: Reset email requests return 404 when the user is unknown and 429 when rate limited.
Health Checks
GET /health_check/livenessGET /health_check/readiness
Authenticated User Endpoints
Bearer token required; ToS/Privacy acceptance may be enforced (461/462) depending on deployment settings.
Current User
GET /api/users/current(aliasGET /api/users/me, deprecated)GET /api/users/:id(owner or admin)
Returns full user data including addresses and phone numbers when loaded.
Update User
PUT /api/users/:id
Body may include profile fields and flags:
{
"user": {
"first_name": "Jane",
"last_name": "Doe",
"display_name": "Jane Q. Doe",
"nick_name": "JD",
"gender": "Cis Female",
"race": ["White"],
"ethnicity": "Not Hispanic or Latinx",
"height": 167,
"weight": 63,
"birthday": "1995-01-01",
"password": "newpassword123",
"accept_tos": true,
"accept_privacy_policy": true,
"approved_ips": ["1.2.3.4"],
"preferences": {
"theme": "dark",
"display_name_pref": "full_name",
"display_middle_initial_only": true
},
"custom_attrs": { "plan": "pro" },
"addresses": [{
"name": "Home",
"line_1": "123 Main St",
"line_2": "Apt 5",
"city": "Anytown",
"state": "CA",
"postal": "12345",
"country": "US",
"primary": true
}],
"phone_numbers": [{
"number": "+14155550123",
"primary": true
}]
}
}
useris required; any fields not supplied remain unchanged. Gender uses an enumerated list (Cis/Cisgender, Trans*, Non-binary, Two-spirit, etc.); race and ethnicity are enumerated as above.user_idin the path may be the UUID, username, orcurrent(for nested routes). Addresses and phone numbers use the same shapes as their dedicated endpoints.
Example:
curl -X PUT \
-H "Authorization: Bearer ${api_token}" \
-H "Content-Type: application/json" \
-d '{"user":{"first_name":"Jane","accept_tos":true,"accept_privacy_policy":true}}' \
http://localhost:4000/api/users/current
Response (200 OK):
{
"ok": true,
"code": 200,
"data": {
"id": "c0c7d53e-7a76-4f4f-9f1e-e5a0f6e9c8b1",
"first_name": "Jane",
"latest_tos_accept_ver": 2,
"latest_pp_accept_ver": 1,
"roles": ["user"]
}
}
Delete User
DELETE /api/users/:id
Response: 204 No Content (user record is anonymized)
Sessions (User)
GET /api/sessions/active— active sessions for the current token (paginated)GET /api/sessions/currentPUT /api/sessions/current/extend— body{ "expire_in_seconds": 3600 }PUT /api/users/:user_id/sessions/current/extend— owner/admin alias of the aboveDELETE /api/sessions/currentGET /api/users/:user_id/sessions— paginated, owner or adminGET /api/users/:user_id/sessions/:idPUT /api/users/:user_id/sessions/:id/extend— same body as aboveDELETE /api/users/:user_id/sessions/:idDELETE /api/users/:user_id/sessions— revoke all active sessions for the userGET /api/users/:user_id/sessions/active— active only
Notes:
- Extension endpoints return
403 SessionRevokedOrExpiredwhen the session is no longer valid. DELETE /api/users/:user_id/sessionsresponds with{status, num_revoked, message}so you can confirm how many tokens were revoked.page_num/page_sizeapply to list endpoints; tokens are only returned on session creation, not on reads.
Examples:
- List sessions (paginated):
Response:curl -H "Authorization: Bearer ${api_token}" \ "http://localhost:4000/api/users/current/sessions?page_num=0&page_size=5"{ "ok": true, "code": 200, "page_num": 0, "page_size": 5, "data": [ { "id": "sess-1", "user_id": "...", "expires_at": "2025-01-01T12:00:00Z", "revoked_at": null, "is_valid": true } ]} - Extend current session:
curl -X PUT \ -H "Authorization: Bearer ${api_token}" \ -H "Content-Type: application/json" \ -d '{"expire_in_seconds":3600}' \ http://localhost:4000/api/sessions/current/extend - Revoke a session:
curl -X DELETE -H "Authorization: Bearer ${api_token}" \ http://localhost:4000/api/users/current/sessions/sess-1
Session Extensions
GET /api/sessions/:session_id/extensionsGET /api/session_extensions/:id
Each record includes old_expires_at, new_expires_at, extended_by_seconds, and auditing fields. Lists are ordered newest first. Only the session owner or an admin may view these records; others receive 401/403.
Example list:
curl -H "Authorization: Bearer ${api_token}" \
"http://localhost:4000/api/sessions/${session_id}/extensions?page_num=0&page_size=5"
Response:
{
"ok": true,
"code": 200,
"page_num": 0,
"page_size": 5,
"data": [
{
"id": "ext-1",
"old_expires_at": "2025-01-01T12:00:00Z",
"new_expires_at": "2025-01-02T12:00:00Z",
"extended_by_seconds": 86400,
"extended_by_session": "sess-1",
"extended_by_user": "user-1",
"session_id": "sess-1",
"user_id": "user-1"
}
]
}
Contact Information
All routes require the owner or an admin.
Phone Numbers (/api/users/:user_id/phone_numbers)
GET /— listGET /:idPOST /— body{ "phone_number": { "number": "+1234567890", "primary": true } }PUT /:idDELETE /:id
Create example:
curl -X POST \
-H "Authorization: Bearer ${api_token}" \
-H "Content-Type: application/json" \
-d '{"phone_number":{"number":"+14155550123","primary":true}}' \
http://localhost:4000/api/users/current/phone_numbers
Response (201 Created):
{ "ok": true, "data": { "id": "ph-1", "user_id": "user-1", "number": "+14155550123", "primary": true } }
Addresses (/api/users/:user_id/addresses)
GET /GET /:idPOST /— body{ "address": { "name": "Home", "line_1": "123 Main St", "line_2": "Apt 5", "city": "Anytown", "state": "CA", "postal": "12345", "country": "US", "primary": true } }PUT /:idDELETE /:id
Create example:
curl -X POST \
-H "Authorization: Bearer ${api_token}" \
-H "Content-Type: application/json" \
-d '{"address":{"name":"Home","line_1":"123 Main St","line_2":"Apt 5","city":"Anytown","state":"CA","postal":"12345","country":"US","primary":true}}' \
http://localhost:4000/api/users/current/addresses
Response (201 Created):
{ "ok": true, "data": { "id": "addr-1", "user_id": "user-1", "name": "Home", "city": "Anytown", "state": "CA" } }
Address payloads require line_2 in this API. Both addresses and phone numbers include primary flags and surface verified_at timestamps when present.
Logs (User)
GET /api/logs— paginated logs for the authenticated userGET /api/logs/:id— only if you own the log or are adminGET /api/users/:user_id/logs— owner/admin alias to fetch logs for a specific user
Example:
curl -H "Authorization: Bearer ${api_token}" \
"http://localhost:4000/api/logs?page_num=0&page_size=10"
Response:
{
"ok": true,
"data": [
{ "id": "log-1", "verb": "POST", "type": "sessions", "what": "#SessionController.create/2", "when": "2024-12-16T12:00:00Z" }
],
"page_num": 0,
"page_size": 10
}
Log payloads use type (users|sessions) and HTTP verb (GET|POST|PUT|DELETE). All list endpoints take page_num/page_size.
Admin Endpoints
Require an admin token (roles includes "admin"). These routes skip ToS/Privacy plugs.
Users
GET /api/admin/users— paginated list (page_num/page_size)PUT /api/admin/users/:id— update any user (email/username/password, roles includingmoderator,reset_passwordflag, approved IPs, preferences, addresses, phone numbers)PUT /api/admin/users/:id/lockPUT /api/admin/users/:id/unlock
Examples:
- List users:
curl -H "Authorization: Bearer ${admin_token}" \ "http://localhost:4000/api/admin/users?page_num=0&page_size=10" - Lock a user:
curl -X PUT -H "Authorization: Bearer ${admin_token}" \ http://localhost:4000/api/admin/users/c0c7d53e-7a76-4f4f-9f1e-e5a0f6e9c8b1/lock - Update roles / force reset:
curl -X PUT \ -H "Authorization: Bearer ${admin_token}" \ -H "Content-Type: application/json" \ -d '{"user":{"roles":["admin","moderator"],"reset_password":true,"approved_ips":["1.2.3.4"]}}' \ http://localhost:4000/api/admin/users/c0c7d53e-7a76-4f4f-9f1e-e5a0f6e9c8b1
Password Reset (Admin)
POST /api/admin/users/:id/reset_password— issue reset token (returns token + expiry)PUT /api/admin/users/:id/reset_password/:tokenPUT /api/admin/users/reset_password/:token
Issue token example:
curl -X POST -H "Authorization: Bearer ${admin_token}" \
http://localhost:4000/api/admin/users/c0c7d53e-7a76-4f4f-9f1e-e5a0f6e9c8b1/reset_password
Response:
{
"ok": true,
"code": 200,
"data": {
"password_reset_token": "abcd1234",
"password_reset_token_expires_at": "2025-01-02T12:00:00Z"
}
}
Sessions (Admin)
GET /api/admin/sessions— paginatedDELETE /api/admin/sessions/:id
Logs (Admin)
GET /api/admin/logs— all logs, paginatedGET /api/admin/logs/:idGET /api/admin/logs/users/:user_id— actions performed by userGET /api/admin/logs/sessions/:session_id— actions performed by sessionGET /api/admin/logs/who/:user_id— actions that targeted the user
Example (by user):
curl -H "Authorization: Bearer ${admin_token}" \
"http://localhost:4000/api/admin/logs/users/c0c7d53e-7a76-4f4f-9f1e-e5a0f6e9c8b1?page_size=20"
Field Notes
- Path parameters
:idfor users accept either UUID or username; nesteduser_idroutes also allowcurrent. - Roles supported:
user,admin,moderator. - User payloads include
email_verified,locked_at/locked_by,tos_accepted/privacy_policy_accepted, and acceptance event arrays;token_expiredmay appear on error payloads. - Gender is validated against the enumerated list in the OpenAPI spec (covers cis/trans/non-binary variants). Race is one of the five US census values; ethnicity is Hispanic/Not Hispanic.
- Addresses require
line_2; address/phone responses includeprimaryandverified_atwhen set. - Session creation honors
valid_only_for_ipandvalid_only_for_approved_ips; if a user hasapproved_ips, login is constrained to those addresses even when the flag is false. - Session defaults: expires in 7 days, extendable window 28 days, max single extension 7 days. An absolute cap is configurable; lists of session extensions are ordered newest first.
- Minimum password length defaults to 6 characters (
MIN_PASSWORD_LENGTH).
For quick examples, see scripts/curl/ in the repo.