@droponair/sdk-js
v0.23.0
Published
DropOnAir SDK for end-to-end encrypted messaging
Downloads
2,760
Maintainers
Readme
DropOnAir JS SDK
End-to-end encrypted messaging, group chat, voice/video calls, and broadcast channels for any app.
DropOnAir operates as a blind encrypted relay - your users' encryption keys and message content never leave their devices.
Getting Started
- Create a free account at panel.droponair.com
- Create an app in the dashboard to get your App ID and Public API Key
- Install the SDK:
Installation
npm install @droponair/sdk-jsQuick Start
import { initialize } from '@droponair/sdk-js';
const client = await initialize({
appId: 'your-app-id', // From the DropOnAir dashboard
publicApiKey: 'your-public-api-key', // From the DropOnAir dashboard
getUserJwt: async () => {
const res = await fetch('/api/auth/me', { credentials: 'include' });
const { jwt } = await res.json();
return jwt;
},
keyDirectoryEndpoint: '/api/messaging/keys',
tokenExchangeEndpoint: '/api/messaging/token-exchange',
});
// Listen for incoming messages (already decrypted)
client.onMessage(({ fromUserId, plaintext, timestamp }) => {
console.log(`${fromUserId}: ${plaintext}`);
});
// Send an E2EE message
await client.sendMessage('recipient-user-id', 'Hello!');Features
- E2EE Messaging - X25519 key agreement, AES-256-GCM encryption, multi-device support
- Message Edit & Delete - Edit sent messages or tombstone them for everyone or only your own devices
- Cleartext Messaging - Lightweight messages without E2EE overhead
- Broadcast Channels - Pub/sub for announcements and notifications
- Group Messaging - Server-managed groups with member roles
- Voice & Video Calls - 1-to-1 WebRTC call signaling with TURN support
- Group Calls - Mesh WebRTC group calls with per-participant signaling
- Offline Delivery - Messages queued and delivered when recipients reconnect
- Multi-Device - Per-device encryption with automatic self-sync
API Reference
Initialization
const client = await initialize(options);| Option | Type | Required | Description |
|--------|------|----------|-------------|
| appId | string | Yes | Your DropOnAir app ID |
| publicApiKey | string | Yes | Your DropOnAir public API key |
| getUserJwt | () => Promise<string> | Yes | Returns a fresh user JWT from your backend |
| keyDirectoryEndpoint | string | No | Public key directory URL (default: /api/messaging/keys) |
| tokenExchangeEndpoint | string | No | Token exchange URL (default: /api/messaging/token-exchange) |
| messagingWsUrl | string | No | WebSocket URL (default: wss://sdk.droponair.com/ws/messages) |
| messagingHttpUrl | string | No | HTTP URL (default: https://sdk.droponair.com) |
| autoConnect | boolean | No | Connect on init (default: true) |
| storage | KeyStorageAdapter | No | Custom key storage (default: IndexedDB) |
| debug | boolean | No | Enable debug logging (default: false) |
Connection
| Method | Returns | Description |
|--------|---------|-------------|
| connect() | Promise<void> | Connect to DropOnAir (generates keys, exchanges JWT, opens WebSocket) |
| disconnect() | void | Disconnect and stop auto-reconnect |
E2EE Messaging
| Method | Returns | Description |
|--------|---------|-------------|
| sendMessage(toUserId, plaintext) | Promise<{ messageId }> | Send an encrypted message |
| editMessage(originalMessageId, toUserId, newText) | Promise<{ editId }> | Edit a previously sent encrypted message |
| deleteMessage(originalMessageId, toUserId, scope) | Promise<{ deleteId }> | Delete a previously sent 1:1 message. scope is FOR_EVERYONE or FOR_ME |
| sendCleartextMessage(toUserId, plaintext) | Promise<{ messageId }> | Send a cleartext message (no E2EE) |
| editCleartextMessage(originalMessageId, toUserId, newText) | Promise<{ editId }> | Edit a previously sent cleartext message |
| onMessage(callback) | () => void | Listen for incoming messages. Returns unsubscribe function |
| onMessageEdit(callback) | () => void | Listen for inbound edits to 1:1 messages |
| onMessageDelete(callback) | () => void | Listen for inbound delete tombstones for 1:1 messages |
| onEvent(callback) | () => void | Listen for system events (CONNECTED, DELIVERED, ERROR, etc.) |
| ack(messageId) | Promise<void> | Manually acknowledge a message |
| registerPushToken({ platform, token, voipToken? }) | Promise<void> | Register this device for push notifications. platform is 'APNS', 'FCM', or 'WEB_PUSH'. |
| unregisterPushToken({ platform }) | Promise<void> | Unregister this device for push notifications (e.g. on logout). |
| listMyDevices() | Promise<DeviceInfo[]> | List the current user's registered devices. |
| revokeMyDevice(deviceId) | Promise<DeviceInfo> | Revoke one of the current user's devices. Permanent. |
| markRead(messageId, conversationId?) | void | Mark a message as read; relays a receipt to the user's other devices. |
| onReadReceipt(callback) | () => void | Listen for read receipts from the user's other devices. |
| clearNotification(conversationId) | void | Tell the user's other devices a conversation's notifications were dismissed. |
| onNotificationCleared(callback) | () => void | Listen for notification-clear syncs from the user's other devices. |
| syncDraft(conversationId, draftText) | void | Push a conversation draft to the user's other devices (opt-in, cleartext). |
| onDraftSync(callback) | () => void | Listen for draft syncs from the user's other devices. |
Cross-device read receipts
Available since SDK 0.11.0. Your app decides when a message is read — the platform never infers it. Call markRead() at that moment; the receipt syncs to the user's other devices so they can clear their unread UI. It is not sent to the message's sender. The app owner can switch read receipts off entirely from the dashboard.
// When your UI decides the message has been read
client.markRead(messageId, peerUserId);
// On the user's other devices
client.onReadReceipt(e => {
// e.messageId was read elsewhere — clear your unread state for it
});Group read receipts. Since SDK 0.13.0, a group can opt into sharing read receipts with every member. Call updateGroup(groupId, { readReceiptsVisibleToGroup: true }) (owner/admin); after that, a member's markRead() on a group message is broadcast to every other member, and onReadReceipt fires with e.fromUserId set to the member who read it. It is opt-in per group — the platform never enables it for you, and it has no effect unless read receipts are also enabled for your app.
await client.updateGroup(groupId, { readReceiptsVisibleToGroup: true });
client.onReadReceipt(e => {
// e.fromUserId is the group member who read e.messageId
});Notification clear & draft sync
Available since SDK 0.12.0, both own-device only. clearNotification() tells your user's other devices a conversation's notifications were dismissed — always available. syncDraft() pushes a draft so the user can keep typing on another device — opt-in (the app owner enables it in the dashboard) and the draft text crosses the relay in cleartext.
client.clearNotification(conversationId);
client.onNotificationCleared(e => { /* clear badge for e.conversationId */ });
client.syncDraft(conversationId, composerText);
client.onDraftSync(e => { /* pre-fill composer with e.draftText */ });Device trust
Available since SDK 0.10.0. Any device that completes a connection is implicitly trusted. listMyDevices() powers a "Your devices" screen; revokeMyDevice() cuts a device off immediately. When the current device is revoked, the SDK stops reconnecting and emits a DEVICE_REVOKED event via onEvent — listen for it to clear local key storage and prompt re-registration.
const devices = await client.listMyDevices();
await client.revokeMyDevice('old-phone-device-id');
client.onEvent(e => {
if (e.type === 'DEVICE_REVOKED') {
// This device was revoked elsewhere. Clear local keys and show a re-register screen.
}
});Push notifications
Available since SDK 0.9.0. Customers configure their own APNs / FCM / VAPID credentials in the dashboard. The platform delivers a push only when the sender attaches a pushPayload to a message and the recipient device has no live WebSocket session. See the per-product availability and limits on your dashboard's Subscription page.
// iOS, after didRegisterForRemoteNotificationsWithDeviceToken
await client.registerPushToken({ platform: 'APNS', token: deviceTokenHex });
// iOS + PushKit/CallKit (VoIP)
await client.registerPushToken({ platform: 'APNS', token: deviceTokenHex, voipToken: voipTokenHex });
// Android, after FirebaseMessaging.getInstance().token
await client.registerPushToken({ platform: 'FCM', token: fcmToken });
// Browser, the token is the JSON returned by PushManager.subscribe()
await client.registerPushToken({ platform: 'WEB_PUSH', token: JSON.stringify(subscription) });
// On logout
await client.unregisterPushToken({ platform: 'APNS' });E2EE invariant: the push body is sender-supplied cleartext metadata only (e.g. "1 new message from Alice"), never the encrypted message contents. The recipient SDK decrypts the real message after the push wakes the device and the WebSocket reconnects.
Message Edit & Delete
Available since SDK 0.4.0.
// Edit an encrypted message
await client.editMessage(originalMessageId, 'recipient-user-id', 'Updated text');
// Edit a cleartext message
await client.editCleartextMessage(originalMessageId, 'recipient-user-id', 'Updated text');
// Delete for everyone
await client.deleteMessage(originalMessageId, 'recipient-user-id', 'FOR_EVERYONE');
// Delete only on the sender's own devices
await client.deleteMessage(originalMessageId, 'recipient-user-id', 'FOR_ME');
client.onMessageEdit((edit) => {
console.log('edited', edit.originalMessageId, edit.text);
});
client.onMessageDelete((del) => {
console.log('deleted', del.originalMessageId, del.scope);
});- E2EE edits are re-encrypted per recipient device; the relay never sees the new plaintext.
FOR_EVERYONEnotifies the recipient;FOR_MEonly syncs the tombstone to the sender's other devices.- Each edit and each
FOR_EVERYONEdelete counts as oneMESSAGEusage record.
Attachments (Customer-Managed Storage)
Available since SDK 0.5.0. Configure your bucket once in the DropOnAir panel (S3-compatible, GCS, or Azure Blob). DropOnAir never holds file bytes - the SDK uploads/downloads directly against your bucket via short-lived presigned URLs.
// Convenience: encrypt + upload + finalize in one call, then send with the message.
const ref = await client.prepareAttachmentAndUpload(fileBytes, {
toUserId: 'recipient-user-id',
mimeType: 'image/jpeg',
encryptionType: 'E2EE', // or 'CLEARTEXT' for public files
});
await client.sendMessage('recipient-user-id', 'Here is the file', { attachments: [ref] });
// Recipient side: incoming DecryptedMessage carries optional attachments[]
client.onMessage(async (msg) => {
console.log(msg.plaintext);
for (const att of msg.attachments ?? []) {
const dl = await client.downloadAttachment(att);
// dl.bytes is Uint8Array, dl.mimeType / dl.sizeBytes / dl.sha256 are populated
}
});| Method | Returns | Description |
|--------|---------|-------------|
| prepareAttachmentAndUpload(bytes, options) | Promise<AttachmentRef> | Encrypt (E2EE), upload, finalize, return ref |
| createUploadSession(options) | Promise<UploadSession> | Low-level: presigned PUT URL only |
| finalizeAttachment(attachmentId, sha256) | Promise<void> | Low-level: commit integrity hash |
| downloadAttachment(ref) | Promise<DownloadedAttachment> | Get presigned URL + download + decrypt |
| revokeAttachment(attachmentId \| ref) | Promise<void> | Revoke an attachment you sent; recipients get ATTACHMENT_REVOKED |
| sendMessage(toUserId, text, { attachments }) | Promise<{ messageId }> | Send with attachments |
Preview thumbnails (optional). Pass
thumbnail(bytes) inprepareAttachmentAndUploadoptions and the SDK uploads it as a separate encrypted attachment, linked viaAttachmentRef.thumbnailAttachmentId. Purely the developer's choice; omit it and there is no thumbnail. Download the thumbnail like any attachment.Revoke.
revokeAttachment()stops the platform issuing new download URLs and notifies recipients. Bytes a recipient already downloaded cannot be recalled. For GROUP attachments, download is authorized against current group membership, so a removed member loses access. Pass anAttachmentRefinstead of an id (revokeAttachment(ref), since 0.13.1) to revoke the linked preview thumbnail in the same call; a thumbnail is a separate attachment, so revoking a bare id leaves its thumbnail untouched.E2EE: a random AES-256-GCM file key encrypts the bytes; the file key is wrapped per recipient device using X25519 + HKDF (same model as message payloads). Server never sees the unwrapped file key.
Availability and per-file / per-month limits depend on your plan and are enforced server-side before the presigned URL is issued. See the pricing page and your dashboard Subscription page for what's enabled on your app.
Upload URL TTL = 15 min. Download URL TTL = 5 min. Download authorization checks that the requester is the original sender or in the captured recipient list.
Broadcast Channels
| Method | Returns | Description |
|--------|---------|-------------|
| subscribeBroadcast(channelId) | Promise<void> | Subscribe to a channel |
| unsubscribeBroadcast(channelId) | Promise<void> | Unsubscribe from a channel |
| publishBroadcast(channelId, plaintext) | Promise<{ broadcastId }> | Publish to a channel |
| onBroadcast(callback) | () => void | Listen for broadcast messages |
Groups
| Method | Returns | Description |
|--------|---------|-------------|
| createGroup(name, memberUserIds?) | Promise<GroupInfo> | Create a group with optional members |
| listGroups() | Promise<GroupInfo[]> | List your groups |
| getGroup(groupId) | Promise<GroupInfo> | Get group details |
| addGroupMembers(groupId, userIds) | Promise<GroupInfo> | Add members (owner/admin) |
| removeGroupMember(groupId, userId) | Promise<GroupInfo> | Remove a member (owner/admin) |
| updateGroup(groupId, { name?, readReceiptsVisibleToGroup? }) | Promise<GroupInfo> | Update group settings (owner/admin) |
| deleteGroup(groupId) | Promise<void> | Delete a group (owner only) |
| sendGroupMessage(groupId, plaintext, memberUserIds, options?) | Promise<{ messageId }> | Send an end-to-end encrypted group message (sender-side per-recipient fan-out). options.attachments for E2EE group attachments. |
| sendCleartextGroupMessage(groupId, plaintext) | Promise<{ messageId }> | Send a plaintext (non-encrypted) group message; server fans out to every other member. |
| onGroupMessage(callback) | () => void | Listen for group messages |
1-to-1 Calls
| Method | Returns | Description |
|--------|---------|-------------|
| startCall(targetUserId) | Promise<string> | Start a call, returns callId |
| acceptCall(callId) | Promise<void> | Accept incoming call |
| rejectCall(callId) | Promise<void> | Reject incoming call |
| endCall(callId) | Promise<void> | End or cancel a call |
| toggleVideo(callId, enabled) | void | Toggle video on/off |
| sendCallSignal(type, callId, payload) | void | Send SDP/ICE signaling data |
| onCallEvent(callback) | () => void | Listen for call events |
| fetchTurnCredentials() | Promise<TurnCredentials> | Get TURN server credentials for NAT traversal |
Group Calls
| Method | Returns | Description |
|--------|---------|-------------|
| startGroupCall(groupId) | Promise<string> | Start a group call, returns callId |
| joinGroupCall(callId, groupId) | Promise<void> | Join an active group call |
| leaveGroupCall(callId) | Promise<void> | Leave a group call |
| endGroupCall(callId) | Promise<void> | End a group call for all |
| sendGroupCallSignal(type, callId, groupId, targetUserId, payload) | void | Send SDP/ICE to a peer |
| onGroupCallEvent(callback) | () => void | Listen for group call events |
Scheduled & Persistent Rooms
Available since SDK 0.14.0. A room is an addressable container a live multi-party call runs inside - unlike a group, it is not tied to a member roster. Anyone in your app with the room id can attempt to join; the room's policy is the gate. "Scheduled" vs "persistent" is just how you compose the knobs - the platform imposes no meeting model.
| Method | Returns | Description |
|--------|---------|-------------|
| createRoom(options?) | Promise<Room> | Create a room (name, schedule, policy, hosts) |
| listRooms() | Promise<Room[]> | Rooms you created or host |
| getRoom(roomId) | Promise<Room> | One room, including live-call state |
| updateRoom(roomId, update) | Promise<Room> | Update name/schedule/policy/hosts (creator only) |
| deleteRoom(roomId) | Promise<void> | Delete a room (creator only) |
| joinRoom(roomId) | Promise<string> | Join the room's live call, returns callId |
| leaveRoom(callId) | Promise<void> | Leave the room's live call |
| getSfuToken(roomId) | Promise<SfuToken> | Token to join the room's SFU media (SFU-mode rooms only) |
| startSfuRecording(roomId, destinationId) | Promise<SfuRecording> | Start server-side recording (SFU + mediaEncryption: 'SFU' only) |
| stopSfuRecording(roomId, recordingId) | Promise<SfuRecording> | Stop a running SFU recording |
| listSfuRecordings(roomId) | Promise<SfuRecording[]> | List SFU recordings for a room, newest first |
Room policy (RoomPolicy, all optional): waitingRoom (non-hosts wait for host admit), requireHost (non-hosts cannot open the call), maxParticipants (per-room cap), autoCloseWhenEmpty (room flips to CLOSED when the last participant leaves), mediaMode ('MESH' default | 'SFU'), mediaEncryption ('E2EE' default | 'SFU').
const room = await client.createRoom({
name: 'Weekly sync',
scheduledStartAt: Date.now() + 3_600_000, // metadata - your app drives the countdown UX
policy: { waitingRoom: true, requireHost: true },
});
// Join the live call. The returned callId works with every group-call
// signaling method - pass '' for groupId.
const callId = await client.joinRoom(room.roomId);
client.onGroupCallEvent((e) => {
// room-call events carry e.roomId; e.type GROUP_CALL_PARTICIPANT_JOINED, etc.
});
client.sendGroupCallSignal('GROUP_CALL_SDP_OFFER', callId, '', peerUserId, sdp);joinRoom rejects with HOST_REQUIRED, ROOM_CLOSED, or WAITING_ROOM_PENDING (listen for GROUP_CALL_WAITING_ROOM_ADMITTED, then call joinRoom again).
Live Stage
Available since SDK 0.15.0. Create a room with policy.stageMode and its live call runs as a stage: hosts join as speakers, everyone else joins as receive-only audience. Audience can raise a hand; a host promotes them to speaker. The media topology is still the WebRTC mesh, so a stage is suited to panels and small/medium audiences - large-audience streaming needs an SFU and is out of scope.
| Method | Description |
|--------|-------------|
| raiseHand(callId) | Request promotion to speaker (any participant) |
| lowerHand(callId) | Lower your raised hand |
| promoteToSpeaker(callId, userId) | Promote an audience member (host / co-host) |
| demoteToAudience(callId, userId) | Demote a speaker (host / co-host) |
| submitStageQuestion(callId, text) | Submit a text question to the stage's speakers |
const room = await client.createRoom({ name: 'AMA', policy: { stageMode: true, requireHost: true } });
const callId = await client.joinRoom(room.roomId);
// Audience side
client.raiseHand(callId);
client.submitStageQuestion(callId, 'How does E2EE key rotation work?');
// Host side - role changes arrive as GROUP_CALL_ROLE_CHANGED events
client.onGroupCallEvent((e) => {
if (e.type === 'GROUP_CALL_STAGE_HAND_RAISED') client.promoteToSpeaker(callId, e.payload!);
if (e.type === 'GROUP_CALL_ROLE_CHANGED') updateStageUi(e.payload); // {"userId","role":"SPEAKER"|"AUDIENCE"}
});Audience members are receive-only by convention: on GROUP_CALL_ROLE_CHANGED to AUDIENCE, your app simply does not publish a local media track. The platform signals the role; your app owns the WebRTC tracks.
Routed media (SFU)
Available since SDK 0.17.0. A room can opt out of peer-to-peer mesh and route its live-call media through the platform's media server. Set policy.mediaMode to 'SFU' at create time, then ask the SDK for a join token; hand the token to your SFU client (e.g. LiveKit) to connect. policy.mediaEncryption controls whether the media server can decrypt the media: 'E2EE' (default) keeps the platform blind, 'SFU' lets the server terminate media so it can be recorded server-side.
const room = await client.createRoom({
name: 'All hands',
policy: { mediaMode: 'SFU', mediaEncryption: 'E2EE' },
});
const sfu = await client.getSfuToken(room.roomId);
// sfu = { url, token, room, mediaEncryption, expiresAt }
// connect with your SFU client using sfu.url + sfu.tokenMesh remains the default; only switch to SFU once the participant count outgrows mesh or you specifically need server-side recording. The getSfuToken call returns 409 for mesh-mode rooms and 503 if the media server is not available.
SFU recording
Available since SDK 0.18.0. Server-side recording for SFU-mode rooms with mediaEncryption: 'SFU'. The platform's media server captures the room composite and uploads the finalized file directly to your storage bucket (S3, GCS, or Azure); the platform never holds the recorded bytes. Storage destinations are registered in the panel; the SDK references each by an opaque destinationId.
const rec = await client.startSfuRecording(room.roomId, 'dst_aBc123');
// { recordingId, status: 'STARTING', startedAt, ... }
// Later, when the host ends the recording:
await client.stopSfuRecording(room.roomId, rec.recordingId);Completion lands as the sfu-recording.completed webhook (or by polling listSfuRecordings) and carries locationUri pointing at the finalized file in your bucket. E2EE-encrypted SFU rooms cannot be server-recorded by design (the media server forwards traffic it cannot decrypt) - use the client-side startRecording for those.
Live call participants also receive in-band notification frames via the existing group-call event callback so a "🔴 RECORDING" UI badge stays in sync. The event types are GROUP_CALL_SFU_RECORDING_STARTED (payload = recordingId), GROUP_CALL_SFU_RECORDING_STOPPED (recordingId), and GROUP_CALL_SFU_RECORDING_AVAILABLE (recordingId|locationUri); the platform also notifies late joiners about any already-active SFU recording on the room. Plan limits (maxSfuRecordingMinutesPerMonth) are enforced server-side; deny at startSfuRecording returns HTTP 403 with a deny reason such as MONTHLY_SFU_RECORDING_MINUTES_LIMIT_REACHED.
HTTP fallback (SSE) transport
Available since SDK 0.19.0. For environments where WebSocket upgrades are blocked by a corporate firewall but plain HTTPS GET/POST work, the SDK exports a standalone SseTransport primitive that rides the platform's /v1/transport/stream (long-lived SSE) and /v1/transport/send (POST) endpoints. WebSocket on /ws remains the default for client.connect(); this primitive is opt-in for the corporate-firewall use case.
import { SseTransport } from '@droponair/sdk-js';
const sse = new SseTransport({
httpUrl: 'https://sdk.droponair.com',
getJwt: async () => jwt,
});
sse.onFrame((bytes) => { /* protobuf decode */ });
await sse.connect();
await sse.sendEnvelope(envelopeBytes);EventSource-based receive (auto-reconnects + 25s keep-alives that defeat proxy idle timeouts), fetch POST for send. Bring your own protobuf decoder (use the codec already exported by the SDK). Verify the lane is enabled on the server via GET /api/info: the transports array includes "sse" and features includes "transport_sse". v1 covers 1:1 Envelope delivery; call signaling stays on WebSocket. Full init({ transport: 'sse' }) integration into MessagingClient lands in a follow-up.
WebTransport (HTTP/3) transport
Available since SDK 0.20.0. For modern browsers + environments with native HTTP/3 support, the SDK exports a WebTransportTransport primitive that rides the platform's /v1/transport/wt endpoint. Multi-stream and lower head-of-line blocking versus WebSocket; particularly useful on flaky cellular networks.
import { WebTransportTransport } from '@droponair/sdk-js';
const wt = new WebTransportTransport({
httpUrl: 'https://sdk.droponair.com',
getJwt: async () => jwt,
});
wt.onFrame((bytes) => { /* protobuf decode */ });
await wt.connect();
await wt.sendEnvelope(envelopeBytes);Uses the browser-native WebTransport API; opens a single bidirectional stream and rides raw bytes both ways. Browser support: Chromium 97+ (stable), Firefox 125+ (stable), Safari Technology Preview only, Node 22+ behind experimental flags. The droponair.com endpoint advertises transports: ["webtransport"] only when the droponair-webtransport sidecar is live; check GET /api/info.transports and features.transport_webtransport to detect availability. Bring your own protobuf decoder. WebSocket remains the default; this primitive is purely opt-in.
Transport auto-select
Available since SDK 0.21.0. selectTransport({ httpUrl }) queries /api/info.transports, intersects with what the runtime supports (WebTransport / WebSocket / EventSource), and returns the best lane for this client + platform pair. Default preference: WebTransport → WebSocket → SSE (overridable).
import { selectTransport, WebTransportTransport, SseTransport } from '@droponair/sdk-js';
const lane = await selectTransport({ httpUrl: 'https://sdk.droponair.com' });
switch (lane) {
case 'webtransport': /* new WebTransportTransport(...) */ break;
case 'sse': /* new SseTransport(...) */ break;
default: /* fall through to your WebSocket client */
}Foundation for an init({ transport: 'auto' }) shortcut once full MessagingClient integration lands (a follow-up). Today it's a small standalone helper so existing customers can adopt the pattern without waiting.
Call Recording
Available since SDK 0.16.0. The SDK signals recording state on a group or room call; your app does the actual media capture (MediaRecorder) and uploads the file to your own storage — the platform never holds the media. The recording signal is broadcast to every participant (including anyone who joins later); that transparency is enforced server-side.
| Method | Description |
|--------|-------------|
| startRecording(callId) | Signal that you started recording |
| stopRecording(callId) | Signal that you stopped recording |
| markRecordingAvailable(callId, location) | Announce an uploaded recording (location references it in your storage) |
client.startRecording(callId);
// ... your app records the streams and uploads the file ...
client.markRecordingAvailable(callId, 's3://my-bucket/recordings/call-123.webm');
client.stopRecording(callId);
client.onGroupCallEvent((e) => {
if (e.type === 'GROUP_CALL_RECORDING_STARTED') showRecordingBanner(e.payload); // recorder userId
});The platform also fires recording.started / recording.stopped / recording.available webhooks. For E2EE calls, recording stays on the client (the platform has no media plane); a server-side SFU recording mode is a future option.
Screen Sharing
Available since SDK 0.6.0. The SDK only signals start/stop - capture (via the browser's getDisplayMedia()) and adding the resulting MediaStreamTrack to the existing peer connection are the app's responsibility.
// 1:1 call
async function shareMyScreen(callId: string, peerConnection: RTCPeerConnection) {
const stream = await navigator.mediaDevices.getDisplayMedia({ video: true });
const track = stream.getVideoTracks()[0];
peerConnection.addTrack(track, stream);
client.startScreenShare(callId);
track.onended = () => client.stopScreenShare(callId);
}
// Group call
async function shareMyScreenInGroup(callId: string, groupId: string, peerConnections: RTCPeerConnection[]) {
const stream = await navigator.mediaDevices.getDisplayMedia({ video: true });
const track = stream.getVideoTracks()[0];
peerConnections.forEach(pc => pc.addTrack(track, stream));
client.startGroupScreenShare(callId, groupId);
track.onended = () => client.stopGroupScreenShare(callId, groupId);
}
client.onCallEvent((evt) => {
if (evt.type === 'CALL_SCREEN_SHARE_STARTED') showShareIndicator(evt.callId);
if (evt.type === 'CALL_SCREEN_SHARE_STOPPED') hideShareIndicator(evt.callId);
});
client.onGroupCallEvent((evt) => {
if (evt.type === 'GROUP_CALL_SCREEN_SHARE_STARTED') showShareIndicator(evt.callId, evt.payload); // payload = sharer userId
if (evt.type === 'GROUP_CALL_SCREEN_SHARE_STOPPED') hideShareIndicator(evt.callId);
});| Method | Returns | Description |
|--------|---------|-------------|
| startScreenShare(callId, payload?) | void | Signal start in a 1:1 call |
| stopScreenShare(callId, payload?) | void | Signal stop in a 1:1 call |
| startGroupScreenShare(callId, groupId, payload?) | void | Signal start in a group call |
| stopGroupScreenShare(callId, groupId, payload?) | void | Signal stop in a group call |
- The server enforces one concurrent sharer per group call. If another participant already holds the slot when you call
startGroupScreenShare, the SDK delivers aGROUP_CALL_SCREEN_SHARE_STOPPEDevent with the current holder's userId inpayload, so you can roll back the optimistic UI. - When a sharer leaves the call, the server broadcasts a STOPPED event to remaining participants before they see
PARTICIPANT_LEFT. - Availability depends on your plan. See the pricing page and your dashboard Subscription page for what's enabled on your app.
Conference moderation and waiting room
Available since SDK 0.7.0. The call initiator is the default host; host can transfer the role mid-call or appoint co-hosts. Host and co-host can mute, remove, and gate joiners through a waiting room. If the host leaves without transferring, the server auto-promotes the longest-joined remaining participant and broadcasts GROUP_CALL_ROLE_CHANGED.
// Host actions
client.transferHost(callId, groupId, 'alice');
client.appointCoHost(callId, groupId, 'bob');
client.muteParticipant(callId, groupId, 'eve'); // signaling hint
client.removeParticipant(callId, groupId, 'mallory'); // server-enforced drop
// Waiting room
client.requestWaitingRoomEntry(callId, groupId); // would-be joiner side
client.admitFromWaitingRoom(callId, groupId, 'newbie'); // host / co-host
client.rejectFromWaitingRoom(callId, groupId, 'troll');
client.onGroupCallEvent((evt) => {
if (evt.type === 'GROUP_CALL_ROLE_CHANGED') updateHostUi(evt.payload); // {"userId":"...","role":"HOST|CO_HOST|PARTICIPANT"}
if (evt.type === 'GROUP_CALL_WAITING_ROOM_JOINED') showWaitingApprovalPrompt(evt.payload);
if (evt.type === 'GROUP_CALL_WAITING_ROOM_ADMITTED') joinTheCall(evt.callId, evt.groupId);
if (evt.type === 'GROUP_CALL_MUTE_PARTICIPANT') muteMyMicLocally();
if (evt.type === 'GROUP_CALL_PARTICIPANT_REMOVED') exitCallUi();
});| Method | Returns | Description |
|--------|---------|-------------|
| transferHost(callId, groupId, newHostUserId) | void | Host only |
| appointCoHost(callId, groupId, userId) | void | Host only |
| revokeCoHost(callId, groupId, userId) | void | Host only |
| muteParticipant(callId, groupId, targetUserId) | void | Host / co-host. Signaling hint - the target SDK mutes the local mic. |
| removeParticipant(callId, groupId, targetUserId) | void | Host / co-host. Server drops the target from the call session. |
| requestWaitingRoomEntry(callId, groupId) | void | Would-be joiner |
| admitFromWaitingRoom(callId, groupId, userId) | void | Host / co-host |
| rejectFromWaitingRoom(callId, groupId, userId) | void | Host / co-host |
- Mute is intentionally a signaling hint: WebRTC media tracks can't be muted from outside the producing client. Remove is server-enforced - the target SDK receives
GROUP_CALL_PARTICIPANT_REMOVEDand the others receivePARTICIPANT_LEFT. - Availability depends on your plan. See the pricing page and your dashboard Subscription page for what's enabled on your app.
Security
- X25519 ECDH key agreement for shared secrets
- HKDF-SHA256 key derivation
- AES-256-GCM authenticated encryption with AAD binding (messageId, senderId, recipientId, timestamp)
- Private keys generated and stored locally, never sent to DropOnAir
- Multi-device: each device has its own keypair, messages encrypted per-device
Requirements
- Node.js >= 18.0.0
- Browser with Web Crypto API support (all modern browsers)
License
MIT
Support
- Website: droponair.com
- Email: [email protected]
