- Base URL:
http://HOST:PORT/api(every route below is mounted under/api) - Default Content-Type:
application/json - Authorization:
Authorization: Bearer <access_token>on every protected route - Timestamp Format:
RFC3339 / ISO8601(e.g.2026-10-04T12:34:56.789Z) - IDs: All user, group and message IDs are UUIDs
- CORS:
Access-Control-Allow-Origin: *; allowed methodsGET, POST, PUT, DELETE, OPTIONS
Every JSON response uses the same envelope. data is omitted on errors, and error is omitted on success.
{
"success": true,
"data": { },
"error": "<message>"
}| Token | Lifetime | Notes |
|---|---|---|
| Access token | 15 minutes | HS256 JWT, sub = user ID. Send it as a Bearer token, or as ?token= for the WebSocket |
| Refresh token | 7 days | HS256 JWT. Its hash is stored server-side. Each refresh rotates it, so the old refresh token stops working |
Every route behind the auth guard returns this when the access token is missing, expired or malformed. The sections below don't repeat it.
{
"success": false,
"error": "Access token missing, expired or malformed"
}| Method | Endpoint | Auth | Description |
|---|---|---|---|
| GET | /api/health | No | Health check (server + database status) |
| POST | /api/auth/register | No | Register a new user |
| POST | /api/auth/login | No | Log in and receive a token pair |
| POST | /api/auth/refresh | No | Rotate a refresh token for a new token pair |
| GET | /api/auth/{provider} | No | Start Google / GitHub OAuth sign-in |
| GET | /api/auth/{provider}/callback | No | OAuth callback that hands tokens back to the app |
| GET | /api/users/me | Yes | Get the authenticated user's profile |
| GET | /api/users/{id} | Yes | Get a user by ID |
| GET | /api/users/search?q=&page=&limit= | Yes | Search users by username or display name (rate limited) |
| DELETE | /api/users/me | Yes | Permanently delete the authenticated user's account |
| GET | /api/ws?token= | Yes (query) | WebSocket upgrade endpoint |
| WS | Client → Server | — | Events the client sends |
| WS | Server → Client | — | Events the server pushes |
| POST | /api/messages | Yes | Send a direct message (REST fallback, no live delivery) |
| GET | /api/messages?with=&before=&limit= | Yes | Paginated direct message history |
| GET | /api/messages/conversations | Yes | Inbox: latest message and unread count per chat |
| PUT | /api/messages/{id} | Yes | Edit your own message (15-minute window) |
| POST | /api/messages/read | Yes | Mark a sender's messages as read |
| POST | /api/groups | Yes | Create a group |
| GET | /api/groups | Yes | List the groups you belong to |
| GET | /api/groups/{id} | Member | Get group details |
| GET | /api/groups/{id}/messages?before=&limit= | Member | Paginated group message history |
| GET | /api/groups/{id}/members | Member | List group members |
| POST | /api/groups/{id}/leave | Member | Leave a group |
| PATCH | /api/groups/{id} | Admin | Rename a group |
| POST | /api/groups/{id}/members | Admin | Add a member |
| DELETE | /api/groups/{id}/members/{userId} | Admin | Remove a member |
| POST | /api/e2ee/keys | Yes | Upload identity key, signed prekey and one-time prekeys |
| GET | /api/e2ee/bundle/{userId}?device_id= | Yes | Fetch a user's prekey bundle (consumes one one-time prekey) |
| POST | /api/e2ee/verify | Yes | Mark a contact's identity key as verified or unverified |
| GET | /api/e2ee/verify/{userId} | Yes | Get your verification status for a contact |
| POST | /api/e2ee/reset | Yes | Wipe all of your server-side keys (password re-auth) |
Reports the status of the server and its PostgreSQL connection pool.
- URL:
/api/health - Method:
GET - Authentication Required:
NO - curl:
curl -s http://HOST:PORT/api/health | jq .
{
"success": true,
"data": {
"postgres": "up",
"timestamp": "<Timestamp>"
}
}Returned when the database ping fails.
{
"success": false,
"data": {
"postgres": "down",
"timestamp": "<Timestamp>"
},
"error": "Database engine unreachable"
}- URL:
/api/auth/register - Method:
POST - Authentication Required:
NO - curl:
curl -X POST http://HOST:PORT/api/auth/register -H "Content-Type: application/json" -d '{ "username": "<username>", "display_name": "Full Name", "password": "<password>" }' -s
{
"username": "<username>",
"display_name": "Full Name",
"password": "<password>"
}usernameis trimmed and lowercased. It must then be 3–25 characters and contain onlya-zand0-9.passwordmust be at least 8 characters. It is hashed with Argon2id.display_namemust not be empty.
The server always appends a random 4-digit discriminator (1000–9999) to the requested username, e.g. abc → abc.4130. Use the full username returned in the response to log in.
{
"success": true,
"data": {
"tokens": {
"access_token": "<access_token>",
"refresh_token": "<refresh_token>"
},
"user": {
"id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
"username": "<username>.1234",
"display_name": "Full Name",
"created_at": "<Timestamp>"
}
}
}Returned for malformed JSON ("Invalid payload syntax") or a failed validation rule. Examples:
{ "success": false, "error": "username prefix must be between 3 and 25 characters" }{ "success": false, "error": "username prefix must contain only alphanumeric characters" }{ "success": false, "error": "password must be at least 8 characters long" }{ "success": false, "error": "display name cannot be empty" }"Token generation failure" or "Failed to securely record registration token metrics".
- URL:
/api/auth/login - Method:
POST - Authentication Required:
NO - curl:
curl -X POST http://HOST:PORT/api/auth/login -H "Content-Type: application/json" -d '{ "username": "<username>", "password": "<password>" }' -s
{
"username": "<username>.1234",
"password": "<password>"
}The username is trimmed and lowercased before lookup.
{
"success": true,
"data": {
"tokens": {
"access_token": "<access_token>",
"refresh_token": "<refresh_token>"
},
"user": {
"id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
"username": "<username>.1234",
"display_name": "Full Name",
"created_at": "<Timestamp>"
}
}
}{
"success": false,
"error": "invalid username or password credentials"
}{
"success": false,
"error": "Invalid json payload structure"
}Exchanges a refresh token for a new access/refresh pair. The submitted refresh token is revoked.
- URL:
/api/auth/refresh - Method:
POST - Authentication Required:
NO - curl:
curl -X POST http://HOST:PORT/api/auth/refresh -H "Content-Type: application/json" -d '{ "refresh_token": "<refresh_token>" }' -s
{
"refresh_token": "<refresh_token>"
}{
"success": true,
"data": {
"access_token": "<access_token>",
"refresh_token": "<refresh_token>"
}
}"Invalid json payload structure" or "Refresh token parameter missing".
{ "success": false, "error": "invalid or altered refresh token payload" }{ "success": false, "error": "refresh token expired or revoked" }Starts the OAuth sign-in flow. The app opens this URL in a browser.
- URL:
/api/auth/{provider}, whereproviderisgoogleorgithub - Method:
GET - Authentication Required:
NO
Redirects to the provider's consent screen. It also sets an oauth_state cookie (HttpOnly, SameSite=Lax, 5 minutes) that the callback validates.
{
"success": false,
"error": "Unsupported OAuth provider context"
}The provider redirects here. Set it as the redirect URL in your Google / GitHub OAuth app, and in GOOGLE_REDIRECT_URL / GITHUB_REDIRECT_URL.
- URL:
/api/auth/{provider}/callback?code=&state= - Method:
GET - Authentication Required:
NO
- If a user is already linked to this provider account ID, sign them in.
- Otherwise, if a user exists with the same email, link the provider to that user.
- Otherwise, create a new user. The username is
<alphanumeric email handle>.<4 digits>and the display name comes from the provider. OAuth-only accounts have no usable password.
GitHub sign-in requires a verified primary email on the GitHub account.
Returns an HTML handoff page that opens the app with the token pair:
elephant://oauth-callback?access_token=<access_token>&refresh_token=<refresh_token>
On Android, the page first tries an intent://…#Intent;scheme=elephant;package=in.commandlinecoding.elephant;end; URL, then falls back to the custom scheme.
An "Authentication Failed" HTML page is returned when the state cookie doesn't match, the provider exchange fails, or the account can't be created.
User objects can also include email, google_id and github_id. These fields are omitted when empty, and the endpoints below never populate them.
- URL:
/api/users/me - Method:
GET - Authentication Required:
YES - curl:
curl "http://HOST:PORT/api/users/me" -H "Authorization: Bearer <access_token>"
{
"success": true,
"data": {
"id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
"username": "<username>",
"display_name": "Full Name",
"created_at": "<Timestamp>"
}
}{
"success": false,
"error": "User profile not found"
}- URL:
/api/users/{id} - Method:
GET - Authentication Required:
YES - curl:
curl "http://HOST:PORT/api/users/{id}" -H "Authorization: Bearer <access_token>"
{
"success": true,
"data": {
"id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
"username": "<username>",
"display_name": "Full Name",
"created_at": "<Timestamp>"
}
}{
"success": false,
"error": "Requested profile does not exist"
}Case-insensitive substring match on username or display name, ordered by username.
- URL:
/api/users/search?q={query}&page={page}&limit={limit} - Method:
GET - Authentication Required:
YES - Rate limit: Token bucket per client IP: 10-request burst, refilling at 2 requests/second
- curl:
curl "http://HOST:PORT/api/users/search?q={query}&page=1&limit=20" -H "Authorization: Bearer <access_token>"
| Param | Default | Notes |
|---|---|---|
q |
"" |
An empty query matches every user |
page |
1 |
1-based |
limit |
20 |
Values outside 1–100 fall back to 20 |
{
"success": true,
"data": [
{
"id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
"username": "<username>",
"display_name": "Full Name",
"created_at": "<Timestamp>"
}
]
}If no users match, data is an empty array.
{
"success": false,
"error": "Rate limit exceeded. Too many search requests."
}{
"success": false,
"error": "Failed to execute user directory search operations"
}- URL:
/api/users/me - Method:
DELETE - Authentication Required:
YES - curl:
curl -X DELETE "http://HOST:PORT/api/users/me" -H "Authorization: Bearer <access_token>"
Deletes the user. Their messages, group memberships, devices and prekeys, refresh tokens and safety-number verifications are removed by ON DELETE CASCADE, and any live WebSocket is closed. The access token stays valid until it expires but no longer refers to an account.
{
"success": true
}{
"success": false,
"error": "User profile not found"
}{
"success": false,
"error": "Failed to delete account"
}- URL:
/api/ws?token={access_token} - Method:
GET(WebSocket upgrade) - Authentication Required:
YES(access token in thetokenquery parameter) - wscat:
wscat -c "ws://HOST:PORT/api/ws?token={access_token}"
The connection is open. All other connected users receive a user_status event with online: true.
Returned (with no body) when token is missing, expired or invalid.
error: Unexpected server response: 401
- The server sends a WebSocket ping every 54 seconds. A connection that hasn't sent a pong (or any message) within 60 seconds is closed.
- The maximum inbound frame size is 4096 bytes. Larger frames close the connection, so keep encrypted payloads small.
- Each user has one live connection. A new connection for the same user replaces the previous one in the hub.
- Unknown
typevalues (e.g. an application-levelping) and malformed JSON are ignored. - The server never echoes a sent event back to the sender.
Every event targets either a user (receiver_id) or a group (group_id), never both. For group events, the server fans the event out to every member of the group except the sender.
The server stores and relays content as an opaque string. The Elephant app sends a JSON-encoded envelope:
"{\"type\": <int>, \"ciphertext\": \"<base64 or text>\"}"- Direct messages:
ciphertextis a base64-encoded Signal Protocol message, andtypeis the libsignal ciphertext message type. The first message to a new contact is a PreKey message built from the contact's prekey bundle. - Group messages: currently sent with
type: 0, andciphertextholds the plaintext message. Group messages are not yet end-to-end encrypted.
Persists the message and delivers it live to online recipients. Offline recipients pick it up through the history endpoints.
{
"type": "chat",
"message_id": "cccccccc-cccc-cccc-cccc-cccccccccccc",
"receiver_id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
"content": "<content>",
"reply_to_message_id": "dddddddd-dddd-dddd-dddd-dddddddddddd"
}message_idis optional. If you send a client-generated UUID, the server uses it as the stored message ID, which lets offline-queued sends stay idempotent with the local DB. If you omit it, the server generates one.reply_to_message_idis optional.- For a group, send
group_idinstead ofreceiver_id.
{
"type": "read_receipt",
"receiver_id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
}- Direct: marks every unread message from
receiver_idto you as read, and forwards the receipt to that user. - Group (
group_id): updates yourlast_read_atfor the group, and forwards the receipt to the other members.
{
"type": "request_status",
"receiver_id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
}The server replies directly with a user_status event.
{
"type": "typing",
"receiver_id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
"content": "true"
}content is relayed as-is. The app sends "true" when typing starts and "false" when it stops. Use group_id for groups.
{
"type": "chat",
"sender_id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
"receiver_id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
"content": "<content>",
"message_id": "cccccccc-cccc-cccc-cccc-cccccccccccc",
"timestamp": "<Timestamp>"
}For group messages, group_id is set instead of receiver_id.
When reply_to_message_id resolves to a stored message, the event includes a preview of the quoted message. In this form, both receiver_id and group_id are always present, and the unused one is an empty string.
{
"type": "chat",
"sender_id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
"receiver_id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
"group_id": "",
"content": "<content>",
"message_id": "cccccccc-cccc-cccc-cccc-cccccccccccc",
"reply_to_message_id": "dddddddd-dddd-dddd-dddd-dddddddddddd",
"timestamp": "<Timestamp>",
"quoted_message": {
"id": "dddddddd-dddd-dddd-dddd-dddddddddddd",
"sender_id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
"content": "<original message content>"
}
}{
"type": "typing",
"sender_id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
"receiver_id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
"content": "true"
}{
"type": "read_receipt",
"sender_id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
"receiver_id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
"timestamp": "<Timestamp>"
}sender_id is the user who read the messages.
Broadcast to every connected client when a user connects or disconnects. Also sent directly in response to request_status.
{
"type": "user_status",
"user_id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
"online": true
}{
"id": "cccccccc-cccc-cccc-cccc-cccccccccccc",
"sender_id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
"receiver_id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
"group_id": "<group_id>",
"content": "<content>",
"created_at": "<Timestamp>",
"is_read": false,
"edited_at": "<Timestamp>",
"reply_to_message_id": "dddddddd-dddd-dddd-dddd-dddddddddddd",
"quoted_message": {
"id": "dddddddd-dddd-dddd-dddd-dddddddddddd",
"sender_id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
"content": "<quoted content>"
}
}receiver_id, group_id, edited_at, reply_to_message_id and quoted_message are omitted when empty.
REST fallback for direct messages. The message is stored but not pushed over the WebSocket.
- URL:
/api/messages - Method:
POST - Authentication Required:
YES - curl:
curl -X POST "http://HOST:PORT/api/messages" -H "Content-Type: application/json" -H "Authorization: Bearer <access_token>" -d '{ "receiver_id": "<receiver_id>", "content": "<content>" }'
{
"receiver_id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
"content": "<content>",
"reply_to_message_id": "dddddddd-dddd-dddd-dddd-dddddddddddd"
}reply_to_message_id is optional.
{
"success": true,
"data": {
"id": "cccccccc-cccc-cccc-cccc-cccccccccccc",
"sender_id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
"receiver_id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
"content": "<content>",
"created_at": "<Timestamp>",
"is_read": false
}
}"Invalid json payload structure", "Receiver identity token required", "message content cannot be empty", "cannot send a message to yourself", or a database error message.
Returns messages between you and another user, newest first.
- URL:
/api/messages?with={user_id}&before={timestamp}&limit={limit} - Method:
GET - Authentication Required:
YES - curl:
curl "http://HOST:PORT/api/messages?with={user_id}&limit=50" -H "Authorization: Bearer <access_token>"
| Param | Required | Default | Notes |
|---|---|---|---|
with |
Yes | — | The other user's ID |
before |
No | now + 1 minute | RFC3339 timestamp. Returns messages created strictly before it. To page backwards, pass the created_at of the oldest message you have |
limit |
No | 50 |
Values outside 1–100 fall back to 50 |
{
"success": true,
"data": [
{
"id": "cccccccc-cccc-cccc-cccc-cccccccccccc",
"sender_id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
"receiver_id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
"content": "<content>",
"created_at": "<Timestamp>",
"is_read": true,
"edited_at": "<Timestamp>",
"reply_to_message_id": "dddddddd-dddd-dddd-dddd-dddddddddddd",
"quoted_message": {
"id": "dddddddd-dddd-dddd-dddd-dddddddddddd",
"sender_id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
"content": "<quoted content>"
}
}
]
}{
"success": false,
"error": "Missing target 'with' user query constraint"
}"Failed to pull chat logs".
Returns one entry per direct chat and per group you belong to, ordered by latest message (newest first). Groups with no messages yet are not listed.
- URL:
/api/messages/conversations - Method:
GET - Authentication Required:
YES - curl:
curl "http://HOST:PORT/api/messages/conversations" -H "Authorization: Bearer <access_token>"
{
"success": true,
"data": [
{
"id": "<other user's id | group id>",
"type": "direct",
"name": "<username | group name>",
"display_name": "<display name, empty for groups>",
"last_message": "<content>",
"last_message_time": "<Timestamp>",
"sender_id": "<sender of the last message>",
"is_read": false,
"unread_count": 3
}
]
}typeisdirectorgroup.- Direct:
is_readis the read flag of the last message, andunread_countcounts unread messages sent to you. - Group:
is_readis true if the last message is no newer than yourlast_read_at, andunread_countcounts other members' messages since then.
"Failed to compile inbox conversations".
Replaces the content of a message you sent. Edits are only allowed within 15 minutes of created_at, and they are not broadcast over the WebSocket. For E2EE direct messages, the client must send newly encrypted content.
- URL:
/api/messages/{id} - Method:
PUT - Authentication Required:
YES - curl:
curl -X PUT "http://HOST:PORT/api/messages/{id}" -H "Content-Type: application/json" -H "Authorization: Bearer <access_token>" -d '{ "content": "<new content>" }'
{
"content": "<new content>"
}{
"success": true,
"data": {
"id": "cccccccc-cccc-cccc-cccc-cccccccccccc",
"sender_id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
"receiver_id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
"content": "<new content>",
"created_at": "<Timestamp>",
"is_read": false,
"edited_at": "<Timestamp>"
}
}"Message ID required", "Invalid json payload structure" or "Content cannot be empty".
{ "success": false, "error": "You can only edit your own messages" }{ "success": false, "error": "Message can no longer be edited (15 minute window expired)" }{ "success": false, "error": "Message not found" }Marks every unread message that sender_id sent to you as read. This is the REST equivalent of the WebSocket read_receipt, but the sender is not notified.
- URL:
/api/messages/read - Method:
POST - Authentication Required:
YES - curl:
curl -X POST "http://HOST:PORT/api/messages/read" -H "Content-Type: application/json" -H "Authorization: Bearer <access_token>" -d '{ "sender_id": "<sender_id>" }'
{
"sender_id": "<sender_id>"
}{
"success": true,
"data": "Target messages marked read successfully"
}"Invalid payload layout" or "Sender identification missing".
Routes under /api/groups/{id} require group membership. When the caller isn't a member, or the group doesn't exist, they return:
{
"success": false,
"error": "Access denied: you are not a member of this group"
}Admin-only routes additionally return 403 for non-admins:
{
"success": false,
"error": "Administrative clearance privileges required"
}The group creator becomes its admin, and added users join as member.
- URL:
/api/groups - Method:
POST - Authentication Required:
YES - curl:
curl -X POST "http://HOST:PORT/api/groups" -H "Content-Type: application/json" -H "Authorization: Bearer <access_token>" -d '{ "name": "<group_name>" }'
{
"name": "<group_name>"
}{
"success": true,
"data": {
"id": "<group_id>",
"name": "<group_name>",
"created_by": "<creator_id>",
"created_at": "<Timestamp>"
}
}"Invalid payload group name configuration" (empty or missing name).
Returns the groups you belong to, newest first.
- URL:
/api/groups - Method:
GET - Authentication Required:
YES - curl:
curl "http://HOST:PORT/api/groups" -H "Authorization: Bearer <access_token>"
{
"success": true,
"data": [
{
"id": "<group_id>",
"name": "<group_name>",
"created_by": "<creator_id>",
"created_at": "<Timestamp>"
}
]
}- URL:
/api/groups/{id} - Method:
GET - Authentication Required:
YES(member) - curl:
curl "http://HOST:PORT/api/groups/{id}" -H "Authorization: Bearer <access_token>"
{
"success": true,
"data": {
"id": "<group_id>",
"name": "<group_name>",
"created_by": "<creator_id>",
"created_at": "<Timestamp>"
}
}"Group not found".
Returns group messages, newest first.
- URL:
/api/groups/{id}/messages?before={timestamp}&limit={limit} - Method:
GET - Authentication Required:
YES(member) - curl:
curl "http://HOST:PORT/api/groups/{id}/messages?limit=50" -H "Authorization: Bearer <access_token>"
| Param | Default | Notes |
|---|---|---|
before |
now | RFC3339 timestamp. Returns messages created strictly before it |
limit |
50 |
Values outside 1–100 fall back to 50 |
{
"success": true,
"data": [
{
"id": "<message_id>",
"sender_id": "<sender_id>",
"group_id": "<group_id>",
"content": "<content>",
"created_at": "<Timestamp>",
"is_read": false,
"edited_at": "<Timestamp>",
"reply_to_message_id": "<message_id>",
"quoted_message": {
"id": "<message_id>",
"sender_id": "<user_id>",
"content": "<quoted content>"
}
}
]
}is_read is always false for group messages. Use the unread_count in Conversations to track group read state.
Members are listed admins first, then by join date.
- URL:
/api/groups/{id}/members - Method:
GET - Authentication Required:
YES(member) - curl:
curl "http://HOST:PORT/api/groups/{id}/members" -H "Authorization: Bearer <access_token>"
{
"success": true,
"data": [
{
"user_id": "<user_id>",
"username": "<username>",
"display_name": "<Full Name>",
"role": "admin",
"joined_at": "<Timestamp>"
},
{
"user_id": "<user_id>",
"username": "<username>",
"display_name": "<Full Name>",
"role": "member",
"joined_at": "<Timestamp>"
}
]
}role is either admin or member.
- URL:
/api/groups/{id}/leave - Method:
POST - Authentication Required:
YES(member) - curl:
curl -X POST "http://HOST:PORT/api/groups/{id}/leave" -H "Authorization: Bearer <access_token>"
{
"success": true,
"data": "You have left the group"
}"Failed to leave the group".
- URL:
/api/groups/{id} - Method:
PATCH - Authentication Required:
YES(admin) - curl:
curl -X PATCH "http://HOST:PORT/api/groups/{id}" -H "Content-Type: application/json" -H "Authorization: Bearer <access_token>" -d '{ "name": "<new_group_name>" }'
{
"name": "<new_group_name>"
}{
"success": true,
"data": "Group details updated successfully"
}"Invalid group name".
Adding a user who is already a member is a no-op that still returns 200.
- URL:
/api/groups/{id}/members - Method:
POST - Authentication Required:
YES(admin) - curl:
curl -X POST "http://HOST:PORT/api/groups/{id}/members" -H "Content-Type: application/json" -H "Authorization: Bearer <access_token>" -d '{ "user_id": "<user_id>" }'
{
"user_id": "<user_id>"
}{
"success": true,
"data": "User attached to channel context cleanly"
}"Missing user target value".
Returned when the user doesn't exist:
{
"success": false,
"error": "Failed to add member to channel"
}- URL:
/api/groups/{id}/members/{userId} - Method:
DELETE - Authentication Required:
YES(admin) - curl:
curl -X DELETE "http://HOST:PORT/api/groups/{id}/members/{userId}" -H "Authorization: Bearer <access_token>"
{
"success": true,
"data": "Member detached from group context cleanly"
}{
"success": false,
"error": "Failed to drop target user from channel"
}The server is a public-key directory for the Signal Protocol (X3DH + Double Ratchet). It stores only public keys, and private keys never leave the device. All keys are base64-encoded strings. Keys are stored per (user_id, device_id), and the app currently always uses device_id = "main".
Creates or replaces your device's identity key and signed prekey, and appends the supplied one-time prekeys.
If the identity key differs from the one previously stored for this device, every other user's verification of you is reset to unverified.
- URL:
/api/e2ee/keys - Method:
POST - Authentication Required:
YES - curl:
curl -X POST "http://HOST:PORT/api/e2ee/keys" -H "Content-Type: application/json" -H "Authorization: Bearer <access_token>" -d @bundle.json
{
"device_id": "main",
"identity_key": "<base64>",
"signed_prekey": "<base64>",
"signature": "<base64 signature of signed_prekey>",
"one_time_prekeys": [
{ "id": 1, "content": "<base64>" },
{ "id": 2, "content": "<base64>" }
]
}device_id defaults to main. identity_key, signed_prekey and signature are required.
{
"success": true,
"data": {
"message": "Prekey cryptographical parameters saved successfully",
"requires_refill": false
}
}requires_refill is true when fewer than 10 one-time prekeys remain for the device.
"Malformatted JSON configuration payload structure".
Returned when a required key is missing ("missing core payload parameters for prekey bundle submission") or the database write fails.
Returns a user's public keys so that you can start a session with them. Each call consumes (deletes) one one-time prekey. When the user has none left, the one_time_prekey_* fields are omitted.
- URL:
/api/e2ee/bundle/{userId}?device_id=main - Method:
GET - Authentication Required:
YES - curl:
curl "http://HOST:PORT/api/e2ee/bundle/{userId}?device_id=main" -H "Authorization: Bearer <access_token>"
{
"success": true,
"data": {
"user_id": "<user_id>",
"device_id": "main",
"identity_key": "<base64>",
"signed_prekey": "<base64>",
"signature": "<base64>",
"one_time_prekey_id": 1,
"one_time_prekey_body": "<base64>"
}
}Returned when the user hasn't uploaded keys yet:
{
"success": false,
"error": "device keys not found for target identity profile"
}Records whether you have verified a contact's identity key (e.g. by comparing safety numbers or scanning a QR code).
- URL:
/api/e2ee/verify - Method:
POST - Authentication Required:
YES - curl:
curl -X POST "http://HOST:PORT/api/e2ee/verify" -H "Content-Type: application/json" -H "Authorization: Bearer <access_token>" -d '{ "verified_user_id": "<user_id>", "is_verified": true }'
{
"verified_user_id": "<user_id>",
"is_verified": true
}{
"success": true,
"data": { "message": "Contact verification status updated" }
}"Invalid JSON payload structure", "target verified_user_id is required" or "cannot verify own user identity".
- URL:
/api/e2ee/verify/{userId} - Method:
GET - Authentication Required:
YES - curl:
curl "http://HOST:PORT/api/e2ee/verify/{userId}" -H "Authorization: Bearer <access_token>"
is_verified is false if you never verified this user, or if they have uploaded a new identity key since you did.
{
"success": true,
"data": {
"user_id": "<your user_id>",
"verified_user_id": "<user_id>",
"is_verified": true
}
}Deletes all of your devices, identity keys and one-time prekeys, and marks every other user's verification of you as unverified. Afterwards, generate and upload a new bundle.
- URL:
/api/e2ee/reset - Method:
POST - Authentication Required:
YES - curl:
curl -X POST "http://HOST:PORT/api/e2ee/reset" -H "Content-Type: application/json" -H "Authorization: Bearer <access_token>" -d '{ "password": "<password>" }'
{
"password": "<your account password>"
}{
"success": true,
"data": {
"message": "All encryption keys wiped successfully. Please re-generate and upload a new prekey bundle."
}
}"password required for re-authentication", "invalid password re-authentication attempt" or "user account context not found".