Complete API documentation for HushNet Backend.
- Authentication
- Root Endpoints
- User Endpoints
- Device Endpoints
- Session Endpoints
- Chat Endpoints
- Message Endpoints
- WebSocket Endpoints
- Federation — Inter-Node Messaging
- Error Responses
HushNet uses Ed25519 signature-based authentication instead of JWT. Each authenticated request must include the following headers:
| Header | Description | Format |
|---|---|---|
X-Identity-Key |
Base64-encoded Ed25519 public key (32 bytes) | base64(public_key) |
X-Signature |
Base64-encoded signature (64 bytes) | base64(signature) |
X-Timestamp |
Unix timestamp (seconds) | 1698765432 |
- Client generates a current Unix timestamp
- Client signs the timestamp with their Ed25519 private key
- Client sends request with headers:
X-Identity-Key: Device's identity public keyX-Signature: Signature of the timestampX-Timestamp: The timestamp that was signed
- Server verifies:
- Timestamp is within 30 seconds window (anti-replay)
- Signature is valid for the given public key
- Device exists with that identity key
import { sign } from '@noble/ed25519';
const timestamp = Math.floor(Date.now() / 1000).toString();
const signature = await sign(
new TextEncoder().encode(timestamp),
privateKey
);
const headers = {
'X-Identity-Key': base64Encode(publicKey),
'X-Signature': base64Encode(signature),
'X-Timestamp': timestamp,
'Content-Type': 'application/json'
};| Status | Error | Description |
|---|---|---|
401 |
Missing X-Identity-Key | Header not provided |
401 |
Missing X-Signature | Header not provided |
401 |
Missing X-Timestamp | Header not provided |
401 |
Expired timestamp | Timestamp outside 30s window |
401 |
Signature mismatch | Invalid signature |
401 |
Unknown device | Device not registered |
400 |
Bad signature b64 | Invalid base64 encoding |
400 |
Signature must be 64 bytes | Wrong signature length |
400 |
Bad pubkey b64 | Invalid base64 encoding |
400 |
Bad pubkey length | Public key not 32 bytes |
400 |
Bad pubkey | Invalid Ed25519 public key |
Health check endpoint.
Authentication: Not required
Response: 200 OK
{
"message": "Hello from HushNet Backend",
"version": "0.1.0",
"status": "healthy"
}List all users.
Authentication: Not required
Response: 200 OK
[
{
"id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"username": "alice",
"created_at": "2025-11-02T10:30:00Z"
},
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"username": "bob",
"created_at": "2025-11-02T11:00:00Z"
}
]Create a new user.
Authentication: Not required
Request Body:
{
"username": "alice"
}Response: 201 Created
{
"id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"username": "alice",
"created_at": "2025-11-02T10:30:00Z"
}Errors:
400 Bad Request: Invalid username409 Conflict: Username already exists
Get user by ID.
Authentication: Required
Parameters:
id(UUID): User ID
Response: 200 OK
{
"id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"username": "alice",
"created_at": "2025-11-02T10:30:00Z"
}Errors:
401 Unauthorized: Invalid authentication404 Not Found: User does not exist
Authenticate a user (returns user info for verification).
Authentication: Not required
Request Body:
{
"username": "alice",
"identity_pubkey": "base64_encoded_public_key"
}Response: 200 OK
{
"user_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"device_id": "d1e2f3a4-b5c6-7890-abcd-ef1234567890",
"username": "alice"
}Errors:
401 Unauthorized: Invalid credentials404 Not Found: User or device not found
List all devices for a user.
Authentication: Required
Parameters:
user_id(UUID): User ID
Response: 200 OK
[
{
"id": "d1e2f3a4-b5c6-7890-abcd-ef1234567890",
"user_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"identity_pubkey": "base64_encoded_key",
"prekey_pubkey": "base64_encoded_key",
"signed_prekey_pub": "base64_encoded_key",
"signed_prekey_sig": "base64_encoded_sig",
"one_time_prekeys": ["key1", "key2", "key3"],
"device_label": "iPhone 15 Pro",
"push_token": "apns_token_here",
"last_seen": "2025-11-02T14:30:00Z",
"created_at": "2025-11-02T10:30:00Z"
}
]Register a new device.
Authentication: Required
Request Body:
{
"user_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"identity_pubkey": "base64_encoded_identity_key",
"prekey_pubkey": "base64_encoded_prekey",
"signed_prekey_pub": "base64_encoded_signed_prekey_pub",
"signed_prekey_sig": "base64_encoded_signed_prekey_signature",
"one_time_prekeys": [
"base64_encoded_otpk_1",
"base64_encoded_otpk_2",
"base64_encoded_otpk_3"
],
"device_label": "iPhone 15 Pro",
"push_token": "apns_or_fcm_token"
}Response: 201 Created
{
"id": "d1e2f3a4-b5c6-7890-abcd-ef1234567890",
"user_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"identity_pubkey": "base64_encoded_identity_key",
"device_label": "iPhone 15 Pro",
"created_at": "2025-11-02T10:30:00Z"
}Errors:
400 Bad Request: Invalid device data401 Unauthorized: Invalid authentication409 Conflict: Device already exists
Get public keys for all devices of a user (for key exchange).
Authentication: Required
Parameters:
username(string): Username
Response: 200 OK
{
"user_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"username": "alice",
"devices": [
{
"device_id": "d1e2f3a4-b5c6-7890-abcd-ef1234567890",
"identity_pubkey": "base64_encoded_key",
"signed_prekey_pub": "base64_encoded_key",
"signed_prekey_sig": "base64_encoded_sig",
"one_time_prekey": "base64_encoded_otpk"
}
]
}Note: One-time prekeys are consumed after retrieval.
Initiate an X3DH session with another device.
Authentication: Required
Request Body:
{
"sender_device_id": "your_device_uuid",
"recipient_device_id": "recipient_device_uuid",
"ephemeral_pubkey": "base64_encoded_ephemeral_key",
"sender_prekey_pub": "base64_encoded_sender_prekey",
"otpk_used": "base64_encoded_one_time_prekey_used",
"ciphertext": "base64_encoded_initial_message"
}Response: 201 Created
{
"session_id": "session-uuid",
"status": "pending",
"created_at": "2025-11-02T10:30:00Z"
}Get pending session requests for the authenticated device.
Authentication: Required
Query Parameters:
device_id(UUID): Device ID
Response: 200 OK
[
{
"id": "session-uuid",
"sender_device_id": "sender-device-uuid",
"recipient_device_id": "your-device-uuid",
"ephemeral_pubkey": "base64_encoded_key",
"sender_prekey_pub": "base64_encoded_key",
"otpk_used": "base64_encoded_key",
"ciphertext": "base64_encoded_message",
"state": "initiated",
"created_at": "2025-11-02T10:30:00Z"
}
]Confirm and complete a session.
Authentication: Required
Request Body:
{
"pending_session_id": "session-uuid",
"chat_id": "chat-uuid"
}Response: 200 OK
{
"session_id": "confirmed-session-uuid",
"status": "completed",
"chat_id": "chat-uuid"
}Get all chats for the authenticated user.
Authentication: Required
Query Parameters:
user_id(UUID): User ID
Response: 200 OK
[
{
"id": "chat-uuid",
"chat_type": "direct",
"user_a": "user-uuid-1",
"user_b": "user-uuid-2",
"name": null,
"last_message_id": "message-uuid",
"created_at": "2025-11-02T10:00:00Z",
"updated_at": "2025-11-02T14:30:00Z"
},
{
"id": "chat-uuid-2",
"chat_type": "group",
"name": "Project Team",
"owner_id": "user-uuid-1",
"last_message_id": "message-uuid-2",
"created_at": "2025-11-01T09:00:00Z",
"updated_at": "2025-11-02T15:00:00Z"
}
]Get all device IDs participating in a chat.
Authentication: Required
Parameters:
chat_id(UUID): Chat ID
Response: 200 OK
{
"chat_id": "chat-uuid",
"devices": [
"device-uuid-1",
"device-uuid-2",
"device-uuid-3"
]
}Send an encrypted message.
Authentication: Required
Request Body:
{
"chat_id": "chat-uuid",
"to_device_id": "recipient-device-uuid",
"header": {
"dh_pubkey": "base64_encoded_ratchet_key",
"pn": 0,
"n": 1
},
"ciphertext": "base64_encoded_encrypted_content"
}Response: 201 Created
{
"message_id": "message-uuid",
"logical_msg_id": "logical-uuid",
"created_at": "2025-11-02T14:30:00Z"
}Get pending messages for a device.
Authentication: Required
Parameters:
device_id(UUID): Device ID
Response: 200 OK
[
{
"id": "message-uuid",
"logical_msg_id": "logical-uuid",
"chat_id": "chat-uuid",
"from_user_id": "sender-user-uuid",
"from_device_id": "sender-device-uuid",
"to_user_id": "your-user-uuid",
"to_device_id": "your-device-uuid",
"header": {
"dh_pubkey": "base64_key",
"pn": 0,
"n": 1
},
"ciphertext": "base64_encrypted_content",
"delivered_at": null,
"read_at": null,
"created_at": "2025-11-02T14:30:00Z"
}
]Establish a WebSocket connection for real-time events.
Authentication: Query parameter user_id required
Connection:
const ws = new WebSocket('ws://127.0.0.1:8080/ws?user_id=<user-uuid>');{
"type": "message",
"chat_id": "chat-uuid",
"user_id": "recipient-user-uuid"
}Action: Fetch pending messages for the chat.
{
"type": "session",
"user_id": "affected-user-uuid",
"sender_device_id": "sender-device-uuid",
"receiver_device_id": "receiver-device-uuid"
}Action: Fetch pending sessions.
{
"type": "device",
"user_id": "user-uuid"
}Action: Refresh device list for the user.
Federation allows users registered on different HushNet nodes to exchange messages. The cryptographic layer (X3DH, Double Ratchet) is unchanged — clients remain responsible for all key agreement and encryption. Servers route opaque ciphertexts; no node can read message content.
┌───────────────────────────────────────────────────────────────────────┐
│ HushNet Network │
│ │
│ ┌──────────────────┐ S2S (Ed25519) ┌───────────────┐ │
│ │ Node A │◄────────────────────────────► Node B │ │
│ │ │ │ │ │
│ │ Alice (local) │ │ Bob (local) │ │
│ │ Bob (shadow)◄──┼──────────────────────────────┼─────────────┘ │
│ └──────────────────┘ └───────────────┘ │
│ ▲ ▲ │
│ │ client API client API │ │
│ │ │ │
│ Client A Client B │
└───────────────────────────────────────────────────────────────────────┘
Invariants:
- Clients always talk to their home node only.
- Nodes forward already-encrypted payloads. No node sees plaintext.
- Private keys never leave the client device.
- Double Ratchet session state never touches a server.
A federated address uniquely identifies a user across all nodes:
alice@node-a.hushnet.net
bob@node-b.hushnet.net
Format: {username}@{node_host}
The node host is the NODE_HOST environment variable set at startup. It is registered at the central registry (registry.hushnet.net) alongside the node's Ed25519 public key and API base URL.
Routing rule (applied server-side on every relevant request):
- If
node_host == this node→ local delivery (existing path, unchanged). - If
node_host != this node→ federated path (S2S forwarding).
Every outbound S2S request from Node A carries four headers. Node B verifies them in sequence before executing the handler.
| Header | Value |
|---|---|
X-Node-ID |
Sender's canonical node identifier (node-a.hushnet.net) |
X-Timestamp |
Unix seconds as a decimal string |
X-Nonce |
16 random bytes, base64-encoded |
X-Node-Signature |
Ed25519 signature, base64-encoded |
Canonical string signed (fields joined by \n, UTF-8):
{HTTP_METHOD}\n{path}\n{timestamp}\n{nonce}
Only the path portion of the URL is signed (no scheme, no host), so the canonical string is stable regardless of which domain name the caller used.
Verification sequence on Node B:
flowchart TD
A[Inbound S2S request] --> B{"abs(now - timestamp) <= 60s?"}
B -- no --> R1[401 timestamp out of window]
B -- yes --> C{node_id in federation_nodes?}
C -- no --> D[GET registry/nodes/node_id]
D --> E{registry responds 200?}
E -- no --> R2[401 peer not found in registry]
E -- yes --> F[upsert federation_nodes]
F --> G
C -- yes --> G{is_blocked = false?}
G -- no --> R3[403 node is blocked]
G -- yes --> H[verify Ed25519 signature]
H --> I{valid?}
I -- no --> R4[401 invalid node signature]
I -- yes --> J[INSERT used_node_nonces ON CONFLICT DO NOTHING]
J --> K{rows_affected = 1?}
K -- no --> R5[401 replayed nonce]
K -- yes --> L[execute handler]
Public key discovery: On first contact from an unknown peer, Node B fetches GET {registry_url}/api/registry/nodes/{node_id} and caches the result in federation_nodes. Subsequent requests use the cache (no registry call).
Anti-replay: The (node_id, nonce) pair is stored in used_node_nonces immediately after signature verification. Nonces are unique per request; the 60-second timestamp window bounds how long they need to be retained. The outbox worker purges entries older than 5 minutes.
Before initiating X3DH with a remote user, Client A must obtain Bob's prekey bundle from Node B. Node A acts as a transparent proxy; it does not cache the bundle because OTPKs are one-time-use.
sequenceDiagram
participant CA as Client A
participant NA as Node A
participant NB as Node B
CA->>NA: GET /users/federated/bob@node-b.hushnet.net/keys
Note over NA: AuthenticatedDevice (Alice's headers)
NA->>NA: resolve node-b in federation_nodes<br/>(registry fallback if unknown)
NA->>NB: GET /s2s/users/bob/keys<br/>X-Node-ID / X-Timestamp / X-Nonce / X-Node-Signature
Note over NB: AuthenticatedNode verifies signature
NB->>NB: look up local user "bob"<br/>fetch device bundles<br/>consume one OTPK per device
NB-->>NA: 200 DeviceBundle[]
NA-->>CA: 200 DeviceBundle[]
Note over CA: X3DH key agreement performed locally<br/>shared secret never leaves client
Client A performs X3DH locally using Bob's prekeys, then sends the encrypted init envelope to Node A. Node A forwards it to Node B synchronously (no outbox — session inits are small and need to be prompt).
sequenceDiagram
participant CA as Client A
participant NA as Node A
participant NB as Node B
participant CB as Client B
CA->>NA: POST /sessions<br/>{ recipient_user_address: "bob@node-b.hushnet.net",<br/> sessions_init: [...] }
Note over NA: AuthenticatedDevice
NA->>NA: parse address → username=bob, node=node-b
NA->>NA: resolve Node B (DB cache / registry)
NA->>NB: POST /s2s/sessions<br/>{ from_federated_address: "alice@node-a",<br/> from_device_id, from_identity_pubkey,<br/> to_user: "bob", sessions_init: [...] }
Note over NB: AuthenticatedNode
NB->>NB: upsert shadow user alice@node-a<br/>upsert shadow device (alice_device_id)
NB->>NB: INSERT pending_sessions<br/>(pg trigger fires)
NB-->>NA: 200 { status: "ok" }
NA-->>CA: 202 Accepted { status: "forwarded" }
NB-->>CB: WebSocket event<br/>{ type: "pending_session", ... }
Note over CB: fetches GET /sessions/pending<br/>confirms session locally (unchanged flow)
Client A sends an already-encrypted message to Node A. Node A writes to the outbox for durability, then immediately attempts delivery to Node B in a background task. The client receives 202 Accepted before the S2S call completes.
sequenceDiagram
participant CA as Client A
participant NA as Node A
participant NB as Node B
participant CB as Client B
CA->>NA: POST /messages<br/>{ to_user_address: "bob@node-b.hushnet.net",<br/> logical_msg_id, payloads: [...] }
Note over NA: AuthenticatedDevice
NA->>NA: resolve Node B
NA->>NA: serialize S2sMessagePayload
NA->>NA: INSERT federation_outbox (durable)
NA-->>CA: 202 Accepted { status: "queued" }
NA-)NB: POST /s2s/messages (background task)<br/>{ logical_msg_id, from_federated_address,<br/> from_device_id, from_identity_pubkey,<br/> to_user: "bob", payloads: [...] }
Note over NB: AuthenticatedNode
NB->>NB: resolve local user "bob" (404 if not found)
NB->>NB: upsert shadow user + device for Alice
NB->>NB: get_or_create_direct_chat(shadow_alice, bob)
NB->>NB: INSERT messages per device payload<br/>ON CONFLICT (logical_msg_id, to_device_id) DO NOTHING
NB->>NB: pg trigger fires → pg_notify
NB-->>NA: 200 S2sAck { status: "delivered" | "duplicate" }
NA->>NA: UPDATE federation_outbox SET status='delivered'
NB-->>CB: WebSocket event { type: "message", ... }
Note over CB: fetches GET /messages/pending (unchanged)
Outbox retry schedule:
| Attempt | Delay before next retry |
|---|---|
| 1 | 10 s |
| 2 | 20 s |
| 3 | 40 s |
| 4 | 80 s |
| 5 | 160 s |
| 6 | 320 s |
| 7 | 640 s |
| 8 | 1 280 s |
| 9 | 2 560 s |
| 10 | — (marked failed) |
When Node B receives a message from alice@node-a, it needs valid rows in users and devices to satisfy the foreign-key constraints on the messages table. Two shadow records are upserted automatically:
Shadow user (users.home_node_id IS NOT NULL):
username="alice"(local part of the federated address)federated_address="alice@node-a.hushnet.net"(unique key for upsert)home_node_id→ FK tofederation_nodes
Shadow device (devices with empty prekey fields):
id= device UUID from Node A (reused; UUID collision probability negligible)identity_pubkey= Alice's Ed25519 IK (included in every S2S payload)prekey_pubkey,signed_prekey_pub,signed_prekey_sig=""(never queried)one_time_prekeys=[](never queried)
Shadow records are never returned by client-facing endpoints (GET /users, GET /users/:id/keys, etc.) because those queries filter WHERE home_node_id IS NULL.
Fetch the prekey bundle for a remote user. Node A proxies the request to the user's home node.
Authentication: Required (standard AuthenticatedDevice headers)
Path parameter:
| Name | Type | Description |
|---|---|---|
address |
string | Full federated address: bob@node-b.hushnet.net |
Response: 200 OK — same structure as local GET /users/:id/keys
[
{
"device_id": "d1e2f3a4-b5c6-7890-abcd-ef1234567890",
"identity_pubkey": "base64...",
"signed_prekey_pub": "base64...",
"signed_prekey_sig": "base64...",
"one_time_prekeys": ["base64_otpk_1"]
}
]Errors:
| Status | Condition |
|---|---|
400 |
Address missing @ separator |
403 |
Target node is blocked |
404 |
Remote user not found on target node |
502 |
Target node returned an error |
503 |
Registry unreachable and node unknown |
Two existing endpoints accept an additional optional field for cross-node delivery. Clients that do not send the new field continue to work without modification.
{
"chat_id": "uuid",
"logical_msg_id": "string",
"to_user_id": "uuid",
"to_user_address": "bob@node-b.hushnet.net",
"payloads": [
{
"to_device_id": "uuid",
"header": { "...": "..." },
"ciphertext": "base64..."
}
]
}When to_user_address is present and its node portion differs from this node's NODE_HOST, the message is forwarded via S2S. to_user_id is still required for schema compatibility but is ignored in the federated path.
Response when federated: 202 Accepted
{ "status": "queued" }{
"recipient_user_id": "uuid",
"recipient_user_address": "bob@node-b.hushnet.net",
"sessions_init": [ { "...": "..." } ]
}Response when federated: 202 Accepted
{ "status": "forwarded" }These endpoints are consumed exclusively by peer nodes. Client applications must never call them directly.
Return this node's public identity. No authentication required — this is the bootstrap endpoint that lets an unknown peer fetch the public key before the registry has been consulted.
Response: 200 OK
{
"node_id": "node-a.hushnet.net",
"api_url": "https://node-a.hushnet.net/api",
"public_key_b64": "base64_ed25519_verifying_key",
"protocol_version": "0.0.1"
}Security note: The returned key should be cross-checked against the central registry before being trusted. A MITM that intercepts this call could substitute their own key if the channel is not TLS-protected.
Return the full device list for a local user.
Authentication: S2S (AuthenticatedNode)
Response: 200 OK — array of Devices records (same structure as GET /users/:id/devices)
Errors: 404 if user does not exist or is a shadow record.
Return the prekey bundle for a local user, consuming one OTPK per device. Semantics are identical to the local GET /users/:id/keys.
Authentication: S2S (AuthenticatedNode)
Response: 200 OK — DeviceBundle[]
Errors: 404 if user does not exist or is a shadow record.
Accept a forwarded X3DH session initiation.
Authentication: S2S (AuthenticatedNode)
Request body:
{
"from_federated_address": "alice@node-a.hushnet.net",
"from_device_id": "uuid",
"from_identity_pubkey": "base64...",
"to_user": "bob",
"sessions_init": [
{
"recipient_device_id": "uuid",
"ephemeral_pubkey": "base64...",
"sender_prekey_pub": "base64...",
"otpk_used": "true",
"ciphertext": "base64..."
}
]
}Response: 200 OK
{ "status": "ok" }Accept forwarded encrypted message payloads for a local recipient.
Authentication: S2S (AuthenticatedNode)
Request body:
{
"logical_msg_id": "string",
"from_federated_address": "alice@node-a.hushnet.net",
"from_device_id": "uuid",
"from_identity_pubkey": "base64...",
"to_user": "bob",
"payloads": [
{
"to_device_id": "uuid",
"header": { "dh": "base64...", "pn": 0, "n": 1 },
"ciphertext": "base64..."
}
]
}Response: 200 OK
{
"logical_msg_id": "string",
"status": "delivered"
}status is "duplicate" if all payloads were already present in the database (idempotent retry from the sender's outbox). The sender must treat both "delivered" and "duplicate" as success and stop retrying.
Errors:
| Status | Condition |
|---|---|
404 |
Recipient username not found or is a shadow record |
500 |
DB error during shadow upsert or message insert |
Delivery acknowledgment sent from Node B back to Node A. Advisory: Node A's outbox worker already marks entries delivered when it receives a 200 from POST /s2s/messages. This endpoint is for explicit acks sent by Node B after delayed processing.
Authentication: S2S (AuthenticatedNode)
Request body:
{
"logical_msg_id": "string",
"status": "delivered"
}Response: 200 OK
{ "status": "ack received" }| Failure | Node A behavior | Node B behavior |
|---|---|---|
| Node B unreachable | 202 to client; outbox retries with backoff | — |
| Node B returns 404 for recipient | outbox entry marked failed immediately; no retry |
404 response |
| All OTPKs depleted on Node B | bundle returned with empty one_time_prekeys; client proceeds with SPK-only X3DH |
one_time_prekeys: [] in response |
| Duplicate message delivery (outbox retry) | outbox marked delivered on any 200 |
INSERT ... ON CONFLICT DO NOTHING; returns status: "duplicate" |
| Delayed or missing ack | outbox resends after TTL; Node B's idempotent insert prevents double storage | — |
| Invalid S2S signature | — | 401; sender logs and does not retry same payload |
| Blocked peer node | 403 returned on any S2S request | — |
| Registry unreachable at auth time | — | 503; request rejected; sender may retry later |
The federation layer adds three tables and two columns to the existing schema. All changes are additive; no existing table is altered destructively.
-- Peer node registry
CREATE TABLE federation_nodes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
node_id TEXT UNIQUE NOT NULL, -- "node-a.hushnet.net"
api_url TEXT NOT NULL,
public_key_b64 TEXT NOT NULL, -- Ed25519 verifying key
last_seen TIMESTAMPTZ,
is_blocked BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Anti-replay nonce store (TTL: 5 minutes)
CREATE TABLE used_node_nonces (
nonce TEXT NOT NULL,
node_id TEXT NOT NULL,
used_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (nonce, node_id)
);
-- Outbound delivery queue with retry state
CREATE TABLE federation_outbox (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
target_node_id TEXT NOT NULL,
logical_msg_id TEXT NOT NULL,
payload JSONB NOT NULL, -- verbatim S2S request body
attempt_count INT NOT NULL DEFAULT 0,
last_attempt TIMESTAMPTZ,
next_attempt TIMESTAMPTZ NOT NULL DEFAULT NOW(),
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'delivered', 'failed')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Users: federation columns
ALTER TABLE users
ADD COLUMN home_node_id UUID REFERENCES federation_nodes(id),
-- NULL = local user; non-NULL = shadow record for a remote user
ADD COLUMN federated_address TEXT UNIQUE;
-- "alice@node-a.hushnet.net"; populated for all users after migration
-- Messages: deduplication constraint
ALTER TABLE messages
ADD CONSTRAINT uniq_message_per_device UNIQUE (logical_msg_id, to_device_id);| Variable | Default | Description |
|---|---|---|
NODE_HOST |
node-unknown.hushnet.net |
This node's canonical identifier (used as node_id in S2S auth) |
NODE_API_URL |
https://{NODE_HOST}/api |
Base API URL announced to peers |
REGISTRY_URL |
https://registry.hushnet.net |
Central registry for peer node discovery |
REGISTER_TO_REGISTRY |
false |
Set to true to register at startup |
{
"error": "Error message description",
"code": "ERROR_CODE",
"status": 400
}| Status Code | Description |
|---|---|
200 OK |
Request successful |
201 Created |
Resource created successfully |
400 Bad Request |
Invalid request data |
401 Unauthorized |
Authentication failed |
403 Forbidden |
Access denied |
404 Not Found |
Resource not found |
409 Conflict |
Resource already exists |
422 Unprocessable Entity |
Validation failed |
500 Internal Server Error |
Server error |
Currently, there is no rate limiting implemented. For production deployments, consider implementing rate limiting at the application or infrastructure level.
- Authentication requests: 5 per minute per IP
- Message sending: 60 per minute per device
- WebSocket connections: 5 concurrent per user
- API requests: 100 per minute per device
- Always verify signatures on the client side before sending
- Handle timestamp synchronization - ensure client clock is accurate
- Implement exponential backoff for failed requests
- Cache device keys to reduce API calls
- Use WebSocket for real-time updates instead of polling
- Validate all cryptographic materials before use
- Implement proper error handling for all API calls