AeroNyx Chat Relay Client Integration
Integration reference for the AeroNyx Chat Relay: WebSocket authentication, sealed 1:1 and group messages, offline delivery, receipts, reactions, presence, encrypted attachments, push notifications and rate limits.
Build an integration prompt
Choose a client stack and a task, then copy the prompt into your coding assistant. It is generated locally; nothing is sent to an AI service.
Generated prompt
This is the integration reference for the AeroNyx Chat Relay: the WebSocket and HTTPS service at api.aeronyx.network that carries end-to-end encrypted 1:1 messages, group messages, receipts, reactions, presence and encrypted attachments between AeroNyx identities.
It is written for engineers building AeroNyx-compatible clients, bots and services, and for AI coding agents implementing them. Every frame, field and limit on this page reflects the production relay and the AeroNyx App as of October 2026.
The Chat Relay is the centralized delivery path. It coexists with the decentralized node path (onion routing and anonymous mailboxes, see Verified two-hop delivery); a client may use both. For a request/response HTTPS integration without a WebSocket, see Central Chat HTTPS API v1.
Trust model
The relay is blind to content. Clients encrypt and sign everything before it reaches the relay, and the relay routes, queues and rate-limits opaque ciphertext.
The relay never receives:
- message text, reaction emoji, edits or group payloads in plaintext
- chat keys, group keys, attachment keys or nonces
- attachment contents, file names, thumbnails, waveforms or transcripts
- private identity keys
The relay does observe delivery metadata, and integrators should treat it as visible to the operator:
- sender and receiver public keys, group IDs and message IDs
- timestamps, payload sizes and delivery, receipt and read state
- presence, foreground state and typing indicators (sent as plaintext frames)
- call signalling metadata (room name, call ID, video flag)
- attachment ciphertext size, declared media type and expiry
- the
contact_requestflag and IP-level connection metadata
Confidentiality of content comes from end-to-end encryption, not from access control on the relay. Design accordingly: an attacker who obtains a stored ciphertext must still be unable to read it.
Identities and keys
An AeroNyx chat identity is an Ed25519 key pair. The 32-byte public key, written as 64 lowercase hexadecimal characters, is the address. Generate keys on the device and never send the private key anywhere.
Two keys are derived from an identity pair:
- Chat key (1:1).
HKDF-SHA256(salt = empty, ikm = X25519(my_secret, peer_public), info = "AERONYX-P2P-KEY", length = 32), where both Ed25519 keys are converted to X25519 (SHA-512(seed)[0..32]clamped for the secret, Edwards-to-Montgomery for the public key). Both peers derive the same key. - Message signatures. Ed25519 with the identity key, over the exact byte strings defined on this page.
Always use lowercase hex for public keys in frames. The relay does not normalise case in every queue key.
Authentication
RelayAuth signature
WebSocket login and every authenticated HTTPS endpoint use the same signature:
digest = SHA256("AeroNyx-RelayAuth-v1" || pubkey[32] || timestamp as u64 little-endian)
signature = Ed25519(identity_key, digest)
"AeroNyx-RelayAuth-v1" is the 20 ASCII bytes without a terminator. timestamp is Unix seconds and must be within 300 seconds of server time.
The signature binds only the identity and the time. It does not bind the method, path, body or connection, and there is no nonce. Generate a fresh timestamp for every request, send it only over TLS, and never log it.
HTTPS header
Authorization: Relay <pubkey-hex>:<timestamp>:<signature-hex>
A failed check returns HTTP 401 with {"success": false, "error": "<reason>"}. Reasons include missing_auth_header, malformed_auth_header, invalid_timestamp, timestamp_expired, invalid_pubkey and invalid_signature.
WebSocket connection
Endpoint
wss://api.aeronyx.network/ws/relay/
Native clients connect without an Origin header. Browsers must connect from an allowed origin; any other origin, or an unknown Host, is closed with code 1008.
Login
The server accepts the socket, then expects an auth frame within 30 seconds:
{
"type": "auth",
"pubkey": "<64 hex>",
"timestamp": 1780000000,
"signature": "<128 hex>"
}
Success:
{ "type": "auth_ack", "session_id": "<uuid>", "server_ts": 1780000001 }
Use server_ts to estimate clock offset; message timestamps are checked against the same ±300 second window.
After auth_ack the server starts its heartbeat, subscribes the connection to its channels, and immediately replays the offline queue (see Offline delivery).
Failure sends {"type": "auth_error", "reason": "<reason>"} and closes the socket with code 4001. Reasons: missing_fields, timestamp_expired, invalid_pubkey, invalid_signature_length, invalid_signature_encoding, invalid_signature, internal_error. Login timeout sends reason timeout and closes with 4002.
Any other frame before login is answered with auth_error reason authentication_required; the socket stays open.
Heartbeat and foreground state
| Direction | Frame | Behaviour |
|---|---|---|
| server → client | {"type":"ping"} every 30 s | Reply {"type":"pong"}. |
| client → server | {"type":"ping"} | Server replies {"type":"pong"}. |
| client → server | {"type":"presence_state","foreground":true} | Marks this connection active. |
| client → server | {"type":"presence_state","foreground":false} | Marks it backgrounded, so the relay may send a push for new messages. |
The relay considers an identity online only while its connection sends ping, pong or presence_state with foreground: true at least every 90 seconds. The AeroNyx App pings every 15 seconds while in the foreground and treats more than three missed pongs as a dead connection.
Long-lived connections should reconnect at least once every 24 hours. Messages are never lost when a connection silently stops receiving live frames, because every message is also queued and replayed at login, but live delivery resumes only after reconnecting.
Frame rules
- Text frames only, one JSON object per frame. Binary frames are ignored.
- Maximum frame size is 1,048,576 characters. Larger frames are refused with
{"type":"error","reason":"message_too_large"}. Keeppayload_b64under about 800 KiB to leave room for the frame. - Malformed frames return
errorwith reasoninvalid_json,invalid_json_typeorunknown_type.
Generic error frame:
{ "type": "error", "reason": "<reason>", "retry_after": 0 }
Rate-limit errors also carry scope.
Close codes
| Code | Meaning |
|---|---|
1008 | Host or Origin not allowed. |
4000 | Server could not deliver its heartbeat. |
4001 | Login failed. |
4002 | Login timed out. |
Reconnect with exponential backoff and jitter. The AeroNyx App waits 2^(attempt-1) seconds, clamped to 1–60 seconds, multiplied by a random factor between 0.8 and 1.2.
Sending a 1:1 message
1. Build the sealed envelope
The payload of a 1:1 message is a signed, encrypted ChatEnvelope. Its exact construction, a Python reference implementation and a golden test vector are in Central Chat HTTPS API v1: Sealed envelope format. The same envelope is used on both APIs.
In summary: XChaCha20-Poly1305 under the chat key, an Ed25519 signature over a 121-byte transcript, and a fixed binary layout. content_type is 0 for every message, including messages with attachments.
The plaintext is the UTF-8 text for a plain message, or a JSON object for messages with attachments, replies, forwards or link previews:
{
"type": "aeronyx_message",
"text": "See the attached file",
"attachments": [ { "blob_id": "...", "file_key": "...", "media_type": "image/jpeg", "file_name": "photo.jpg", "file_size": 482113 } ],
"reply": { "msg_id": "...", "sender_pubkey": "...", "text": "..." },
"forwarded": true,
"forwarded_from_name": "...",
"forwarded_from_pubkey": "...",
"link_preview": { "url": "https://...", "title": "...", "description": "...", "site_name": "..." }
}
All fields other than type and text are optional. Receivers should render any plaintext that is not a JSON object with "type": "aeronyx_message" as plain text. The attachment object is defined in Encrypted attachments.
2. Sign the frame
Every message frame carries a second signature, payload_sig, which the relay verifies before accepting it:
payload_sig = Ed25519(identity_key,
SHA256(receiver_pubkey[32] || sender_pubkey[32] || discriminant as 1 byte
|| timestamp as u64 little-endian || envelope_bytes))
envelope_bytes is the decoded payload_b64. Use discriminant 11 for messages and edits and 12 for reactions. payload_sig is hex-encoded.
3. Send
{
"type": "relay_send",
"msg_id": "9e762ae0f5da43e6bec5eb8143f1f834",
"receiver_pubkey": "<64 hex>",
"discriminant": 11,
"payload_b64": "<base64 envelope>",
"timestamp": 1780000000,
"payload_sig": "<128 hex>"
}
| Field | Rules |
|---|---|
msg_id | 32 lowercase hex characters: the envelope's 16-byte message_id. The receiving App stores the message under this ID, so it must match the envelope. |
receiver_pubkey | The recipient's public key. |
discriminant | 11. |
timestamp | Unix seconds, within ±300 s of server time. |
suppress_push | Optional. true sends no push notification. |
contact_request | Optional. Marks a first message to someone who requires contact verification (see Contact verification). |
4. Acknowledgement
{ "type": "relay_delivered", "msg_id": "...", "delivered": true, "queued": true, "durable": true }
durable(andqueued) istrueonce the message is stored in the recipient's offline queue. Treatdurable: trueas "sent".deliveredmeans the recipient had an active foreground connection. It is not proof of receipt; use delivery receipts for that.
Wait up to 15 seconds for the acknowledgement before treating the attempt as failed.
Rejections and errors
{ "type": "send_rejected", "msg_id": "...", "reason": "verification_required" }
| Response | Reason | Action |
|---|---|---|
send_rejected | verification_required | The recipient accepts messages only from contacts. Do not retry. |
send_rejected | contact_request_rate_limited | Contact request limit reached for this recipient. Do not retry. |
error | missing_fields | A required field is absent or empty. |
error | invalid_timestamp, timestamp_expired | Fix the clock and re-stamp. |
error | invalid_payload_sig | payload_sig does not verify. |
error | rate_limited | Retry after retry_after seconds. |
Retries
Retry an unacknowledged message with the same msg_id and the same envelope bytes. Because the relay rejects frames older than 300 seconds, compute a fresh frame timestamp and payload_sig for each retry; the envelope keeps its original timestamp. The relay and receivers de-duplicate by msg_id.
HTTPS fallback
When the WebSocket is unavailable, the same message can be posted over HTTPS:
POST /api/relay/push/
Authorization: Relay <pubkey>:<timestamp>:<signature>
Content-Type: application/json
{
"receiver_pubkey": "<64 hex>",
"discriminant": 11,
"payload_b64": "<base64 envelope>",
"timestamp": 1780000000,
"payload_sig": "<128 hex>"
}
Success is HTTP 200 with {"success": true}. Contact verification failures return 400 with verification_required or contact_request_rate_limited, and rate limiting returns 429 with Retry-After. Messages sent this way are queued for the recipient but do not trigger a push notification, so resend over the WebSocket once it reconnects.
Receiving messages
Incoming messages arrive as relay_envelope:
{
"type": "relay_envelope",
"sender_pubkey": "<64 hex>",
"discriminant": 11,
"payload_b64": "<base64 envelope>",
"timestamp": 1780000000,
"msg_id": "9e762ae0f5da43e6bec5eb8143f1f834",
"from_offline": false
}
For discriminant: 11:
- De-duplicate by
msg_id. The same message can arrive live and again from the offline queue. - Parse the envelope and check that its
receiver_pubkeyis your identity. - For live frames (
from_offline: false), drop the message if the envelope timestamp is more than 300 seconds from your clock. - Verify the envelope signature against the envelope's
sender_pubkey. That key, not the frame'ssender_pubkey, is the authenticated sender. - Decrypt with the chat key derived from that sender. Very old App versions encrypted with the raw X25519 output; try it if the HKDF key fails.
- Store the message durably, then send a delivery receipt and, for
from_offline: true, an offline acknowledgement.
1:1 envelopes are delivered without payload_sig; the envelope signature is the authenticity check. Frames may also carry contact_request: true.
If a message is not addressed to you, fails verification or fails decryption, drop it without showing anything.
Offline delivery
Every message, edit, revoke, reaction, receipt and read receipt is written to the recipient's offline queue before live delivery. The queue is replayed automatically after each login and on request:
{ "type": "relay_pull" }
The relay replays all queued items as their normal frame types with from_offline: true, ordered by timestamp, followed by:
{ "type": "relay_pull_done", "count": 12, "has_more": false }
Delivery is at least once. Items stay in the queue until acknowledged:
{ "type": "relay_offline_ack", "msg_id": "9e762ae0f5da43e6bec5eb8143f1f834" }
Acknowledge reactions with "reaction_id" instead of "msg_id". Acknowledge only after the item is durably stored on the device. The relay replies with {"type":"relay_offline_ack","msg_id":"...","success":true}.
Queue limits:
| Limit | Value |
|---|---|
| Items per recipient | 1,000. When full, new items are rejected and the sender sees durable: false. |
| Retention | 72 hours after the most recent item was added. |
| Payload size | 1 MiB decoded, within the 1 MiB frame limit. |
The relay is a delivery buffer, not a message history. Keep history on the device.
Delivery and read receipts
Delivery receipt
Send after storing a message:
{ "type": "message_receipt", "receiver_pubkey": "<original sender>", "msg_id": "...", "timestamp": 1780000010 }
The relay replies message_receipt_ack and delivers {"type":"message_receipt","sender_pubkey":"...","msg_id":"...","timestamp":...} to the original sender.
Read receipt
{ "type": "message_read", "receiver_pubkey": "<original sender>", "msg_id": "<latest read message>", "timestamp": 1780000200, "enabled": true }
Read receipts are watermarks: the App marks the given message and every earlier outgoing message in the conversation as read. Send one only when the user has read receipts enabled.
The relay enforces a reciprocal rule on send, on live delivery and on replay. A read receipt is delivered only if both users are mutual contacts and both have read_receipts_enabled. Otherwise the sender receives:
{ "type": "message_read_ack", "msg_id": "...", "delivered": false, "suppressed": true, "reason": "receiver_read_receipts_disabled" }
| Reason | Meaning |
|---|---|
client_disabled | The frame carried enabled: false or read_receipts_enabled: false. |
not_mutual_contact | The users are not mutual contacts. |
reader_read_receipts_disabled | The reader has read receipts turned off. |
receiver_read_receipts_disabled | The original sender has read receipts turned off. |
invalid_pubkey | A public key is malformed. |
A delivered read receipt is acknowledged with {"type":"message_read_ack","msg_id":"...","delivered":true}.
Edits and revocations
Edit
An edit is a new sealed envelope (with its own random message_id) containing the full replacement content, sent against the original message ID:
{
"type": "message_edit",
"receiver_pubkey": "<64 hex>",
"msg_id": "<original msg_id>",
"target_msg_id": "<original msg_id>",
"payload_b64": "<base64 envelope>",
"payload_sig": "<128 hex>",
"timestamp": 1780000400
}
payload_sig uses the formula in Sign the frame with discriminant 11. An edit that removes all attachments sets "attachments_edited": true in its plaintext JSON. The relay replies message_edit_ack. Receivers verify and decrypt the envelope like a message and must apply an edit only if its sender wrote the original message.
Revoke
{
"type": "message_revoke",
"receiver_pubkey": "<64 hex>",
"sender_pubkey": "<64 hex>",
"msg_id": "<original msg_id>",
"timestamp": 1780000500,
"payload_sig": "<128 hex>"
}
The revoke signature is over raw bytes, without hashing:
payload_sig = Ed25519(identity_key,
"aeronyx-message-revoke-v1" || sender_pubkey[32] || receiver_pubkey[32]
|| UTF-8(msg_id) || timestamp as u64 little-endian)
The relay replies message_revoke_ack. The relay does not check authorship: receivers must verify the signature and apply a revoke only if its sender wrote the original message. To delete the attachments of a revoked message, call POST /api/relay/blob/{blob_id}/delete/.
Reactions
A 1:1 reaction is a sealed envelope whose message_id is the reaction ID, with content_type 2 and the plaintext {"emoji": "❤️", "op": "add"} (or "remove"):
{
"type": "message_reaction",
"receiver_pubkey": "<64 hex>",
"msg_id": "<message being reacted to>",
"reaction_id": "<32 hex>",
"payload_b64": "<base64 envelope>",
"payload_sig": "<128 hex>",
"timestamp": 1780000300
}
payload_siguses discriminant12.reaction_idis the idempotency key. A repeatedreaction_idwithin 72 hours is acknowledged with"duplicate": trueand not delivered again.- The relay replies
{"type":"message_reaction_ack","msg_id":"...","reaction_id":"...","delivered":...,"queued":...}. - Reactions are limited to 20 per sender and conversation within the rate window.
Group reactions are described under Groups.
Presence and typing
Presence and typing frames are plaintext metadata and are visible to the relay.
Presence
Subscribe to contacts:
{ "type": "presence_subscribe", "pubkeys": ["<64 hex>", "<64 hex>"] }
Up to 200 keys are considered per frame; malformed and duplicate keys are ignored. Subscriptions accumulate for the lifetime of the connection. Subscribe only to your own contacts.
{
"type": "presence_subscribe_ack",
"count": 2,
"updates": [
{ "pubkey": "<a>", "visible": true, "presence_visible": true, "last_seen_visible": true, "online": false, "last_seen_ts": 1780000000, "reason": "allowed" },
{ "pubkey": "<b>", "visible": false, "presence_visible": false, "last_seen_visible": false, "reason": "not_mutual_contact" }
],
"server_ts": 1780000001
}
Presence is visible only between mutual contacts, and only if the target has presence_enabled. Hidden entries (reason not_mutual_contact or presence_hidden) contain no online or last_seen_ts. last_seen_ts is present only when last_seen_visible is true; otherwise show a generic state such as "last seen recently".
Live changes arrive as:
{ "type": "presence_update", "pubkey": "<a>", "online": true, "presence_visible": true, "last_seen_visible": true, "last_seen_ts": 1780000100 }
Typing
{ "type": "typing", "receiver_pubkey": "<64 hex>", "is_typing": true }
{ "type": "group_typing", "group_id": "<uuid>", "is_typing": true }
Typing is forwarded only when the sender's presence would be visible to the recipient (mutual contacts and presence_enabled), or to other members of a group the sender belongs to. It is never stored, queued or pushed.
Profile and privacy settings
GET /api/relay/profile/
Authorization: Relay <pubkey>:<timestamp>:<signature>
{
"success": true,
"profile": {
"pubkey_hex": "<64 hex>",
"display_name": "AeroNyx User",
"bio": "",
"handle": "",
"avatar_url": "",
"status_text": "",
"privacy": { "presence_enabled": true, "last_seen_enabled": true, "read_receipts_enabled": true },
"updated_at": "2026-10-09T10:00:00Z"
}
}
An identity without a profile receives empty fields and all three privacy flags true.
PATCH /api/relay/profile/
Authorization: Relay <pubkey>:<timestamp>:<signature>
Content-Type: application/json
{ "display_name": "Alice", "privacy": { "presence_enabled": true, "last_seen_enabled": false, "read_receipts_enabled": false } }
| Field | Rules |
|---|---|
display_name | Up to 50 characters. |
bio | Up to 200 characters. |
avatar_url | https:// URL or empty. |
handle | a-z and 0-9, 5–24 characters. Handles are subject to membership rules and change cooldowns. |
privacy.* | Booleans. The three flags may also be sent at the top level. |
Errors include no_valid_fields, <flag>_invalid_boolean, handle_taken and handle_change_cooldown:<date>.
Keep the client consistent with these settings: do not send read receipts when they are off, and do not display a peer's read state while your own read receipts are off.
Contact verification
A user can require that strangers verify before messaging. When the recipient has turned this on and has not added the sender as a contact, relay_send is rejected with verification_required.
To start a conversation, send one message with "contact_request": true. Contact requests bypass the check and are limited to 3 per sender and recipient within a rolling 24-hour window; further requests are rejected with contact_request_rate_limited. The contact_request flag is delivered to the recipient so the App can present the message as a request.
Contact verification applies to relay_send and the HTTPS fallback.
Groups
Group messages
Group content is encrypted with a shared 32-byte group key using AES-256-GCM:
payload_b64 = base64(nonce[12] || AES-256-GCM(group_key, plaintext_json) || tag[16])
The plaintext JSON contains text, type (text, media, system or reaction), sender_pubkey, created_at, and optionally attachments, mentions, reply, forwarded, forwarded_from_name, forwarded_from_pubkey and link_preview.
{
"type": "group_send",
"msg_id": "<32 hex>",
"group_id": "<uuid>",
"payload_b64": "<base64>",
"timestamp": 1780000000,
"payload_sig": "<128 hex>",
"key_version": 3
}
payload_sig = Ed25519(identity_key,
SHA256(UTF-8(group_id) || sender_pubkey[32] || 0x19 || timestamp as u64 little-endian
|| decoded payload))
The relay checks the signature and that the sender is an active member, stores the message for every other active member, then delivers it live. Group payloads do not use the 1:1 envelope, and group envelopes are delivered with group_id, key_version and payload_sig so receivers can verify the sender before decrypting.
{
"type": "group_delivered",
"msg_id": "...",
"group_id": "...",
"accepted": true,
"accepted_count": 5,
"delivered_count": 2,
"queued_count": 5,
"failed_count": 0,
"member_count": 6
}
A sender that is not a member receives error with reason not_a_member.
Group edits, revokes and reactions
| Frame | Signature |
|---|---|
group_message_edit (group_id, msg_id, target_msg_id, payload_b64, payload_sig, key_version, timestamp) | Group formula above. |
group_message_reaction (group_id, msg_id, reaction_id, payload_b64, payload_sig, key_version, timestamp) | Group formula above. The payload is a group payload with type: "reaction". |
group_message_revoke (group_id, sender_pubkey, msg_id, timestamp, payload_sig) | Raw Ed25519 over `"aeronyx-group-message-revoke-v1" |
Each is acknowledged with the matching _ack frame carrying the delivery counts. Receivers apply edits and revokes only from the original author.
Group keys
Group keys are distributed by the group owner as per-member key bundles: base64(nonce[12] || AES-256-GCM(chat_key(owner, member), group_key) || tag[16]). The REST endpoints under /api/relay/groups/ create groups, manage members and invitations, upload key bundles, rotate keys (keys/rotate/) and fetch the caller's current bundle (keys/me/). Senders encrypt with the latest key version they hold; receivers that lack a key version should fetch keys/me/ and hold the message until the key arrives.
Encrypted attachments
Attachments are encrypted on the device, uploaded as opaque ciphertext, and referenced from inside the encrypted message.
Encrypt the file
For each file, generate a random 32-byte key and a random 12-byte nonce:
blob = nonce[12] || AES-256-GCM(file_key, file_bytes) || tag[16]
Upload blob. Put file_key and every descriptive field inside the encrypted message, never in an upload request.
Attachment object
| Key | Required | Meaning |
|---|---|---|
blob_id | yes | ID returned by the upload. |
file_key | yes | Base64 of the 32-byte file key. |
media_type | yes | MIME type of the plaintext file. |
file_name | yes | Display name. |
file_size | yes | Plaintext size in bytes. |
thumb_b64 | no | Base64 JPEG thumbnail, up to 64 KiB. |
duration_ms | no | Audio or video duration. |
waveform | no | Up to 96 numbers in [0, 1] for voice messages. |
sticker, sticker_pack, sticker_pose | no | Sticker identity. |
live | no | Live Photo motion part: {blob_id, file_key, media_type, file_size, duration_ms?, width?, height?}. |
Receivers ignore attachments that lack blob_id or file_key. Voice messages from the App are AAC-LC in an MP4 container (audio/mp4).
Upload: single request (up to 10 MiB)
POST /api/relay/blob/presign/
Authorization: Relay <pubkey>:<timestamp>:<signature>
Content-Type: application/json
{ "file_size": 482141, "media_type": "image/jpeg", "media_kind": "image", "ttl_days": 7 }
file_size is the ciphertext size. media_kind is one of voice, image, video, file, avatar, other. ttl_days is clamped to 1–30 (default 7).
{
"blob_id": "<uuid>",
"upload_url": "https://...",
"upload_method": "PUT",
"storage": "r2",
"expires_at": "2026-10-16T10:00:00Z",
"media_type": "image/jpeg",
"media_kind": "image",
"access_mode": "capability",
"ttl_days": 7,
"max_bytes": 10485760
}
Then:
PUTthe ciphertext toupload_urlwithin 15 minutes, with only aContent-Typeheader. Do not send theAuthorizationheader to storage.POST /api/relay/blob/{blob_id}/complete/with RelayAuth. The relay confirms the object and returns{blob_id, file_size, expires_at, storage}.
Upload: multipart (up to 100 MiB)
POST /api/relay/blob/multipart/create/
{ "total_size": 52428800, "media_type": "video/mp4", "media_kind": "video", "ttl_days": 7 }
{
"blob_id": "<uuid>",
"upload_id": "...",
"part_size": 8388608,
"total_parts": 7,
"part_urls": ["https://...", "..."],
"storage": "r2",
"expires_at": "...",
"max_bytes": 104857600
}
part_size defaults to 8 MiB and may be requested between 5 and 16 MiB. Part URLs are valid for 60 minutes. PUT each part to its URL and record the ETag response header, then:
POST /api/relay/blob/multipart/complete/
{ "blob_id": "<uuid>", "upload_id": "...", "parts": [ { "part_number": 1, "etag": "\"...\"" } ] }
The AeroNyx App uses the single-request upload up to 8 MiB of ciphertext and multipart above that.
Download
GET /api/relay/blob/{blob_id}/
The relay answers 302 with a Location on the content delivery network and X-AeroNyx-Blob-Size, X-AeroNyx-Blob-Media-Type and X-AeroNyx-Blob-Storage headers. Follow the redirect yourself without forwarding any Authorization header, then verify and decrypt the blob with its file_key. Cap the download at the expected size.
A blob_id is a bearer capability: anyone who holds it can fetch the ciphertext, which is why the key travels only inside the encrypted message. access_mode and expiry are enforced on the relay redirect. Do not rely on them for confidentiality.
Deleting attachments
POST /api/relay/blob/{blob_id}/delete/
Only the uploader may delete a blob. The response is {"blob_id": "...", "deleted": true}.
Attachment errors
Errors use {"success": false, "error": "<text>", "error_code": "<code>"}. Branch on error_code.
| HTTP | error_code | Meaning |
|---|---|---|
| 400 | blob_id_invalid | Malformed blob ID. |
| 400 | blob_missing_file_size, blob_missing_total_size, blob_missing_parts, blob_bad_parts, blob_bad_part_count | Invalid upload request. |
| 400 | blob_not_r2, blob_multipart_complete_failed | Completion failed; start a new upload. |
| 401 | auth_required | The blob requires RelayAuth. |
| 403 | blob_not_uploader, download_forbidden | Caller is not allowed. |
| 404 | blob_not_found, blob_not_uploaded | Unknown blob, or completion before the upload finished. |
| 410 | blob_expired | The blob expired. Ask the sender to resend. |
| 413 | blob_too_large | Over the limit; the response includes max_bytes and chunked_max_bytes. |
| 503 | blob_r2_unavailable | Storage temporarily unavailable; retry with backoff. |
Legacy upload endpoints
POST /api/relay/blob/ (multipart form, up to 10 MiB) and the resumable session API under /api/relay/blob/session/ (up to 100 MiB, chunks of 64 KiB to 4 MiB, sessions valid for 24 hours) remain available as a fallback. New clients should use the endpoints above.
Push notifications
The relay sends Apple Push Notification service (APNs) notifications for iOS and macOS. Android clients receive messages over the WebSocket only.
A push is sent for relay_send and group_send when the recipient has no active foreground connection, the sender did not set suppress_push, and the recipient has not muted the conversation. Edits, revokes, reactions and receipts do not push. Push payloads contain no ciphertext:
{
"aps": { "alert": { "title": "AeroNyx", "body": "New encrypted message" }, "sound": "default", "badge": 1, "content-available": 1 },
"kind": "p2p_message",
"sender_pubkey": "<64 hex>",
"message_id": "..."
}
kind is p2p_message (with sender_pubkey) or group_message (with group_id). Call notifications use missed_call and dedicated call payloads. On receiving a push, connect and run relay_pull.
| Endpoint | Body |
|---|---|
POST /api/relay/push/register/ | token (64 hex), platform (ios or macos), bundle_id, environment (production or sandbox), optional token_type (alert or voip) and provider (apns). |
POST /api/relay/push/unregister/ | token, optional platform, token_type, provider. |
POST /api/relay/push/mute/ | kind (p2p or group), target (public key or group ID), muted (boolean). |
All three require RelayAuth. Registering a token moves it to the calling identity.
Calls
Voice and video call signalling travels over the same WebSocket as plaintext metadata; media flows separately. The frames are call_invite, call_answer, call_reject, call_hangup and call_busy for 1:1 calls, and group_call_invite, group_call_invite_broadcast, group_call_answer and group_call_hangup_broadcast for groups, with meeting admission frames for hosted meetings.
The relay validates room_name against the participants: p2p_ followed by the first 16 hex characters of SHA256(lower_key + ":" + higher_key) for 1:1 calls, and grp_<first 8 characters of group_id>_<first 8 hex of SHA256(group_id)> for groups. Call signals for an offline recipient are held for 120 seconds.
Rate limits
| Scope | Limit | Response |
|---|---|---|
relay_send, group_send and HTTPS push, per identity | 50 per short window; 100,000 per day | WebSocket: error rate_limited with retry_after. HTTPS: 429 with Retry-After. |
group_send per sender and group | 10 per short window | error rate_limited, scope: "sender". |
group_send per group | 50 per short window | error rate_limited, scope: "group". |
| Reactions per sender and conversation | 20 per short window | error rate_limited, scope: "reaction". |
| Contact requests per sender and recipient | 3 per 24 hours | send_rejected contact_request_rate_limited. |
Back off for at least retry_after seconds. Do not resend in a tight loop: counters keep running while you retry.
Implementation checklist
- Generate and store an Ed25519 identity; use lowercase hex keys everywhere.
- Implement RelayAuth and the WebSocket login, heartbeat and
presence_state. - Implement the sealed envelope and verify against the golden vector in Central Chat HTTPS API v1.
- Send with
relay_send, treatdurable: trueas sent, re-stamptimestampandpayload_sigon retries. - Receive
relay_envelope: de-duplicate, verify, decrypt, store, then sendmessage_receiptandrelay_offline_ack. - Run
relay_pullafter login and on push wake-up; acknowledge only after durable storage. - Read profile privacy flags and follow them for presence and read receipts.
- Encrypt attachments on the device and upload through
presignor multipart; keepfile_keyinside the encrypted message. - Verify authorship before applying edits and revokes.
- Keep message search and history on the device. The relay has no content search and is not an archive.
Frequently asked questions
Can the AeroNyx Chat Relay read my messages?
No. Messages, edits, reactions, group messages and attachments are encrypted and signed on the sender's device before they reach the relay, and the keys never leave the devices of the people in the conversation. The relay stores and forwards ciphertext only.
What can the AeroNyx Chat Relay see?
The relay sees delivery metadata: sender and recipient public keys, group and message IDs, timestamps, payload sizes, delivery and read state, presence and typing signals, call signalling metadata, attachment sizes and connection IP addresses. It does not see message content, attachment content or keys. The full list is in the trust model at the top of this page.
Which encryption does AeroNyx chat use?
Each identity is an Ed25519 key pair. Two people derive a shared chat key with X25519 and HKDF-SHA256. 1:1 messages are encrypted with XChaCha20-Poly1305 and signed with Ed25519. Group messages and attachments are encrypted with AES-256-GCM. The exact byte formats and a test vector are published in the Central Chat HTTPS API v1 documentation.
How are photos, videos and files protected?
Each file is encrypted on the device with its own random AES-256-GCM key before upload. Storage receives only ciphertext. The file key travels inside the end-to-end encrypted message, so only the recipients can decrypt the file.
What happens if the recipient is offline?
The relay keeps encrypted messages in the recipient's offline queue for up to 72 hours and delivers them when the recipient reconnects. On iOS and macOS the recipient also receives a push notification that contains no message content.
Does AeroNyx keep my chat history?
No. The relay is a delivery buffer: items are removed after the recipient's device confirms it has stored them. Chat history and search live on your devices.
Can I build my own AeroNyx client or bot?
Yes. Any software that holds an Ed25519 identity and implements the formats on this page can exchange messages with AeroNyx App users. For a simpler request/response integration without a WebSocket, use the Central Chat HTTPS API v1.
Is the AeroNyx Chat Relay decentralized?
The Chat Relay is AeroNyx's centralized delivery service. AeroNyx also operates an open-source (AGPL-3.0) node network that can carry chat ciphertext over a network-diverse two-hop route. The two paths coexist: the relay provides fast, reliable delivery and offline queues, and the node path is an optional route that reduces what any single operator can observe.
Why is my message rejected with verification_required?
The recipient only accepts messages from contacts. Send a single first message with contact_request: true, which the recipient sees as a contact request. Up to three contact requests per recipient are allowed in any 24-hour window.
Verified two-hop delivery
For eligible authenticated ChatRelay traffic, the source may choose a network-diverse two-hop path and count delivery only after validating the expected terminal's signed receipt. Relay nodes route ciphertext and do not parse the E2E payload. See the full evidence model in Node Discovery and Verified Encrypted Relay Delivery.
<!-- verified-two-hop-delivery-v1:end -->