Tutorial 6 of 18

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.

16 min read

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 === false

Phase 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

ActionMethod
Send text or structured datasend()
Send raw binarysendBinary()
Request chat historyfetchChatHistory()
Read plan limitsgetPlanLimits()
Check connection stateisConnected

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

PropertyDescription
isConnectedtrue when ready to send (ws) or authenticated (http)
aiEnabledWhether AI is enabled for the current plan
authTokenToken from the last successful auth — avoid logging in production
messageSizeLimitMaximum 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.

FieldRequiredDescription
senderIdYesMust match the connecting user's userId
receiverIdOne ofSingle recipient user ID
receiverIdsOne ofMultiple recipients
dataYesJSON-serializable payload — typically { type: "text", text: "..." }
saveHistoryNoDefault 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.

FieldTypeDescription
idstring (optional)Unique message identifier when assigned by the server
senderstringPrefixed sender address (combines appId and sender's userId)
timestampnumberUnix epoch milliseconds when the message was sent
typestring (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.

MethodUse 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.typeTypical fieldsUse case
texttextChat messages, notifications
imagecontent (bytes or base64)Photo sharing
audiocontent, durationMsVoice notes
doccontent, fileName, mimeTypeFile attachments
Custom stringsYour schemaTyping 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}")