← Back to DevBytes

Matrix Protocol: A Complete Reference Guide

Introduction to the Matrix Protocol

Matrix is an open, decentralized, and federated real-time communication protocol designed to enable seamless messaging, voice, and video collaboration across different services and platforms. Think of it as the "email for messaging" — just as you can send an email from Gmail to someone using Outlook or a self-hosted server, Matrix allows users on different chat services to communicate with each other natively, without central control or proprietary lock-in.

At its core, Matrix is a set of HTTP-based APIs (collectively known as the Client-Server API) that standardize how clients and servers exchange messages, synchronize conversation history, and manage room state. The protocol is published as an open specification by the Matrix.org Foundation and has been adopted by governments, enterprises, and open-source communities worldwide.

Why Matrix Matters

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Understanding the significance of Matrix requires looking at the broader communication landscape. Most messaging platforms today — WhatsApp, Slack, Discord, Telegram — operate as isolated silos. Messages sent on one platform cannot reach users on another unless bridges or third-party integrations are manually configured. Matrix solves this at the protocol level.

True Federation and Decentralization

Matrix servers (called homeservers) can be deployed by anyone. When two homeservers federate, users on each can join shared rooms and exchange messages directly, with no single point of control or failure. This means organizations, communities, and individuals retain ownership of their data while still being able to connect globally.

Interoperability Through Bridges

Matrix's bridging model allows existing platforms like IRC, Slack, Discord, Telegram, and even SMS to be integrated into the Matrix ecosystem. A bridge translates messages between Matrix rooms and third-party channels, making Matrix a universal aggregation layer for all your communication.

End-to-End Encryption

Matrix supports Olm and Megolm, cryptographic protocols based on the Double Ratchet algorithm (the same foundation used by Signal). This provides per-device, per-message encryption that ensures only intended recipients can read message content — even homeserver operators cannot decrypt encrypted rooms.

Open Standard, Royalty-Free

The entire specification is developed openly, with no patent encumbrances. Any developer can implement a client, server, or bot without licensing fees or legal barriers. This fosters a rich ecosystem of interoperable implementations.

Core Concepts and Architecture

Before diving into code, let's establish the foundational concepts that shape every Matrix interaction.

Homeservers

A homeserver is the server-side component that stores message history, manages room state, and handles federation with other servers. Popular implementations include Synapse (the reference implementation in Python), Dendrite (a newer, more scalable implementation in Go), and Conduit (a lightweight Rust implementation). Users register accounts on a homeserver, and their identity takes the form @localpart:domain.tld.

Rooms

All communication in Matrix happens within rooms — persistent conversation containers identified by unique IDs like !abcdef123:example.org. Rooms can be public or invite-only, encrypted or unencrypted, and can contain any number of participants across federated homeservers.

Events

Every message, state change, membership update, or reaction is represented as an event object in Matrix. Events are JSON structures that travel through the Directed Acyclic Graph (DAG) that forms a room's history. The most common event type is m.room.message, but there are dozens of spec-defined event types for typing indicators, read receipts, reactions, edits, and more.

Client-Server API

The Client-Server API (often called the C-S API) is the primary interface developers use. It covers login, registration, room management, message sending, synchronization, and search. The API is RESTful and fully documented at spec.matrix.org.

Matrix URIs and Identifier Syntax

Matrix uses a consistent identifier syntax across all resources:

Getting Started: Your First Matrix Client

The quickest way to understand Matrix is to interact with the Client-Server API directly. Let's walk through a complete session: registering an account, logging in, joining a room, sending messages, and retrieving conversation history — all using plain HTTP requests.

Step 1: Choose a Homeserver

For development and testing, you can use the public homeserver matrix.org or spin up your own Synapse instance locally using Docker:

# Run Synapse locally with Docker
docker run -d --name synapse \
  -v $(pwd)/data:/data \
  -e SYNAPSE_SERVER_NAME=my-local-matrix \
  -e SYNAPSE_REPORT_STATS=no \
  matrixdotorg/synapse:latest

# Generate a configuration if needed
docker exec synapse generate_config

For the examples below, we'll use https://matrix-client.matrix.org as the base URL for the Client-Server API. Replace it with your own homeserver URL if self-hosting.

Step 2: Register a New User

Registration typically requires specifying a username, password, and optionally an initial device display name. Note that matrix.org may require a registration token or CAPTCHA in production; for local Synapse instances, you can disable these checks in the configuration.

curl -X POST https://matrix-client.matrix.org/_matrix/client/v3/register \
  -H "Content-Type: application/json" \
  -d '{
    "username": "mydevuser",
    "password": "s3cur3P@ssw0rd!",
    "initial_device_display_name": "My Tutorial Client",
    "type": "m.login.dummy"
  }'

A successful response returns an access token, device ID, and the fully qualified user ID:

{
  "user_id": "@mydevuser:matrix.org",
  "access_token": "syt_YWxpY2Vf...",
  "device_id": "ABCDEFGHIJ",
  "home_server": "matrix.org"
}

Important: Store the access_token securely. This token authenticates all subsequent API calls. For production applications, use the m.login.password or m.login.token login types with proper user-interactive authentication.

Step 3: Authenticated API Calls

Every authenticated request must include the access token either as a query parameter or in the Authorization header. The recommended approach is the header method:

# Using the Authorization header (preferred)
curl -X GET "https://matrix-client.matrix.org/_matrix/client/v3/sync?filter=0&timeout=30000" \
  -H "Authorization: Bearer syt_YWxpY2Vf..."

# Alternative: query parameter (for environments where headers are difficult)
curl -X GET "https://matrix-client.matrix.org/_matrix/client/v3/sync?access_token=syt_YWxpY2Vf..."

Step 4: Joining a Room

Let's join the public Matrix HQ room. You can join by room alias (#room:domain) or by room ID:

# Join the Matrix HQ welcome room by alias
curl -X POST "https://matrix-client.matrix.org/_matrix/client/v3/join/%23welcome:matrix.org" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'

Note: The # in room aliases must be URL-encoded as %23. The response contains the full room ID and a summary of the room state:

{
  "room_id": "!where:matrix.org",
  "servers": ["matrix.org", "elsewhere.org"]
}

Step 5: Sending Messages

To send a message, you need the room ID and must construct a valid event body. The txnId (transaction ID) is a client-generated unique string used for idempotency — retrying with the same txnId won't produce duplicate messages:

curl -X PUT "https://matrix-client.matrix.org/_matrix/client/v3/rooms/%21where%3Amatrix.org/send/m.room.message/unique-txn-id-001" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "msgtype": "m.text",
    "body": "Hello, Matrix world! This is my first message."
  }'

The response includes the server-assigned event ID:

{
  "event_id": "$abc123def456:matrix.org"
}

Matrix supports several message types beyond plain text:

Step 6: Rich Messages with Formatted Body

Matrix messages can include both a plain text fallback and a formatted HTML representation using the format and formatted_body fields:

curl -X PUT "https://matrix-client.matrix.org/_matrix/client/v3/rooms/%21room%3Amatrix.org/send/m.room.message/txn-002" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "msgtype": "m.text",
    "body": "This is **bold** and _italic_ text (fallback)",
    "format": "org.matrix.custom.html",
    "formatted_body": "

This is bold and italic text

" }'

Step 7: Syncing and Retrieving Messages

The sync endpoint is the primary mechanism for clients to receive new events. A long-polling sync request returns batched events from all rooms the user belongs to:

curl -X GET "https://matrix-client.matrix.org/_matrix/client/v3/sync?timeout=30000&filter=0" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"

The response structure contains three main sections under rooms:

A next_batch token is returned for incremental sync. Pass it as the since parameter in subsequent requests to receive only events that occurred after that point:

# First sync to get initial state and a next_batch token
curl -X GET "https://matrix-client.matrix.org/_matrix/client/v3/sync" \
  -H "Authorization: Bearer YOUR_TOKEN"

# Subsequent incremental syncs
curl -X GET "https://matrix-client.matrix.org/_matrix/client/v3/sync?since=next_batch_token_here&timeout=30000" \
  -H "Authorization: Bearer YOUR_TOKEN"

Building a JavaScript Matrix Client

While raw HTTP requests are educational, production applications typically use the matrix-js-sdk — the official JavaScript/TypeScript SDK maintained by the Matrix.org team. Let's build a simple Node.js bot that listens for messages and responds automatically.

Setting Up the Project

mkdir matrix-bot-tutorial
cd matrix-bot-tutorial
npm init -y
npm install matrix-js-sdk
# Optional: install for TypeScript support
npm install --save-dev @types/node typescript

Bot Implementation

Create a file named bot.js with the following code. This bot logs in, syncs continuously, listens for text messages containing "!ping", and replies with "pong":

const MatrixClient = require('matrix-js-sdk').MatrixClient;
const SimpleFsStore = require('matrix-js-sdk').SimpleFsStore;
const AutojoinRoomsMixin = require('matrix-js-sdk').AutojoinRoomsMixin;

// Configuration - use environment variables in production
const BOT_USERNAME = 'mybotuser';
const BOT_PASSWORD = 'your-secure-password';
const HOMESERVER_URL = 'https://matrix-client.matrix.org';

async function main() {
    // Create a client instance
    const client = MatrixClient.createClient({
        baseUrl: HOMESERVER_URL,
        // Use simple file-based storage for session persistence
        // In production, consider IndexedDB or a proper database
        store: new SimpleFsStore({
            filename: './bot-session.json'
        })
    });

    // Enable auto-joining rooms on invite
    AutojoinRoomsMixin.setupOnClient(client);

    // Login with username and password
    const loginResponse = await client.login('m.login.password', {
        username: BOT_USERNAME,
        password: BOT_PASSWORD,
        initialDeviceDisplayName: 'Node.js Tutorial Bot'
    });

    console.log(`Logged in as ${loginResponse.user_id}`);
    console.log(`Access token: ${loginResponse.access_token}`);

    // Start the sync loop
    await client.startClient({
        initialSyncLimit: 10,          // Messages per room on initial sync
        pollTimeout: 30000,            // Long-poll timeout in ms
        resolveInvitesSeen: true,      // Mark invites as seen
        filter: {
            room: {
                timeline: { limit: 10 },
                ephemeral: { limit: 0 }
            }
        }
    });

    console.log('Sync started. Bot is now listening for messages...');

    // Listen for timeline events in any room
    client.on('Room.timeline', async (event, room, toStartOfTimeline, removed) => {
        // Ignore events that are historical or removed
        if (toStartOfTimeline || removed) return;

        // Only process m.room.message events
        if (event.getType() !== 'm.room.message') return;

        // Ignore messages sent by the bot itself to prevent loops
        if (event.getSender() === client.getUserId()) return;

        const content = event.getContent();
        const body = content.body;

        console.log(`[${room.name || room.roomId}] ${event.getSender()}: ${body}`);

        // Simple command: respond to "!ping"
        if (body.trim().toLowerCase() === '!ping') {
            const replyBody = '🏓 pong! (via Matrix bot)';
            const replyContent = {
                msgtype: 'm.text',
                body: replyBody,
                format: 'org.matrix.custom.html',
                formatted_body: `

🏓 pong! (via Matrix bot)

` }; await client.sendMessage(room.roomId, replyContent); console.log(` ↳ Replied with: ${replyBody}`); } // Command: echo back a message if (body.startsWith('!echo ')) { const echoText = body.slice(6); await client.sendMessage(room.roomId, { msgtype: 'm.text', body: echoText }); console.log(` ↳ Echoed: ${echoText}`); } }); // Handle invitations explicitly if needed client.on('RoomMember.membership', async (event, member) => { if (member.membership === 'invite' && member.userId === client.getUserId()) { console.log(`Invited to room: ${event.getRoomId()}`); await client.joinRoom(event.getRoomId()); console.log(`Joined room: ${event.getRoomId()}`); } }); // Graceful shutdown handler process.on('SIGINT', async () => { console.log('\nShutting down bot...'); client.stopClient(); await client.logout(); console.log('Logged out. Goodbye!'); process.exit(0); }); } main().catch(err => { console.error('Fatal error:', err); process.exit(1); });

Running the Bot

node bot.js

The bot will log in, start syncing, and immediately begin responding to !ping and !echo commands in any room it has joined. The session is persisted to bot-session.json, so restarting the bot won't require re-login — it can reuse the stored access token.

Working with Encrypted Rooms

End-to-end encryption in Matrix uses the Olm and Megolm protocols. To participate in encrypted rooms, your client must set up encryption, upload device keys, and establish Olm sessions with other devices. The SDK handles most of this automatically when you call client.initCrypto().

Here's how to extend the bot to support encrypted messaging:

// After login and before starting the sync
// Initialize the crypto module
await client.initCrypto();

// Wait for crypto to be ready
await client.waitForCryptoReady();
console.log('Crypto subsystem initialized');

// Store crypto keys persistently - critical!
client.on('crypto.keyExport', (exportedKeys) => {
    // Save exportedKeys to a secure file or database
    // These keys are needed to decrypt historical messages
    fs.writeFileSync('./crypto-keys-backup.json', JSON.stringify(exportedKeys));
});

// Start client sync AFTER crypto is ready
await client.startClient({
    // ... sync options
});

// For encrypted rooms, send messages normally - encryption is transparent
await client.sendMessage(encryptedRoomId, {
    msgtype: 'm.text',
    body: 'This message will be automatically encrypted'
});

Critical crypto considerations:

Room Management and State Events

Beyond messaging, Matrix rooms support rich state management. Room state events control the room name, topic, avatar, join rules, guest access, and power levels. Understanding state events is essential for building administrative tools and bots.

Setting Room State

State events are sent to /rooms/{roomId}/state/{eventType}/{stateKey}. Common state keys include an empty string for room-level settings (name, topic) or user IDs for member-specific state:

// Set the room name
curl -X PUT "https://matrix-client.matrix.org/_matrix/client/v3/rooms/%21room%3Adomain/state/m.room.name/" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "Developer Tutorial Room"}'

// Set the room topic
curl -X PUT "https://matrix-client.matrix.org/_matrix/client/v3/rooms/%21room%3Adomain/state/m.room.topic/" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"topic": "Learning Matrix Protocol together"}'

// Set room avatar (requires a pre-uploaded MXC URI)
curl -X PUT "https://matrix-client.matrix.org/_matrix/client/v3/rooms/%21room%3Adomain/state/m.room.avatar/" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"url": "mxc://matrix.org/abcdef123456"}'

Power Levels

Power levels control permissions within a room. The m.room.power_levels event defines thresholds for actions like kicking users, banning, setting state, and sending specific message types:

curl -X PUT "https://matrix-client.matrix.org/_matrix/client/v3/rooms/%21room%3Adomain/state/m.room.power_levels/" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "users": {
      "@admin:matrix.org": 100,
      "@moderator:matrix.org": 50
    },
    "users_default": 0,
    "events": {
      "m.room.name": 50,
      "m.room.power_levels": 100
    },
    "events_default": 0,
    "state_default": 50,
    "ban": 50,
    "kick": 50,
    "redact": 50,
    "invite": 50
  }'

Content Uploads and Attachments

Sending images, files, and other media requires uploading content to the homeserver first, which returns a Matrix Content URI (MXC URI) that can be referenced in message events.

// Step 1: Upload the file
curl -X POST "https://matrix-client.matrix.org/_matrix/media/v3/upload" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: image/png" \
  --data-binary "@my-image.png"

// Response:
// { "content_uri": "mxc://matrix.org/ABCDefgh123456" }

// Step 2: Send an image message referencing the MXC URI
curl -X PUT "https://matrix-client.matrix.org/_matrix/client/v3/rooms/%21room%3Adomain/send/m.room.message/txn-image-001" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "msgtype": "m.image",
    "body": "Check out this screenshot",
    "url": "mxc://matrix.org/ABCDefgh123456",
    "info": {
      "mimetype": "image/png",
      "size": 245760,
      "w": 1920,
      "h": 1080
    }
  }'

The info object provides metadata that clients use for responsive rendering before downloading the full image. Always include accurate dimensions and file size when possible.

Creating and Managing Rooms Programmatically

Creating a room via the API allows you to preset configurations like visibility, encryption, and initial state:

curl -X POST "https://matrix-client.matrix.org/_matrix/client/v3/createRoom" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "My Developer Room",
    "topic": "A room for discussing Matrix development",
    "preset": "private_chat",
    "visibility": "private",
    "invite": ["@friend:matrix.org", "@colleague:otherserver.org"],
    "initial_state": [
      {
        "type": "m.room.encryption",
        "content": {
          "algorithm": "m.megolm.v1.aes-sha2",
          "rotation_period_ms": 604800000,
          "rotation_period_messages": 100
        }
      }
    ],
    "creation_content": {
      "m.federate": true
    }
  }'

Key parameters explained:

Bridging to Other Platforms

Bridges are Matrix's superpower for interoperability. A bridge is a service that connects a Matrix room to a channel on another platform. The Matrix.org ecosystem includes mature bridges for IRC, Slack, Discord, Telegram, WhatsApp, Signal, and more.

Bridge Architecture

Bridges typically operate in two modes:

Deploying a Simple Bridge (Conceptual)

Most bridges run as separate services alongside your homeserver. Here's a conceptual setup using the popular mautrix-discord bridge (a common pattern for all mautrix-based bridges):

# Example: Setting up mautrix-discord bridge (conceptual)
# 1. Clone the bridge repository
git clone https://github.com/mautrix/discord.git
cd discord

# 2. Configure the bridge
cp example-config.yaml config.yaml
# Edit config.yaml with:
#   - Your homeserver URL
#   - Registration settings (the bridge appears as a special user on Matrix)
#   - Discord bot token

# 3. Generate the registration file for your homeserver
python -m mautrix_discord -g

# 4. Add the registration file to your homeserver's app service config
# In Synapse's homeserver.yaml:
# app_service_config_files:
#   - /path/to/discord-registration.yaml

# 5. Run the bridge
python -m mautrix_discord

Once running, users can interact with the bridge bot to link their Discord accounts and bridge specific channels into Matrix rooms. The bridge handles message translation, attachment conversion, and even reaction mapping between platforms.

Best Practices for Matrix Development

1. Token Management and Security

Access tokens grant full account access — treat them like passwords. Never hardcode tokens in client-side code or commit them to version control. Use environment variables or secure credential stores. Implement token refresh logic; tokens can expire or be revoked server-side.

// Good: Load from environment
const ACCESS_TOKEN = process.env.MATRIX_ACCESS_TOKEN;

// Better: Use a secure vault service in production
const token = await secretsManager.getSecret('matrix-access-token');

2. Idempotent Message Sending

Always generate unique transaction IDs for PUT /send requests. Retrying a failed send with the same transaction ID is safe and prevents duplicate messages. Use UUIDs or a combination of timestamp and random string:

// Generate a unique transaction ID
const txnId = `msg_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const endpoint = `/rooms/${roomId}/send/m.room.message/${txnId}`;

3. Sync Loop Resilience

The sync loop is the backbone of real-time communication. Implement exponential backoff on sync failures, handle HTTP errors gracefully, and never block the sync loop with long-running operations:

let retryDelay = 1000; // Start with 1 second
const MAX_RETRY_DELAY = 300000; // Cap at 5 minutes

client.on('sync.error', async (error) => {
    console.error(`Sync error: ${error.message}. Retrying in ${retryDelay}ms...`);
    await new Promise(resolve => setTimeout(resolve, retryDelay));
    retryDelay = Math.min(retryDelay * 2, MAX_RETRY_DELAY);
    client.startClient();
});

client.on('sync.success', () => {
    // Reset retry delay on successful sync
    retryDelay = 1000;
});

4. Rate Limiting Awareness

Homeservers enforce rate limits to protect resources. Batch operations, respect 429 Too Many Requests responses with Retry-After headers, and spread bulk message sends over time rather than sending hundreds of messages simultaneously:

// Respect rate limiting with a simple delay queue
async function sendWithRateLimit(messages, roomId) {
    for (const msg of messages) {
        await client.sendMessage(roomId, msg);
        // Wait 500ms between messages to stay within limits
        await new Promise(resolve => setTimeout(resolve, 500));
    }
}

5. Encryption Key Backup

For encrypted rooms, always enable and regularly update key backup. Without it, users cannot decrypt messages received while they were offline if their device is lost. Implement the m.megolm_backup.v1 protocol:

// Enable key backup with a recovery passphrase
await client.enableKeyBackup({
    algorithm: 'm.megolm_backup.v1.curve25519-aes-sha2',
    // Store a copy of the recovery key securely
    // The user must save this to recover their message history
});

// Regularly check backup integrity
client.on('keyBackupStatus', (status) => {
    if (status.backedUpCount < status.totalCount) {
        console.warn(`Key backup lagging: ${status.backedUpCount}/${status.totalCount} keys backed up`);
    }
});

6. Client-Side Filtering

Use sync filters to reduce bandwidth and processing overhead. Filters specify which event types and rooms to include in sync responses:

const filterDefinition = {
    room: {
        timeline: {
            limit: 20,
            types: ['m.room.message', 'm.room.member', 'm.room.name']
        },
        ephemeral: {
            types: ['m.typing', 'm.receipt']
        },
        account_data: {
            types: ['m.direct']
        }
    },
    presence: {
        types: ['m.presence']
    }
};

// Upload the filter
const filterResponse = await client.uploadFilter(filterDefinition);
const filterId = filterResponse.filter_id;

// Use in sync requests
await client.startClient({ filter: { id: filterId } });

7. Error Handling and Logging

Matrix operations can fail for many reasons: network issues, server errors, invalid parameters, or permission denials. Implement comprehensive error handling:

try {
    await client.sendMessage(roomId, messageContent);
} catch (error) {
    if (error.errcode === 'M_FORBIDDEN') {
        console.error('Bot lacks permission to send messages in this room');
    } else if (error.errcode === 'M_NOT_FOUND') {
        console.error('Room not found or bot not joined');
    } else if (error.errcode === 'M_LIMIT_EXCEEDED') {
        const retryAfter = error.data?.retry_after_ms || 5000;
        console.warn(`Rate limited. Waiting ${retryAfter}ms...`);
        await new Promise(r => setTimeout(r, retryAfter));
    } else {
        console.error(`Unexpected error: ${error.message}`, error);
    }
}

8. Testing Against Multiple Homeservers

Federation means your client or bot may interact with users on different homeservers. Test against at least two homeserver implementations (Synapse, Dendrite, or Conduit) to ensure compatibility. The Matrix Federation Tester (available at federationtester.matrix.org) can help verify homeserver federation readiness.

9. Keep Up with Specification Changes

The Matrix specification evolves continuously through a formal proposal process called MSC (Matrix Spec Change). Major clients and servers implement MSCs before they become part of the official spec. Monitor the matrix-spec-proposals repository and the Matrix blog to stay informed about upcoming

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles