AeroNyx Chat Relay Client Integration

AeroNyxJune 19, 20268 min read

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:

  • type
  • msg_id
  • reaction_id
  • receiver_pubkey
  • group_id
  • timestamp
  • 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:

json
{
  "privacy": {
    "presence_enabled": false,
    "last_seen_enabled": false,
    "read_receipts_enabled": true
  }
}

When presence_enabled=false, peers receive only:

json
{
  "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:

json
{
  "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.

http
GET /api/relay/profile/
Authorization: Relay <pubkey>:<timestamp>:<signature>

Example response:

json
{
  "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:

http
PATCH /api/relay/profile/
Authorization: Relay <pubkey>:<timestamp>:<signature>
Content-Type: application/json
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:

json
{
  "type": "presence_subscribe",
  "pubkeys": ["contact-pubkey-a", "contact-pubkey-b"]
}

Visible response:

json
{
  "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:

json
{
  "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:

json
{
  "type": "message_read",
  "msg_id": "message-id",
  "receiver_pubkey": "original-sender-pubkey",
  "timestamp": 1780000200
}

Delivered ACK:

json
{
  "type": "message_read_ack",
  "msg_id": "message-id",
  "delivered": true
}

Suppressed ACK:

json
{
  "type": "message_read_ack",
  "msg_id": "message-id",
  "delivered": false,
  "suppressed": true,
  "reason": "receiver_read_receipts_disabled"
}

Other possible suppression reasons:

ReasonMeaning
client_disabledSender explicitly sent enabled=false or read_receipts_enabled=false.
not_mutual_contactUsers are not mutual contacts.
reader_read_receipts_disabledReader disabled read receipts.
receiver_read_receipts_disabledOriginal sender disabled read receipts, so they should not receive peer read state.

Offline pull returns read receipts as:

json
{
  "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:

json
{
  "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:

json
{ "emoji": "heart", "op": "add" }

Relay does not decrypt it.

Backend rules:

  • 1:1 reaction signature discriminant: 12
  • reaction_id is the idempotency key.
  • ACK: message_reaction_ack
  • Offline pull returns message_reaction with from_offline=true.
  • Offline ACK can use { "type": "relay_offline_ack", "reaction_id": "..." }.

Group reaction frame:

json
{
  "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:

json
{
  "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.

http
POST /api/relay/blob/
Authorization: Relay <pubkey>:<timestamp>:<signature>
Content-Type: multipart/form-data

Fields:

FieldRequiredNotes
fileyesEncrypted bytes only.
media_kindnovoice, image, video, file, avatar, other.
media_typenoClient-declared MIME type.
ttl_daysnoClamped to 1..30.
access_modenocapability default, or authenticated.
allowed_downloadersnoJSON pubkey array for authenticated mode.

Response:

json
{
  "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:

json
{
  "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

http
POST /api/relay/blob/session/
Authorization: Relay <pubkey>:<timestamp>:<signature>
Content-Type: application/json
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:

json
{
  "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

http
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

http
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

http
POST /api/relay/blob/session/{upload_id}/complete/
Authorization: Relay <pubkey>:<timestamp>:<signature>

Response:

json
{
  "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

http
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

http
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:

http
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:

HTTPerror_codeMeaning
400blob_id_invalidBlob id is not a UUID.
401auth_requiredAuthenticated download requires RelayAuth.
403download_forbiddenAuthenticated pubkey is not allowed.
404blob_not_foundBlob record does not exist.
410blob_expiredBlob expired and was lazily deleted.
413blob_too_largeSimple upload exceeds single-upload limit.
413blob_total_size_too_largeChunked total size exceeds max.
413chunk_too_largeSingle chunk exceeds max chunk size.
409upload_incompleteComplete 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_expired as recoverable: ask the sender to re-upload or resend.

AI agent integration guidance

AI coding agents implementing AeroNyx clients should follow this order:

  1. Implement RelayAuth signing.
  2. Read profile privacy flags.
  3. Enforce local presence/read receipt UI rules.
  4. Use presence_subscribe only for contacts.
  5. Use message_read only when local read receipts are enabled.
  6. Use reactions with reaction_id idempotency.
  7. Use encrypted media blob simple upload first.
  8. Add chunked upload/resume for large media.
  9. Send media references through encrypted relay_send payloads.

Do not add a server-side search endpoint for chat contents. Relay cannot search encrypted plaintext. Search belongs in the local client database.