ExoVoIP Developer guide

Internal chat

Internal chat lets colleagues in the same company message each other in real time from the web control panel and the ExoVoIP app: direct and group conversations, online status, typing indicator, read receipts and file sharing. It is included in the phone system and needs no additional installation.

Messages are protected by end-to-end encryption: only you and the recipients can read them. Neither the company nor Exobyte can see texts, file names or contents.

Who can use it

Chat is tied to the extension, not to the account: everyone sends and receives messages as their own extension.

  • Web control panel: requires a user with an associated extension. Users without an extension (for example an administration-only account) do not see the chat.
  • ExoVoIP app: every device linked to an extension can chat as that extension, on smartphones, tablets and computers.

You can only message colleagues in your own company: chat never crosses company boundaries. The contact list is the company's extension list, with name, number and online status.

Direct conversations and groups

Direct conversation: pick a colleague from the extension list and start typing. There is only one direct conversation between two extensions: if it already exists, it picks up where it left off.

Group: give it a name (up to 80 characters) and choose the members, up to 200 per group. Whoever creates the group is its administrator and can rename it and add or remove members. Any member can leave the group at any time. Changes (new members, members leaving, a new name) appear in the conversation as system messages.

Each message can contain up to 4,000 characters. The author of a message can delete it: it disappears for everyone, together with any attached file.

A direct conversation can be hidden from the list; it reappears with the next message.

Online status

A coloured indicator next to each colleague shows whether they are reachable. The status combines chat and phone:

StatusMeaning
OnlineControl panel or app open and in use
AwayControl panel or app open but idle for a few minutes
On a callThe colleague is busy on a call
Do not disturbDo not disturb is on for the extension
OfflineNo control panel or app open right now

An offline colleague still receives messages: they find them the next time they open the chat and, if they have the app on a smartphone or tablet, they get a notification straight away.

While someone is typing, the other members see a "typing" indicator. Read receipts show how far each member has read: direct conversations show "Read", groups show how many people have read the message.

Files

You can send documents, images and other files in conversations, with an optional short message. Files are end-to-end encrypted too: they are encrypted on the device before sending and the phone system only stores the encrypted content, with no name or type.

RuleValue
Maximum size20 MB per file
Availability48 hours after sending, then the file is permanently deleted
PreviewPNG, JPEG, GIF and WebP images, generated on the recipient's device
Files not allowedExecutables and scripts (for example .exe, .msi, .bat, .js, .ps1, .sh, .apk, .dmg), blocked by the control panel and the app before sending

After 48 hours the message stays in the conversation marked File expired: the content can no longer be recovered. Save any file you need for longer on your device.

Download security

Files have no public address. They can only be downloaded from an authenticated control panel session or a linked device, and only by members of the conversation. When the browser or phone needs to open a file through a link (image preview, download, sharing), the phone system generates a single-use link valid for 60 seconds that only the requester can use. A link copied and forwarded to someone else does not work.

The downloaded file is encrypted: the control panel or the app decrypts it on the device, checks its integrity and only then shows it or saves it with its original name.

The administrator can turn off file sharing for the whole company (see Company settings).

Notifications and pop-ups

Web control panel: when a message arrives, a small messenger-style conversation window opens in the bottom-right corner, with a sound alert. You can reply directly from the window, minimise it or open the full conversation. The unread counter stays visible in the menu.

ExoVoIP app:

  • with the app open, the message appears in a pop-up on top, with a sound alert;
  • with the app closed or in the background, smartphones and tablets receive a notification with the sender's name ("New message from Mario Rossi", or "3 new messages"); the text never travels in the notification, which the phone system cannot read. On Android the app shows a preview when it can, decrypting it on the phone; tapping the notification opens the conversation;
  • on computers, the open app (even minimised) shows the pop-up and a system notification.

Chat notifications are separate from call notifications: to receive them, ExoVoIP notifications must be allowed in the phone settings.

No pop-up is shown for your own messages or for the conversation already open on screen: in that case messages are marked as read.

Muting a conversation

Each member can mute a direct conversation or a group: messages keep arriving and the unread counter updates, but without pop-ups, sounds or notifications. The setting is personal and not visible to other members.

Company settings

The company administrator manages chat from the control panel settings, Chat section:

SettingEffect
Chat enabledTurns chat on or off for every extension in the company
File sharingAllows or blocks sending files in conversations
Message retentionHow many days messages are kept: from 7 to 3,650, default 365 days

Messages older than the retention period are deleted automatically every day. Files always follow their own 48-hour rule, regardless of message retention.

End-to-end encryption

Every message and file is encrypted on the sender's device and decrypted only on the members' devices. The phone system only delivers and stores encrypted content: it knows who writes to whom and when, but not what.

Chat devices. Every browser you sign in to the control panel from and every app device has its own keys, created the first time the chat is opened and kept only on that device. A message is encrypted for all linked devices of the members, including your other devices. You can link up to 20 devices per extension.

SituationWhat happens
New device or new group memberSees messages from that moment on; earlier ones remain readable on the other devices
You sign out of the control panelThat browser is unlinked from the chat and stops receiving messages; it links again at the next sign-in
App device unlinkedIts chat keys are revoked immediately
Lost deviceUnlink it from Chat devices, or ask the administrator to unlink all devices of your extension
A colleague changes deviceThe conversation shows "Mario Rossi's devices have changed"

Safety code. In every direct conversation you can open the safety code (60 digits and a QR code) and compare it in person with your colleague: if it matches, nobody is in between.

What this means.

  • Message search runs on your device.
  • The company and Exobyte cannot read, recover or export conversations.
  • Encryption keys are renewed automatically every 30 days.
  • Group system messages (new members, departures, name changes) are not encrypted: the phone system already knows this information.

Privacy

  • Each conversation can be read only by its members, thanks to end-to-end encryption. Not even the company administrator or Exobyte can read it.
  • Anyone who leaves a group no longer receives new messages.
  • Encrypted messages are stored only to deliver them to recipients and for the period set by the company.
  • Traffic between control panel, app and phone system also travels over encrypted connections.

For developers

Chat features are available through the API, scopes chat:read and chat:write. Every request acts as an extension: the control panel session of a user with an associated extension, or the token of an ExoVoIP app device. Credentials without an associated extension receive 403. Company settings (PUT /v1/chat/settings) require the extensions:write scope.

RequestScopeSDK method
GET /v1/chat/directorychat:readchat.directory()
GET /v1/chat/conversationschat:readchat.listConversations() · py: chat.list_conversations()
POST /v1/chat/conversationschat:writechat.createConversation() · py: chat.create_conversation()
GET /v1/chat/conversations/:idchat:readchat.getConversation() · py: chat.get_conversation()
PATCH /v1/chat/conversations/:idchat:writechat.updateConversation() · py: chat.update_conversation()
DELETE /v1/chat/conversations/:idchat:writechat.leaveConversation() · py: chat.leave_conversation()
GET /v1/chat/conversations/:id/keyschat:readchat.conversationKeys() · py: chat.conversation_keys()
GET /v1/chat/conversations/:id/messageschat:readchat.listMessages() · py: chat.list_messages()
POST /v1/chat/conversations/:id/messageschat:writechat.sendMessage() · py: chat.send_message()
POST /v1/chat/conversations/:id/fileschat:writechat.sendFile() · py: chat.send_file()
DELETE /v1/chat/conversations/:id/messages/:messageIdchat:writechat.deleteMessage() · py: chat.delete_message()
POST /v1/chat/conversations/:id/readchat:writechat.markRead() · py: chat.mark_read()
POST /v1/chat/files/:id/ticketchat:readchat.fileTicket() · py: chat.file_ticket()
GET /v1/chat/files/:idchat:readchat.downloadFile() · py: chat.download_file()
POST /v1/chat/ws-ticketchat:readchat.wsTicket() · py: chat.ws_ticket()
POST /v1/chat/keyschat:writechat.registerKey() · py: chat.register_key()
GET /v1/chat/keyschat:readchat.listKeys() · py: chat.list_keys()
GET /v1/chat/keys/minechat:readchat.myKeys() · py: chat.my_keys()
POST /v1/chat/keys/:keyId/rotatechat:writechat.rotateKey() · py: chat.rotate_key()
DELETE /v1/chat/keys/:keyIdchat:writechat.revokeKey() · py: chat.revoke_key()
GET /v1/chat/extensions/:id/keysextensions:readchat.extensionKeys() · py: chat.extension_keys()
DELETE /v1/chat/extensions/:id/keysextensions:writechat.revokeExtensionKeys() · py: chat.revoke_extension_keys()
GET /v1/chat/settingschat:readchat.getSettings() · py: chat.get_settings()
PUT /v1/chat/settingsextensions:writechat.setSettings() · py: chat.set_settings()

End-to-end encryption and device keys

The full specification (algorithms, formats, test vectors) is in the document _Internal chat: end-to-end encryption_; the TypeScript reference library is @exopbx/chat-crypto. In short:

  • every device registers its public keys with POST /v1/chat/keys: identityKey (Ed25519), encryptionKey (X25519), createdAt, signature (Ed25519 signature of the bundle), platform and label. The phone system verifies the signature and returns key.keyId;
  • POST /v1/chat/keys/:keyId/rotate renews the encryption key with the same identity (the previous key becomes superseded); DELETE /v1/chat/keys/:keyId unlinks the device; GET /v1/chat/keys/mine lists your own devices;
  • GET /v1/chat/keys?extensionIds=12,15 returns the active keys of the extensions; GET /v1/chat/keys?keyIds=31,40 returns the given keys in any state (also replaced or unlinked), to verify the signature of past messages; GET /v1/chat/conversations/:id/keys the keys that must receive the next message;
  • administrators list and unlink the devices of an extension with GET / DELETE /v1/chat/extensions/:id/keys.

The device states its own key with the X-Exo-Chat-Key: <keyId> header on every chat request and on POST /v1/chat/ws-ticket: each message arrives with only that device's wrap. A revoked key, or another device's key, returns 403 with code CHAT_KEY_INVALID.

Messages and files

POST /v1/chat/conversations with { "type": "direct", "extensionId": 12 } returns the existing direct conversation or creates one; with { "type": "group", "name": "Support", "extensionIds": [12, 15] } it creates a group. PATCH accepts name, addExtensionIds, removeExtensionIds and muted.

Messages are read newest first, up to 100 per page (limit, default 50): before=<id> for earlier pages, after=<id> to fetch messages received after an interruption. Each message has kind (text, file, system), envelope (encrypted envelope with the requesting device's wrap), body (system messages only), attachment (id, encrypted size, expiresAt, expired) and clientId.

Sending means an envelope encrypted for exactly the members' active keys, sender included:

{
  "envelope": {
    "v": 1,
    "senderKeyId": 15,
    "ephemeral": "<base64url>",
    "nonce": "<base64url>",
    "ciphertext": "<base64url>",
    "signature": "<base64url>",
    "recipients": [{ "keyId": 15, "nonce": "<base64url>", "wrapped": "<base64url>" }]
  },
  "clientId": "c7f1b2e9a4d0"
}

If the recipient list does not match the active keys, the response is 409 with code CHAT_KEYS_CHANGED and details { "missing": [...], "extra": [...] }: the client reloads the keys and encrypts again. Limits: ciphertext up to 24 KB, at most 1,000 recipients.

The optional clientId field (8–64 characters A-Z a-z 0-9 _ -) is generated by the client and lets you retry a send without duplicating the message.

POST /v1/chat/conversations/:id/files is a multipart/form-data request with the envelope field (envelope JSON, containing the file name, type, fingerprint and key), the file field (the file already encrypted in blocks) and the optional clientId. The encrypted file may exceed 20 MB only by the per-block authentication bytes:

curl -s https://centralino.exovoip.it/api/v1/chat/conversations/42/files \
  -H "Authorization: Bearer $TOKEN" \
  -F "envelope=<envelope.json" \
  -F "file=@quote.pdf.enc;type=application/octet-stream" \
  -F "clientId=c7f1b2e9a4d0"

Files are always downloaded as application/octet-stream and must be decrypted by the client. To download from a context that cannot send headers, request a link with POST /v1/chat/files/:id/ticket: the response contains url (an absolute address) and expiresAt; the link is valid for 60 seconds, once, and only for whoever requested it. Files no longer available return 410.

Real time (WebSocket)

The real-time connection opens in two steps: POST /v1/chat/ws-ticket (with the X-Exo-Chat-Key header) returns a single-use ticket valid for 30 seconds, then you open wss://…/v1/chat/ws?ticket=<ticket>. Every WebSocket message is a JSON object with a type field. On connection you receive ready:

{ "type": "ready", "extensionId": 12 }

A new message (encrypted, with this device's wrap):

{
  "type": "message.new",
  "message": {
    "id": 9812,
    "conversationId": 42,
    "senderExtensionId": 15,
    "kind": "text",
    "body": null,
    "envelope": {
      "v": 1,
      "senderKeyId": 31,
      "ephemeral": "<base64url>",
      "nonce": "<base64url>",
      "ciphertext": "<base64url>",
      "signature": "<base64url>",
      "wrap": { "keyId": 27, "nonce": "<base64url>", "wrapped": "<base64url>" }
    },
    "attachment": null,
    "clientId": null,
    "createdAt": "2026-11-04T10:15:00.000Z",
    "deletedAt": null
  }
}
EventWhen
readyConnection established
message.newNew message in one of the extension's conversations (own included)
message.deletedMessage deleted by its sender
conversation.updatedNew conversation, name or member change, mute
conversation.removedThe extension left or was removed from a group
readA member has read up to messageId
typingA member is typing (typing: true) or stopped (false)
presenceAn extension's online status changed
attachment.expiredA file passed 48 hours and was deleted
keys.changedAn extension's devices changed: reload its keys
pongReply to ping

The client can send { "type": "typing", "conversationId": 42, "typing": true }, { "type": "presence", "presence": "away" } (or online) and { "type": "ping" }. If the device key is revoked the connection closes with code 4403.

const { ticket } = await fetch(`${base}/v1/chat/ws-ticket`, {
  method: 'POST',
  headers: { Authorization: `Bearer ${token}`, 'X-Exo-Chat-Key': String(keyId) },
}).then((r) => r.json());

const ws = new WebSocket(`${base.replace(/^http/, 'ws')}/v1/chat/ws?ticket=${ticket}`);
ws.onmessage = (e) => {
  const ev = JSON.parse(e.data);
  if (ev.type === 'message.new') console.log(ev.message.conversationId, ev.message.envelope?.wrap);
};

Every reconnection needs a new ticket. After reconnecting, resync conversations with GET /v1/chat/conversations and messages with after=<last received id>.