How to Understand the Message Lifecycle and Payload Structure
Follow a realtime message from connection through delivery, then learn how to read its metadata, payload, and application-defined message type.
Every incoming realtime message in DNotifier arrives as a structured object with metadata (routing and envelope fields) and payload (your application data). This tutorial follows a message from connection, through sending and delivery, to receipt — and explains the structure of what your handler receives. The same concepts apply across JavaScript, Dart, and Python.
Step 1: See How a Message Flows
Realtime communication is DNotifier's directed messaging layer: clients connect over WebSocket, send payloads to explicit receiver IDs, and receive deliveries in onMessage.
Client A (user-alice) DNotifier Client B (user-bob)
│ │ │
│ connect() + auth │ │
├─────────────────────►│ │
│ │ connect() │
│ │◄─────────────────────────────┤
│ │ │
│ send({ receiverId: "user-bob" }) │
├─────────────────────►│ │
│ │ onMessage on bob's client │
│ ├─────────────────────────────►│- Both clients connect with the same appId but different userId values.
- The sender calls send() with senderId matching their connected user and receiverId set to the target.
- If the receiver is online, onMessage fires immediately with { metadata, payload }.
- Offline receivers do not get live delivery; use chat history if you need persistence.
Step 2: Understand the Connection Lifecycle
A DNotifier realtime client moves through authentication, WebSocket handshake (when using ws), active messaging, and disconnect. Understanding this lifecycle helps you wire onConnected, onMessage, and onDisconnected correctly.
construct DNotifier({ transport: "ws", userId, ... })
│
▼
await connect()
│
├──► HTTPS auth → production API host
│ │
│ ▼
│ token + plan limits
│
├──► [ws only] open WebSocket + handshake
│
▼
onConnected()
isConnected === true
│
├── send(), sendBinary(), fetchChatHistory(), ...
│
▼
onMessage() ◄── incoming frames (ws only)
│
▼
disconnect() or network loss
│
▼
onDisconnected({ code, reason })
isConnected === falsePhase 1 — Authentication
All transports authenticate the same way at connect():
- The SDK sends appId, appSecret, and userID to the DNotifier auth endpoint.
- The server returns an auth token and plan limits.
- The client sets aiEnabled, messageSizeLimit, and related properties.
- If authentication fails (wrong secret, invalid app, plan issue), connect() throws.
Production auth uses api.dnotifier.com. You rarely need to configure this — SDKs default to the production host. Use the optional url constructor parameter only when DNotifier provides a custom WebSocket endpoint.
Phase 2 — WebSocket Handshake (transport: "ws")
- The SDK opens a WebSocket to the DNotifier realtime endpoint.
- It sends a handshake frame containing the auth token.
- It waits for server acknowledgment.
- It sets isConnected = true and invokes onConnected.
Phase 3 — Active Session
| Action | Method |
|---|---|
| Send text or structured data | send() |
| Send raw binary | sendBinary() |
| Request chat history | fetchChatHistory() |
| Read plan limits | getPlanLimits() |
| Check connection state | isConnected |
Incoming messages always flow through the onMessage callback registered at construction time.
Phase 4 — Disconnect
Disconnect can happen when:
- You call disconnect() explicitly.
- The network drops or the server closes the socket.
- An unrecoverable transport error occurs.
The SDK invokes onDisconnected with optional code and reason. After disconnect, isConnected is false and sends will throw until you connect() again.
Properties After Connect
| Property | Description |
|---|---|
| isConnected | true when ready to send (ws) or authenticated (http) |
| aiEnabled | Whether AI is enabled for the current plan |
| authToken | Token from the last successful auth — avoid logging in production |
| messageSizeLimit | Maximum message size in bytes before chunking |
JavaScript
await notifier.connect();
console.log(notifier.isConnected);
console.log(notifier.messageSizeLimit);
const limits = notifier.getPlanLimits();Dart
await notifier.connect();
print(notifier.isConnected);
print(notifier.messageSizeLimit);
final limits = notifier.getPlanLimits();HTTP Transport Note
With transport: "http", Phase 2 is skipped — there is no persistent socket. connect() authenticates and sets isConnected = true immediately. Realtime send() and onMessage are not the primary use case for HTTP; use ws for live messaging.
Step 3: Build the Message You Send
The core realtime pattern is to send a JSON payload to a receiver ID and handle incoming messages in onMessage. The send() call is where your payload structure begins.
| Field | Required | Description |
|---|---|---|
| senderId | Yes | Must match the connecting user's userId |
| receiverId | One of | Single recipient user ID |
| receiverIds | One of | Multiple recipients |
| data | Yes | JSON-serializable payload — typically { type: "text", text: "..." } |
| saveHistory | No | Default true — persist for chat history APIs |
Example payload:
{
"type": "text",
"text": "Hello from DNotifier!"
}Use any type string your app understands. DNotifier does not enforce a fixed enum.
Requirements for a message to flow:
- transport: "ws" — realtime send/receive uses WebSocket.
- connect() completed — isConnected must be true.
- Matching appId — sender and receiver must use the same application.
- The receiver does not need to be online at send time for history-backed chat. For live delivery, the receiver must be connected.
Step 4: Follow the Message Through Delivery
Once sent, the message travels through the DNotifier cloud and arrives at the receiver's onMessage callback.
send({ data: { type, ... } })
│
▼
DNotifier cloud
│
▼
onMessage({ metadata, payload })
│
├── metadata.sender → who
├── metadata.timestamp → when
├── metadata.id → dedupe key
└── payload.toJSON() → your { type, ... }Large Messages and Chunking
Payloads larger than your plan's messageSizeLimit are split into chunks automatically by the SDK on send and reassembled before onMessage fires on receive. Your handler always sees a complete payload — you do not process individual chunks unless using low-level binary APIs.
Step 5: Understand the Message Shape
Your SDK delivers a DNotifierMessage object to the onMessage callback (or onMessage property in Dart) whenever a WebSocket frame is reassembled and parsed.
┌───────────────────────────────────┐ │ DNotifierMessage │ ├─────────────────┬─────────────────┤ │ metadata │ payload │ │ (envelope) │ (your data) │ └─────────────────┴─────────────────┘
Metadata
Metadata describes the message envelope — who sent it, when, and how DNotifier classified the frame.
| Field | Type | Description |
|---|---|---|
| id | string (optional) | Unique message identifier when assigned by the server |
| sender | string | Prefixed sender address (combines appId and sender's userId) |
| timestamp | number | Unix epoch milliseconds when the message was sent |
| type | string (optional) | Frame or content hint from the transport layer |
Reading the Sender
The sender field is the canonical address of the message origin. Compare it to your known user IDs or map it back to your app's user table. When you send a message, recipients see your prefixed sender ID in their onMessage handler.
Conceptual metadata
{
"id": "msg_abc123",
"sender": "app_xyz:user-alice",
"timestamp": 1717536000123,
"type": "text"
}Exact field names and optional fields are consistent across SDKs; refer to your language reference for typed definitions.
Payload
The payload is the body of the message — whatever you placed in data when calling send(), or what another service sent to you. SDKs wrap raw bytes in a Payload helper with conversion methods.
| Method | Use when |
|---|---|
| toJSON() | Payload is JSON (most text and structured messages) |
| toString() | Payload is plain text |
| toBase64() | You need a base64 representation, such as binary embedded as a string |
| raw() | You need raw bytes (images, audio, custom binary) |
Typical Text Message Payload
Sender
send({
senderId: "user-alice",
receiverId: "user-bob",
data: { type: "text", text: "Hello!" },
});Receiver
onMessage: (msg) => {
const body = msg.payload.toJSON();
// body === { type: "text", text: "Hello!" }
}Dart equivalent
onMessage: (msg) {
final body = msg.payload.toJSON();
// body is a Map with type and text keys
},Message Types (data.type)
DNotifier does not enforce a fixed enum for application payload types. You define conventions in your app.
| data.type | Typical fields | Use case |
|---|---|---|
| text | text | Chat messages, notifications |
| image | content (bytes or base64) | Photo sharing |
| audio | content, durationMs | Voice notes |
| doc | content, fileName, mimeType | File attachments |
| Custom strings | Your schema | Typing indicators, reactions, commands |
The onMessage Handler
onMessage: (msg) => {
const { sender, timestamp } = msg.metadata;
const body = msg.payload.toJSON();
switch (body?.type) {
case "text":
appendChatBubble(sender, body.text, timestamp);
break;
case "typing":
showTypingIndicator(sender);
break;
default:
console.log("Unknown type", body);
}
},Best practices:
- Never assume JSON — call toJSON() inside try/catch or check content type hints first.
- Validate body.type — ignore or log unknown types safely.
- Do not block — offload heavy work such as image decode or disk writes to async tasks.
- Use metadata.id for idempotency and deduplication if your app may receive retries.
Step 6: Know What Does Not Arrive in onMessage
The onMessage callback covers WebSocket-delivered messages. HTTP transport methods (sendAI, search, runWorkflow, and others) return responses directly from the async method call — they do not flow through onMessage.
Some APIs, such as fetchChatHistory, trigger a response that does arrive via onMessage on WebSocket clients.
Step 7: Route Messages by Payload Type in Your Language
Read metadata for the envelope, then branch on the type in your payload. The examples below show the same routing pattern in each supported language.
JavaScript / TypeScript
Read the envelope from metadata, convert the payload to JSON, and route on body.type.
onMessage: (msg) => {
const { sender, timestamp } = msg.metadata;
const body = msg.payload.toJSON();
switch (body?.type) {
case "text":
appendChatBubble(sender, body.text, timestamp);
break;
case "typing":
showTypingIndicator(sender);
break;
default:
console.log("Unknown type", body);
}
},Describe the Payload Shape with TypeScript
type TextPayload = { type: "text"; text: string };
await notifier.send({
senderId: MY_USER_ID,
receiverId: PEER_USER_ID,
data: { type: "text", text: "Hello!" } satisfies TextPayload,
});Dart / Flutter
Check that the decoded payload is a map before reading your application-defined type.
onMessage: (msg) {
final body = msg.payload.toJSON();
if (body is! Map) return;
switch (body['type']) {
case 'text':
appendChatBubble(msg.metadata.sender, body['text'], msg.metadata.timestamp);
break;
case 'typing':
showTypingIndicator(msg.metadata.sender);
break;
default:
print('Unknown type: $body');
}
},Python
Python uses the snake_case to_json() helper and routes dictionary payloads by their type key.
def on_message(msg):
body = msg.payload.to_json()
if not isinstance(body, dict):
return
msg_type = body.get("type")
if msg_type == "text":
append_chat_bubble(msg.metadata.sender, body["text"], msg.metadata.timestamp)
elif msg_type == "typing":
show_typing_indicator(msg.metadata.sender)
else:
print(f"Unknown type: {body}")