Messenger API

Endpoints for the end-to-end encrypted PQC messenger.

All messages are encrypted client-side using ML-KEM-768 key encapsulation and AES-GCM. The server only stores encrypted blobs — it cannot read message contents.

v2 API (March 2026): All write operations now use /api/v2/ endpoints that require ML-DSA-65 signed requests with timestamp validation and nonce-based anti-replay protection. Legacy unsigned endpoints return HTTP 410 (Gone) in production; since node release 1.6.3 (2026-10-05) so do the legacy read routes GET /api/messenger/conversations and GET /api/messenger/messages — use the signed POST …/conversations/list and POST …/messages/list below. Messenger data is node-hosted, not on-chain.

Signed Request Format

All v2 write endpoints accept a signed request body:

{
  "payload": {
    "action": "register_wallet",
    "from": "ml-dsa65-public-key-hex",
    "timestamp": 1710100000000,
    "nonce": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
    "...": "operation-specific fields"
  },
  "signature": "ml-dsa65-signature-hex",
  "public_key": "ml-dsa65-public-key-hex"
}

The server verifies: (1) the ML-DSA-65 signature, (2) the timestamp is within a 5-minute window, (3) the from field matches the signing key, and (4) the nonce has not been used before.

Register Messenger Wallet

POST /api/v2/messenger/wallets/register
Content-Type: application/json

Register your wallet's encryption public key so others can send you encrypted messages and mail. This is required before receiving mail from other apps. Requires a signed request.

Ownership (node release 1.6.3). The entry is owned by the signing key (from / public_key). Re-registering replaces an entry only if the request is signed by that key — id, displayName and encryptionPublicKey can be updated by the owner, never by another wallet. If encryptionPublicKey is already registered to another wallet the request fails with This encryption key is already registered to another wallet. Nodes before 1.6.3 do not enforce this.

Request Body (signed)

{
  "payload": {
    "id": "wallet-uuid-or-public-key",
    "displayName": "Alice",
    "signingPublicKey": "ml-dsa65-signing-public-key-hex",
    "encryptionPublicKey": "ml-kem768-encryption-public-key-hex",
    "from": "ml-dsa65-signing-public-key-hex",
    "timestamp": 1710100000000,
    "nonce": "random-hex-nonce"
  },
  "signature": "ml-dsa65-signature-hex",
  "public_key": "ml-dsa65-signing-public-key-hex"
}
FieldTypeDescription
idstringUnique wallet identifier
displayNamestringHuman-readable name (unique, case-insensitive)
signingPublicKeystringML-DSA-65 public key (hex)
encryptionPublicKeystringML-KEM-768 public key (hex) — needed for E2E encryption

Response

{
  "success": true
}

List Messenger Wallets

GET /api/messenger/wallets

Returns all registered messenger wallets.

Response

{
  "success": true,
  "wallets": [
    {
      "id": "wallet-uuid",
      "display_name": "Alice",
      "signing_public_key": "abc123...",
      "encryption_public_key": "def456..."
    }
  ]
}

Send Message

POST /api/v2/messenger/messages
Content-Type: application/json

Requires a signed request. The sender is authenticated via the ML-DSA-65 signature.

Request Body (signed)

The payload includes the message fields plus from, timestamp, and nonce:

{
  "payload": {
    "conversationId": "conv-uuid",
    "senderPublicKey": "sender-pub-hex",
    "recipientPublicKey": "recipient-pub-hex",
    "senderEncrypted": "base64-encrypted-for-sender",
    "recipientEncrypted": "base64-encrypted-for-recipient",
    "senderDisplayName": "Alice",
    "recipientDisplayName": "Bob",
    "selfDestruct": false,
    "destructSeconds": 0,
    "from": "sender-pub-hex",
    "timestamp": 1710100000000,
    "nonce": "random-hex-nonce"
  },
  "signature": "ml-dsa65-signature-hex",
  "public_key": "sender-pub-hex"
}

Messages are encrypted twice — once for the sender (so they can read their sent messages) and once for the recipient. The server stores both ciphertexts.

Self-Destruct Messages

Set selfDestruct: true and destructSeconds to a value. After the recipient reads the message, it is marked as read and hidden from the UI.

Response

{
  "success": true,
  "messageId": "msg-uuid"
}

Get Messages

POST /api/v2/messenger/messages/list
Content-Type: application/json

Requires a signed request. The server verifies the caller is a participant in the conversation.

Request Body (signed)

{
  "payload": {
    "conversationId": "conv-uuid",
    "from": "your-pub-hex",
    "timestamp": 1710100000000,
    "nonce": "random-hex-nonce"
  },
  "signature": "ml-dsa65-signature-hex",
  "public_key": "your-pub-hex"
}

Response

{
  "messages": [
    {
      "id": "msg-uuid",
      "conversationId": "conv-uuid",
      "senderPublicKey": "abc...",
      "senderDisplayName": "Alice",
      "encrypted": "base64-ciphertext",
      "timestamp": 1706745600000,
      "selfDestruct": false,
      "mediaType": null
    }
  ]
}

Mark Message as Read

POST /api/v2/messenger/messages/read
Content-Type: application/json

Used for self-destruct messages. Requires a signed request with messageId and conversationId in the payload.

Create Conversation

POST /api/v2/messenger/conversations
Content-Type: application/json

Signed request with participantIds (wallet ids or public keys; the creator must be included), optional name, optional isGroup.

1:1 conversations have a deterministic id and creation is an upsert. For two distinct participants (and isGroup not set) the id is "dm_" + hex(sha256(sorted([idA, idB]).join("\n"))) over the two canonical wallet ids, so Alice→Bob and Bob→Alice, or the same person addressed by wallet id, signing key or encryption key, all resolve to the same thread. If it already exists it is returned unchanged with "existing": true (and, if the caller had trashed it, it comes back to their inbox). Groups keep random ids and are never deduplicated.

Response

{ "success": true, "existing": false, "conversationId": "dm_9f2c…", "conversation": { "id": "dm_9f2c…", "participant_ids": ["…"], "is_group": false, "created_at": "…" } }

Delete Message

POST /api/v2/messenger/messages/delete
Content-Type: application/json

Signed request with messageId and conversationId. Only the sender may delete. The delete is soft: the message disappears from listings for everyone but stays recoverable by the sender for 30 days via POST /api/v2/messenger/messages/restore (same payload). After 30 days the sweep removes it permanently.

Delete Conversation

POST /api/v2/messenger/conversations/delete
Content-Type: application/json

Signed request with conversationId, optional "purge": true. The caller must be a participant.

Deleting affects only the caller's view (this changed from the previous global hard delete): the conversation moves to the caller's trash and disappears from their …/list; every other participant keeps the thread and all its messages. It stays recoverable for 30 days; purge: true waives the caller's retention window. The record and its messages are removed permanently only once every participant has deleted it and each share is purged or past retention.

Response

{ "success": true, "conversationId": "dm_9f2c…", "softDeleted": true, "purge": false, "recoverableUntil": "2026-10-25T14:00:00Z" }

Restore Conversation

POST /api/v2/messenger/conversations/restore
Content-Type: application/json

Signed request with conversationId. Clears the caller's delete; the thread and its messages reappear in the caller's inbox. Fails if the conversation is not in the caller's trash (or was purged).

Restore Message

POST /api/v2/messenger/messages/restore
Content-Type: application/json

Signed request with conversationId and messageId; sender only; within the 30-day window.


Get Conversations

POST /api/v2/messenger/conversations/list
Content-Type: application/json

Signed request. Optional "folder": "inbox" (default — not deleted by the caller), "trash" (deleted by the caller, still recoverable; use it for a "Recently deleted" view), or "all".

Response

{
  "success": true,
  "folder": "inbox",
  "conversations": [
    {
      "id": "dm_9f2c…",
      "participant_ids": ["…", "…"],
      "is_group": false,
      "name": null,
      "created_at": "2026-09-25T12:00:00Z",
      "deleted_by": {},
      "last_message_at": "2026-09-25T12:30:00Z",
      "last_sender_id": "…",
      "last_message_preview": "[Encrypted message]",
      "unread_count": 2
    }
  ]
}

deleted_by maps the canonical wallet id of each participant who trashed the thread to the time they did; a conversation in your trash folder has your id in it.


Real-time Events (WebSocket)

New messages are pushed over wss://api.rougechain.io/api/ws so clients don't need to poll. Message events are private. The node sends them only to sockets that proved they own a participant's signing key.

1. Authenticate by sending a signed request, the same format as the v2 REST endpoints, with action set to messenger_ws_subscribe:

{ "auth": { "payload": { "action": "messenger_ws_subscribe", "from": "<signing pubkey>", "timestamp": 1790000000000, "nonce": "<random hex>" },
            "signature": "<ML-DSA-65 signature over the sorted-key JSON payload>",
            "public_key": "<signing pubkey>" } }

The node replies {"type":"subscribed","topics":["messenger"]} or {"type":"auth_error","error":"..."}. Nonces are single-use and timestamps must be within 5 minutes, so re-sign on every reconnect. One socket can authenticate up to 8 identities.

2. Receive an event for every message stored in a conversation you belong to, including your own sends:

{ "type": "new_message", "conversation_id": "dm_…", "message_id": "…", "created_at": "2026-09-25T18:40:00Z",
  "sender_wallet_id": "<sender signing pubkey>", "participant_ids": ["<signing pubkey>", "…"] }

Events never contain message content. Refetch the conversation with POST /api/v2/messenger/messages/list. Unauthenticated sockets never receive message events, and inbox:* topics cannot be joined with a plain subscribe. Public events (new_block, new_transaction, stats) are unchanged. Keep a slow fallback poll for when the socket is down.

Media Messages

The messenger supports image and video attachments. Media is encrypted and sent as base64 within the message payload.

LimitValue
Max upload size50 MB (before compression)
Compressed target~1.5 MB
Image formatConverted to WebP client-side
Video formatConverted to WebM (VP9) client-side

The client automatically compresses large media before encryption and sending.


Encryption Flow

Sender                                    Recipient
  │                                          │
  │ 1. Generate shared secret via ML-KEM-768 │
  │ 2. Derive AES-256 key via HKDF           │
  │ 3. Encrypt message with AES-GCM          │
  │ 4. Encrypt for self (sender copy)        │
  │ 5. Encrypt for recipient                 │
  │                                          │
  │── POST /messages (both ciphertexts) ────►│
  │                                          │
  │                    6. Decapsulate shared secret
  │                    7. Derive same AES key
  │                    8. Decrypt message

Wallet Blocking

Users can block wallets client-side. Blocked wallets are filtered from conversations and contacts. The block list is stored in browser localStorage under pqc_blocked_wallets.

This is a client-side feature — the server is not involved. Blocked users can still send messages, but they won't appear in the blocking user's UI.


SDK Usage

The @rougechain/sdk provides a high-level API for messenger operations. All write operations now require a wallet parameter for ML-DSA-65 request signing:

import { RougeChain, Wallet } from "@rougechain/sdk";

const rc = new RougeChain("https://testnet.rougechain.io/api");
const wallet = Wallet.generate();

// Register wallet (signed request, required for receiving messages and mail)
await rc.messenger.registerWallet(wallet, {
  id: wallet.publicKey,
  displayName: "Alice",
  signingPublicKey: wallet.publicKey,
  encryptionPublicKey: encPubKey,
});

// Conversations (signed requests)
const convos = await rc.messenger.getConversations(wallet);
await rc.messenger.createConversation(wallet, [wallet.publicKey, recipientPubKey]);

// Messages (signed requests)
const msgs = await rc.messenger.getMessages(wallet, conversationId);
await rc.messenger.sendMessage(wallet, conversationId, encryptedContent, {
  selfDestruct: true,
  destructAfterSeconds: 30,
});
await rc.messenger.markRead(wallet, messageId);
await rc.messenger.deleteMessage(wallet, messageId);

TypeScript types: MessengerWallet, MessengerConversation, MessengerMessage