AeroNyx Chat Relay Client Integration
Backend and client integration rules for AeroNyx blind relay messaging, privacy-gated presence, read receipts, reactions, and encrypted media blobs.
AeroNyx Chat Relay Client Integration
This page defines the backend relay frames and media APIs that clients should use for a Telegram-grade encrypted chat experience while preserving AeroNyx's blind-relay invariant.
The intended readers are frontend engineers, mobile app engineers, backend engineers, and AI coding agents implementing AeroNyx-compatible clients.
Non-negotiable privacy invariant
AeroNyx relay is blind to content.
Relay infrastructure must not parse, store, or derive meaning from:
- plaintext chat text
- emoji reaction contents
- voice or media plaintext
- media decrypt keys, nonces, waveforms, filenames, or transcripts
- Memory Chain plaintext
- packet payloads, DNS contents, destinations, domains, URLs, browsing history, wallet-level traffic, or private identity seeds
Allowed relay metadata is limited to routing and reliability fields:
typemsg_idreaction_idreceiver_pubkeygroup_idtimestamp- delivery/read state metadata
- encrypted blob size
- encrypted blob expiry
- optional encrypted blob access mode
- aggregate operational counters
The client encrypts content before relay transport. The relay only routes frames, queues offline metadata, and enforces abuse/privacy gates.
Presence privacy invariant
Presence is visible only to mutual contacts.
Before user A subscribes to user B's online state, the backend checks P2PContact in both directions:
- A must have B as a non-deleted contact.
- B must have A as a non-deleted contact.
If they are not mutual contacts, the backend does not return online or last_seen_ts. This prevents attackers from scanning public keys to discover who is online.
Users can also disable presence broadcasting through profile privacy settings:
{
"privacy": {
"presence_enabled": false,
"last_seen_enabled": false,
"read_receipts_enabled": true
}
}
When presence_enabled=false, peers receive only:
{
"type": "presence_subscribe_ack",
"updates": [
{
"pubkey": "peer-pubkey",
"visible": false,
"presence_visible": false,
"last_seen_visible": false,
"reason": "presence_hidden"
}
]
}
When users are not mutual contacts, peers receive:
{
"pubkey": "peer-pubkey",
"visible": false,
"presence_visible": false,
"last_seen_visible": false,
"reason": "not_mutual_contact"
}
No online or last_seen_ts is included in either hidden case.
Profile privacy API
Clients should read privacy flags from the profile API and keep local UI behavior consistent with backend enforcement.
GET /api/relay/profile/
Authorization: Relay <pubkey>:<timestamp>:<signature>
Example response:
{
"success": true,
"profile": {
"pubkey_hex": "user-pubkey",
"display_name": "AeroNyx User",
"avatar_url": "/media/avatars/...",
"privacy": {
"presence_enabled": true,
"last_seen_enabled": true,
"read_receipts_enabled": true
}
}
}
Update settings:
PATCH /api/relay/profile/
Authorization: Relay <pubkey>:<timestamp>:<signature>
Content-Type: application/json
{
"privacy": {
"presence_enabled": true,
"last_seen_enabled": false,
"read_receipts_enabled": false
}
}
Clients may also PATCH the three flags as top-level fields for backward-compatible integrations.
Presence frames
Subscribe to contact presence:
{
"type": "presence_subscribe",
"pubkeys": ["contact-pubkey-a", "contact-pubkey-b"]
}
Visible response:
{
"type": "presence_subscribe_ack",
"count": 1,
"updates": [
{
"pubkey": "contact-pubkey-a",
"visible": true,
"presence_visible": true,
"last_seen_visible": true,
"online": true,
"last_seen_ts": 1780000000,
"reason": "allowed"
}
],
"server_ts": 1780000001
}
Live update:
{
"type": "presence_update",
"pubkey": "contact-pubkey-a",
"online": false,
"presence_visible": true,
"last_seen_visible": true,
"last_seen_ts": 1780000100
}
If last_seen_enabled=false, clients may receive online without last_seen_ts. The UI should show a generic state such as "recently" or hide exact last-seen time.
Read receipt privacy invariant
Read receipts follow a reciprocal rule:
- If a user disables read receipts, the client must not send that user's
message_read. - If a user disables read receipts, the client must not display peer read receipts.
- The backend also enforces this rule. If either side disables read receipts, relay suppresses the frame.
- Read receipts are metadata-only; they never include message plaintext.
Send read receipt:
{
"type": "message_read",
"msg_id": "message-id",
"receiver_pubkey": "original-sender-pubkey",
"timestamp": 1780000200
}
Delivered ACK:
{
"type": "message_read_ack",
"msg_id": "message-id",
"delivered": true
}
Suppressed ACK:
{
"type": "message_read_ack",
"msg_id": "message-id",
"delivered": false,
"suppressed": true,
"reason": "receiver_read_receipts_disabled"
}
Other possible suppression reasons:
| Reason | Meaning |
|---|---|
client_disabled | Sender explicitly sent enabled=false or read_receipts_enabled=false. |
not_mutual_contact | Users are not mutual contacts. |
reader_read_receipts_disabled | Reader disabled read receipts. |
receiver_read_receipts_disabled | Original sender disabled read receipts, so they should not receive peer read state. |
Offline pull returns read receipts as:
{
"type": "message_read",
"sender_pubkey": "reader-pubkey",
"msg_id": "message-id",
"timestamp": 1780000200,
"from_offline": true
}
The backend applies the same privacy gate during offline pull.
Emoji reactions
1:1 send frame:
{
"type": "message_reaction",
"msg_id": "message-being-reacted-to",
"receiver_pubkey": "peer-pubkey",
"reaction_id": "unique-reaction-event-id",
"timestamp": 1780000300,
"payload_b64": "e2e-ciphertext",
"payload_sig": "ed25519-signature"
}
The encrypted payload may contain client-owned state such as:
{ "emoji": "heart", "op": "add" }
Relay does not decrypt it.
Backend rules:
- 1:1 reaction signature discriminant:
12 reaction_idis the idempotency key.- ACK:
message_reaction_ack - Offline pull returns
message_reactionwithfrom_offline=true. - Offline ACK can use
{ "type": "relay_offline_ack", "reaction_id": "..." }.
Group reaction frame:
{
"type": "group_message_reaction",
"group_id": "group-uuid",
"msg_id": "message-being-reacted-to",
"reaction_id": "unique-reaction-event-id",
"key_version": 1,
"timestamp": 1780000300,
"payload_b64": "group-e2e-ciphertext",
"payload_sig": "ed25519-signature"
}
Encrypted media blob model
Voice messages, large images, and files should use the encrypted media blob channel.
The server stores encrypted bytes only. The decrypt key, nonce, media duration, waveform, display filename, and preview metadata must be inside the E2E relay message payload, not inside the blob API.
Recommended relay message payload after upload:
{
"kind": "voice",
"blob_id": "blob-uuid",
"key_b64": "inside-e2e-envelope",
"nonce_b64": "inside-e2e-envelope",
"duration_ms": 43000,
"waveform": [0, 3, 8, 6, 2],
"media_type": "audio/ogg; codecs=opus",
"file_size": 7340032
}
The whole object above must be encrypted into payload_b64 before sending through relay_send.
Simple encrypted blob upload
Use this for short voice messages and small images.
POST /api/relay/blob/
Authorization: Relay <pubkey>:<timestamp>:<signature>
Content-Type: multipart/form-data
Fields:
| Field | Required | Notes |
|---|---|---|
file | yes | Encrypted bytes only. |
media_kind | no | voice, image, video, file, avatar, other. |
media_type | no | Client-declared MIME type. |
ttl_days | no | Clamped to 1..30. |
access_mode | no | capability default, or authenticated. |
allowed_downloaders | no | JSON pubkey array for authenticated mode. |
Response:
{
"success": true,
"blob_id": "blob-uuid",
"expires_at": "2026-06-26T10:00:00Z",
"file_size": 1048576,
"media_type": "audio/ogg",
"media_kind": "voice",
"access_mode": "capability",
"ttl_days": 7,
"max_bytes": 10485760,
"chunked_max_bytes": 104857600
}
If the file is too large, the backend returns HTTP 413:
{
"success": false,
"error": "file_too_large: max 10MB",
"error_code": "blob_too_large",
"max_bytes": 10485760,
"chunked_max_bytes": 104857600
}
Resumable encrypted blob upload
Use chunked upload for large voice messages, large images, videos, and files.
1. Create upload session
POST /api/relay/blob/session/
Authorization: Relay <pubkey>:<timestamp>:<signature>
Content-Type: application/json
{
"total_size": 7340032,
"chunk_size": 1048576,
"media_type": "audio/ogg",
"media_kind": "voice",
"ttl_days": 7,
"access_mode": "authenticated",
"allowed_downloaders": ["receiver-pubkey"]
}
Response:
{
"success": true,
"upload_id": "upload-uuid",
"status": "pending",
"total_size": 7340032,
"chunk_size": 1048576,
"total_chunks": 7,
"received_chunks": [],
"missing_chunks": [0, 1, 2, 3, 4, 5, 6],
"max_bytes": 104857600,
"max_chunk_bytes": 4194304
}
2. Upload chunks
PUT /api/relay/blob/session/{upload_id}/chunk/{chunk_index}/
Authorization: Relay <pubkey>:<timestamp>:<signature>
Content-Type: application/octet-stream
Body is encrypted chunk bytes.
Clients may also use multipart field file. Re-uploading the same chunk_index overwrites the previous chunk, so retry is idempotent.
3. Resume after network interruption
GET /api/relay/blob/session/{upload_id}/
Authorization: Relay <pubkey>:<timestamp>:<signature>
The response includes missing_chunks. The client should upload only the missing indexes.
4. Complete upload
POST /api/relay/blob/session/{upload_id}/complete/
Authorization: Relay <pubkey>:<timestamp>:<signature>
Response:
{
"success": true,
"upload_id": "upload-uuid",
"status": "completed",
"blob_id": "blob-uuid",
"download_url": "/api/relay/blob/blob-uuid/",
"file_size": 7340032,
"media_kind": "voice",
"access_mode": "authenticated",
"expires_at": "2026-06-26T10:00:00Z"
}
5. Cancel upload
DELETE /api/relay/blob/session/{upload_id}/
Authorization: Relay <pubkey>:<timestamp>:<signature>
The backend deletes temporary chunks and marks the session cancelled.
Download encrypted blob
GET /api/relay/blob/{blob_id}/
For access_mode=capability, no auth is required. The unguessable blob_id is the capability token.
For access_mode=authenticated, the client must include RelayAuth:
GET /api/relay/blob/{blob_id}/
Authorization: Relay <pubkey>:<timestamp>:<signature>
The authenticated pubkey must be the uploader or a member of allowed_downloaders.
Download errors:
| HTTP | error_code | Meaning |
|---|---|---|
| 400 | blob_id_invalid | Blob id is not a UUID. |
| 401 | auth_required | Authenticated download requires RelayAuth. |
| 403 | download_forbidden | Authenticated pubkey is not allowed. |
| 404 | blob_not_found | Blob record does not exist. |
| 410 | blob_expired | Blob expired and was lazily deleted. |
| 413 | blob_too_large | Simple upload exceeds single-upload limit. |
| 413 | blob_total_size_too_large | Chunked total size exceeds max. |
| 413 | chunk_too_large | Single chunk exceeds max chunk size. |
| 409 | upload_incomplete | Complete was called before all chunks arrived. |
Client UX guidance
- Use simple upload for short voice messages and small images.
- Use chunked upload when encrypted bytes exceed
max_bytes. - Keep a local upload record with
upload_id,chunk_size, and completed indexes. - On app restart, call session status and upload only
missing_chunks. - For privacy-sensitive transfers, use
access_mode=authenticated. - Never send media keys, nonce, waveform, filename, or transcript to blob APIs. Put them inside the encrypted relay payload.
- Treat
410 blob_expiredas recoverable: ask the sender to re-upload or resend.
AI agent integration guidance
AI coding agents implementing AeroNyx clients should follow this order:
- Implement RelayAuth signing.
- Read profile privacy flags.
- Enforce local presence/read receipt UI rules.
- Use
presence_subscribeonly for contacts. - Use
message_readonly when local read receipts are enabled. - Use reactions with
reaction_ididempotency. - Use encrypted media blob simple upload first.
- Add chunked upload/resume for large media.
- Send media references through encrypted
relay_sendpayloads.
Do not add a server-side search endpoint for chat contents. Relay cannot search encrypted plaintext. Search belongs in the local client database.