Build a Scalable Real-Time Chat App with Django Channels

19 min read
Raymond F
Raymond F
Published November 21, 2024

Django is approaching its twentieth anniversary. Built for the Web 1.0 era, this Python framework might seem out of place in today’s JavaScript-centric world. But (almost) anything JS can do, Django can do—and much more.

You can see the power of Django in the people who continue to use it–Instagram, Mozilla, Pinterest, Disqus—all of which are built on a foundation of Django. If it can power Instagram, it can scale to whatever you need to build, and though it is a backend framework, it is still more than capable of delivering real-time web applications.

Here, we will do exactly that: build a chat application based on Django using the Stream Python SDK. We’ll also take advantage of Django Channels, WebSockets, and webhooks to create an async, real-time chat service that will work as well as any JavaScript framework.

Using Django and Channels for Real-Time Applications

Let’s start with a quick background on Django Channels and how it will help us with this build.

In traditional Django applications, the request-response cycle is synchronous and stateless. A client makes a request, Django processes it, returns a response, and the connection closes. This model works well for standard web applications but falls short when we need real-time features like chat, notifications, or live updates.

Enter Django Channels. It extends Django's capabilities by introducing asynchronous processing and WebSocket support, making it possible to maintain persistent connections between the server and clients. Think of Channels as a layer that sits alongside Django's traditional HTTP handling, managing both WebSocket connections and regular HTTP requests in a unified way.

The Architecture: Channels, WebSockets, and Webhooks

  1. Channels. Channels introduces an ASGI (Asynchronous Server Gateway Interface) application that handles different types of protocols. It organizes communication through discrete "channels"—think of them as message queues that can handle various events. When a client connects, sends a message, or disconnects, these events flow through specific channels to be processed by your application.
  2. WebSockets. WebSockets provide the backbone for real-time communication. Unlike HTTP's request-response pattern, WebSockets maintain a persistent, full-duplex connection between the client and server. This means:
    Messages can flow in both directions without initiating new connections
    The server can push updates to clients immediately
    Lower latency compared to polling or long-polling solutions
  3. Webhooks. While WebSockets handle real-time client-server communication, webhooks serve as the bridge between your application and external services. In our chat application, we will have a webhook connection to Stream that will:
    Notify the chat app of messages from other clients
    Trigger updates from our client to connected clients

The beauty of this architecture is how these components work in concert. Channels manages the WebSocket connections and message routing, WebSockets maintain persistent connections with clients, and Webhooks handle external integrations and notifications.

Let’s show this architecture in action.

Setting up your Django project with Channels

We’ll start with the basics. Here’s how I would set up a Django project:

bash
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# Create and activate a virtual environment python -m venv env source env/bin/activate # Install Django pip install django # Create a new Django project django-admin startproject chatproject # Navigate into the project directory cd chatproject # Create a new app python manage.py startapp chatapp # Run initial migrations python manage.py migrate # Create a superuser (admin account) python manage.py createsuperuser # Start the development server python manage.py runserver

This will give us an entirely working Django project running at localhost:8000.

As we said above, using Channels requires changing how Django works from synchronous calls to asynchronous. Let’s start by installing Channels:

pip install channels["daphne"]

Here, we're installing Channels along with Daphne, which is the recommended ASGI server for running Django Channels applications in production. This package combination will handle both our HTTP and WebSocket connections. At this point, let’s install the two other main dependencies we’ll need:

pip install stream-chat channels-redis

stream-chat is Stream's Python SDK. This will work entirely within our backend code, with no Stream SDK required on the client in this implementation. Usually, the Python SDK is used alongside a client SDK to handle sensitive functionality such as creating tokens or changing user data. Still, the SDK is fully functional, able to create clients, users, and handle messages just as a frontend library.

channels-redis is used here to handle some real-time functionality of our messaging system between different application instances.

Let’s start using some of these libraries. Django will have set up several files–settings.py, views.py, urls.py, etc.–that we’ll need to edit, alongside adding a few new files. Let’s start with making some basic edits to settings.py. We want to add our “chatapp” and “daphne” to our INSTALLED_APPS:

python
1
2
3
4
5
6
7
8
9
10
11
12
# chatproject/settings.py INSTALLED_APPS = [ 'daphne', 'chatapp', 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', ]

This makes sure Django recognizes our chat application and the Daphne ASGI server. Since we're building a real-time application, we also need to configure our channel layers to handle WebSocket connections and message routing between application instances. Add these settings to your settings.py:

python
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# chatproject/settings.py # Configure the ASGI application ASGI_APPLICATION = 'chatproject.asgi.application' # Configure channel layers for real-time message handling CHANNEL_LAYERS = { 'default': { 'BACKEND': 'channels_redis.core.RedisChannelLayer', 'CONFIG': { "hosts": [('127.0.0.1', 6379)], }, }, } # Add Stream API credentials STREAM_API_KEY = 'your-api-key' STREAM_API_SECRET = 'your-api-secret'

The channel layers configuration tells Django Channels to use Redis as our backing store for real-time message passing. This is crucial for scaling our application across multiple servers. The Stream API credentials will be used to interact with Stream. In your Stream account, create a new app:

New Stream app

You can then get the app key and secret from the app dashboard.

Now let's modify our existing asgi.py file to handle both HTTP and WebSocket protocols:

python
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# chatproject/asgi.py """ ASGI config for chatproject project. It exposes the ASGI callable as a module-level variable named ``application``. For more information on this file, see https://docs.djangoproject.com/en/5.1/howto/deployment/asgi/ """ import os import chatapp.routing from channels.auth import AuthMiddlewareStack from channels.routing import ProtocolTypeRouter, URLRouter from channels.security.websocket import AllowedHostsOriginValidator from django.core.asgi import get_asgi_application os.environ.setdefault("DJANGO_SETTINGS_MODULE", "chatproject.settings") # Initialize Django ASGI application early to ensure the AppRegistry # is populated before importing code that may import ORM models. django_asgi_app = get_asgi_application() application = ProtocolTypeRouter( { "http": django_asgi_app, "websocket": AllowedHostsOriginValidator( AuthMiddlewareStack(URLRouter(chatapp.routing.websocket_urlpatterns)) ), } )

This ASGI configuration sets up our application to handle both traditional HTTP requests through Django's standard ASGI application and WebSocket connections through Channels. The ProtocolTypeRouter acts as a traffic director, routing HTTP requests to Django's standard request handler and WebSocket connections through a series of middleware (for authentication and security) before ultimately reaching our chat application's routing patterns.

Now we need to create a new file, chatapp/routing.py, to define our WebSocket URL patterns:

python
1
2
3
4
5
6
7
8
9
# chatapp/routing.py from django.urls import re_path from . import consumers websocket_urlpatterns = [ re_path(r"ws/chat/(?P<room_name>\w+)/$", consumers.ChatConsumer.as_asgi()), ]

This routing configuration defines the WebSocket URL pattern for our chat rooms. The pattern ws/chat/(?P\w+)/$ will match WebSocket connections to URLs like ws://domain/ws/chat/room1/, where room1 is captured as the room_name parameter. When a connection matches this pattern, it will be handled by our ChatConsumer class, which we'll create in consumers.py in a moment.

Finally, in this setup phase, let’s modify/create the two urls.py files we need. First chatproject/urls.py:

python
1
2
3
4
5
6
7
8
9
# chatproject/urls.py from django.contrib import admin from django.urls import include, path urlpatterns = [ path('admin/', admin.site.urls), path('', include('chatapp.urls')), # Changed to capture root URL ]

This configuration routes all admin-related URLs to Django's admin interface and delegates everything else to our chat app's URL configuration, ensuring our chat application handles the main user experience.

Then, chatapp/urls.py:

python
1
2
3
4
5
6
7
8
9
# chatapp/urls.py from django.urls import path from . import views urlpatterns = [ path('chat/', views.index, name='index'), path('chat/<str:room_name>/', views.room, name='room'), path('stream_webhook/', views.stream_webhook, name='stream_webhook'), ]

Here, we define three main routes: an index page for listing available chat rooms, individual room pages that capture the room name as a URL parameter, and a webhook endpoint for receiving events from Stream's service.

With this setup done, let’s move on to the application's core logic and its integration with the Stream Python SDK.

Integrating Stream Chat with Django

There are going to be three main files we need to modify/create for the chat application to work effectively:

  1. consumers.py. This file contains our WebSocket consumer class that manages real-time connections, handles the lifecycle of WebSocket connections (connect, disconnect, receive), and coordinates message flow between clients and the Stream Chat service.
  2. room.html. This template file provides the user interface for the chat room, containing both the HTML structure and JavaScript code needed to establish WebSocket connections and display messages in real time.
  3. views.py. This Django views file handles HTTP requests, renders the chat interface, and processes webhook events from Stream; it serves as the bridge between our templates and the Stream Chat backend, handling authentication and room management.

We’ll start with consumers.py–it’s a doozy, so we’ll break it down into individual classes and functions:

python
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# consumers.py from channels.generic.websocket import AsyncWebsocketConsumer from asgiref.sync import sync_to_async import json from stream_chat import StreamChat from django.conf import settings from channels.layers import get_channel_layer import logging logger = logging.getLogger(__name__) class ChatConsumer(AsyncWebsocketConsumer): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.stream_client = StreamChat( api_key=settings.STREAM_API_KEY, api_secret=settings.STREAM_API_SECRET ) self.user_id = None self.username = None self.channel = None self.room_name = None self.room_group_name = None self.channel_layer = get_channel_layer()

This initializes our WebSocket ChatConsumer with necessary instance variables. It sets up the Stream Chat client connection and initializes empty variables for tracking the user's session state, including their ID, username, and chat room information. The channel_layer is initialized to handle message broadcasting between different instances.

Next, the connect method:

python
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
async def connect(self): try: self.room_name = self.scope['url_route']['kwargs']['room_name'] self.room_group_name = f'chat_{self.room_name}' # Generate a unique user ID for anonymous users self.user_id = self.scope["user"].id if self.scope.get("user", None) and self.scope["user"].is_authenticated else f"anon_{id(self)}" self.username = f"Anonymous_{self.user_id}" # Create or update the user in Stream await sync_to_async(self.stream_client.upsert_user)({ "id": str(self.user_id), "role": "user", "name": self.username }) # Generate Stream token token = await sync_to_async(self.stream_client.create_token)(str(self.user_id)) # Add to channel layer group await self.channel_layer.group_add( self.room_group_name, self.channel_name ) # Accept the WebSocket connection await self.accept() # Send initial connection data await self.send(text_data=json.dumps({ "type": "connection_established", "token": token, "user_id": str(self.user_id), "username": self.username })) logger.info(f"WebSocket connected for user {self.user_id} in room {self.room_name}") except Exception as e: logger.error(f"Error in connect: {str(e)}", exc_info=True) await self.close()

The connect method handles new WebSocket connections. It extracts the room name from the URL, generates a unique ID for anonymous users, creates/updates the user in Stream's system, generates an authentication token, and adds the user to the channel layer group. Finally, it sends the client's initial connection data, including their token and user information.

We also want a way to disconnect:

python
1
2
3
4
5
6
7
8
9
10
async def disconnect(self, close_code): try: if self.room_group_name: await self.channel_layer.group_discard( self.room_group_name, self.channel_name ) logger.info(f"WebSocket disconnected for user {self.user_id} with code {close_code}") except Exception as e: logger.error(f"Error in disconnect: {str(e)}", exc_info=True)

This method handles cleanup when a WebSocket connection closes. It removes the user from the channel layer group and logs the disconnection, ensuring proper resource cleanup.

We then get into the messaging itself:

Building your own app? Get early access to our Livestream or Video Calling API and launch in days!
python
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
async def receive(self, text_data): try: data = json.loads(text_data) message_type = data.get("type") logger.debug(f"Received message of type {message_type} from user {self.user_id}") if message_type == "set_username": await self.handle_set_username(data) elif message_type == "create_channel": await self.handle_create_channel() elif message_type == "send_message": await self.handle_send_message(data) else: logger.warning(f"Unknown message type received: {message_type}") except json.JSONDecodeError as e: logger.error(f"JSON decode error: {str(e)}") await self.send_error("Invalid message format") except Exception as e: logger.error(f"Error in receive: {str(e)}", exc_info=True) await self.send_error(str(e))

This is the main message router for incoming WebSocket messages. It parses the incoming JSON data and routes different types of messages (set_username, create_channel, send_message) to their appropriate handlers, with error handling for malformed messages.

We also want a way for users to set usernames in Stream:

python
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
async def handle_set_username(self, data): try: self.username = data.get("username") self.user_id = self.username # Update user_id to match username # Update user in Stream await sync_to_async(self.stream_client.upsert_user)({ "id": str(self.user_id), "role": "user", "name": self.username }) # Generate new token token = await sync_to_async(self.stream_client.create_token)(str(self.user_id)) await self.send(text_data=json.dumps({ "type": "username_set", "username": self.username, "token": token })) logger.info(f"Username set to {self.username} for user {self.user_id}") except Exception as e: logger.error(f"Error setting username: {str(e)}", exc_info=True) await self.send_error("Failed to set username")

This method handles username updates. It updates the username locally and in Stream's system, generates a new authentication token for the user, and sends confirmation back to the client.

Now, let’s create a channel:

python
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
async def handle_create_channel(self): try: # Initialize Stream channel self.channel = self.stream_client.channel("messaging", self.room_name) # Create the channel with minimal data first await sync_to_async(self.channel.create)(str(self.user_id)) # Add members after creation await sync_to_async(self.channel.add_members)([str(self.user_id)]) # Query channel history response = await sync_to_async(self.channel.query)( state=True, messages={"limit": 50} ) # Send historical messages for message in response["messages"]: await self.send(text_data=json.dumps({ "type": "message_received", "message": message })) # Confirm channel creation await self.send(text_data=json.dumps({ "type": "channel_created", "channel_id": self.room_name })) logger.info(f"Channel {self.room_name} created successfully") except Exception as e: logger.error(f"Error creating channel: {str(e)}", exc_info=True) await self.send_error("Failed to create channel")

This method initializes a new chat channel in Stream. It creates the channel, adds the current user as a member, retrieves recent message history, and sends this history back to the client along with confirmation of channel creation.

Now, we can send some messages!

python
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
async def handle_send_message(self, data): try: if not self.channel: self.channel = self.stream_client.channel("messaging", self.room_name) message = { "text": data.get("message"), "user": { "id": str(self.user_id), "name": self.username }, "is_local": True # Add a flag to identify local messages } response = await sync_to_async(self.channel.send_message)( message, str(self.user_id) ) logger.info(f"Message sent by {self.username} in channel {self.room_name}") except Exception as e: logger.error(f"Error sending message: {str(e)}", exc_info=True) await self.send_error("Failed to send message")

This method handles new chat messages. It ensures a channel exists, formats the message with user information, sends it to Stream's service, and includes a flag to identify locally generated messages.

Then broadcast our message:

python
1
2
3
4
5
6
7
8
9
10
async def chat_message(self, event): try: message = event['message'] await self.send(text_data=json.dumps({ 'type': 'message_received', 'message': message })) logger.debug(f"Broadcasted message in room {self.room_name}") except Exception as e: logger.error(f"Error in chat_message: {str(e)}", exc_info=True)

This method broadcasts messages to all connected clients in the same room. It takes a message event and sends it to all WebSocket connections in the channel group.
Finally, in case anything goes wrong:

python
1
2
3
4
5
async def send_error(self, message): await self.send(text_data=json.dumps({ "type": "error", "message": message }))

This utility method sends error messages back to the client in a standardized format.
Let’s go through our room.html to see how that works alongside the consumer. Again, it’s pretty big, so we’ll break it down.

First, the actual html:

javascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<!-- chat/templates/chat/room.html --> <!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <title>Chat Room</title> </head> <body> <div id="chat-container"> <div id="username-container"> <input id="username-input" type="text" placeholder="Enter your username" /> <button id="set-username-button">Set Username</button> </div> <div id="chat-log" style=" height: 400px; overflow-y: auto; border: 1px solid #ccc; margin-bottom: 10px; padding: 10px; " ></div> <div id="chat-input-container"> <input id="chat-message-input" type="text" size="100" /> <button id="chat-message-submit">Send</button> </div> </div>

This HTML structure creates a simple chat interface with three main components: a username section where users can set their display name, a chat log area that displays messages in a container, and an input section at the bottom for typing and sending new messages.

Though this is an html file, most of it is the JS required to connect and add event listeners. First, the connection:

javascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
<script> let chatSocket = null; let streamToken = null; let username = null; // Initialize WebSocket connection function connectWebSocket() { const roomName = "{{ room_name }}"; const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:"; chatSocket = new WebSocket( `${wsProtocol}//${window.location.host}/ws/chat/${roomName}/` ); chatSocket.onopen = function (e) { console.log("WebSocket connection established"); }; chatSocket.onmessage = function (e) { const data = JSON.parse(e.data); if (data.type === "connection_established") { // Store the Stream token streamToken = data.token; console.log("Received Stream token:", streamToken); // Join or create channel sendWebSocketMessage({ type: "create_channel", channel_id: "lobby", }); } else if (data.type === "message_sent") { // Handle sent messages appendMessage(data.message); } else if (data.type === "message_received") { // Handle received messages appendMessage(data.message); } else if (data.type === "username_set") { username = data.username; streamToken = data.token; // Update the stream token console.log("Username set:", username); } else if (data.type === "error") { console.error("Error:", data.message); } }; chatSocket.onclose = function (e) { console.error("Chat socket closed unexpectedly"); // Optionally implement reconnection logic here setTimeout(connectWebSocket, 3000); }; }

This section initializes the WebSocket connection and handles different types of messages. It sets up the WebSocket URL based on the current protocol (ws/wss), establishes the connection, and defines handlers for connection events.

The onmessage handler processes different messages, including connection establishment, message sending/receiving, username updates, and errors.

Now we need a function to append new messages:

javascript
1
2
3
4
5
6
7
function appendMessage(message) { const chatLog = document.getElementById("chat-log"); const messageDiv = document.createElement("div"); messageDiv.textContent = `${message.user.name}: ${message.text}`; chatLog.appendChild(messageDiv); chatLog.scrollTop = chatLog.scrollHeight; }

This function handles the display of new messages in the chat interface. It creates a new div element for each message, sets its content to include the sender's name and message text, adds it to the chat log, and automatically scrolls to the latest message.

Then, we want to send new messages to the WebSocket:

javascript
1
2
3
4
5
6
7
function sendWebSocketMessage(message) { if (chatSocket && chatSocket.readyState === WebSocket.OPEN) { chatSocket.send(JSON.stringify(message)); } else { console.error("WebSocket is not connected"); } }

This utility handles sending messages through the WebSocket connection. It checks if the connection is open before sending and stringifies the message object.

We also need to set up a bunch of event listeners to get the data:

javascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// Set up event listeners document.addEventListener("DOMContentLoaded", function () { const usernameInput = document.getElementById("username-input"); const setUsernameButton = document.getElementById( "set-username-button" ); const messageInput = document.getElementById("chat-message-input"); const submitButton = document.getElementById("chat-message-submit"); setUsernameButton.addEventListener("click", function () { const newUsername = usernameInput.value.trim(); if (newUsername) { sendWebSocketMessage({ type: "set_username", username: newUsername, }); } }); messageInput.addEventListener("keypress", function (e) { if (e.key === "Enter") { submitButton.click(); } }); submitButton.addEventListener("click", function () { const message = messageInput.value.trim(); if (message) { if (!username) { alert("Please set your username first!"); return; } sendWebSocketMessage({ type: "send_message", message: message, username: username, // Add username to the message }); messageInput.value = ""; } });

This section sets up all the user interaction handlers. It includes event listeners for setting a username, sending messages, and input validation to ensure a username is set before sending messages.

Finally, let’s call our function and close everything out:

javascript
1
2
3
4
5
6
// Initialize WebSocket connection connectWebSocket(); }); </script> </body> </html>

This is a lot of code. I promise we’re almost there. Just the views.py to go. We only need to break this down into two parts:

python
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# views.py from django.shortcuts import render from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_http_methods from django.conf import settings from django.utils.decorators import method_decorator from django.http import HttpResponse, HttpResponseForbidden from asgiref.sync import async_to_sync from channels.layers import get_channel_layer import json import logging import hmac import hashlib def index(request): return render(request, "chat/index.html") def room(request, room_name): return render(request, "chat/room.html", {"room_name": room_name})

This first part is simple–many imports, then two basic view functions: one to render the main chat index page and another to render individual chat rooms, passing the room_name from the URL to the template.

The next part is where the magic happens:

python
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
class CSRFExemptMixin(object): @method_decorator(csrf_exempt) def dispatch(self, *args, **kwargs): return super().dispatch(*args, **kwargs) @csrf_exempt @require_http_methods(["POST"]) def stream_webhook(request): # Verify Stream.io webhook signature signature = request.headers.get('X-Signature') api_key = request.headers.get('X-Api-Key') webhook_id = request.headers.get('X-Webhook-Id') if not all([signature, api_key, webhook_id]): return HttpResponseForbidden("Missing required headers") try: # Get the raw body raw_body = request.body # Calculate expected signature expected_signature = hmac.new( settings.STREAM_API_SECRET.encode(), raw_body, hashlib.sha256 ).hexdigest() # Compare signatures using constant-time comparison if not hmac.compare_digest(signature, expected_signature): return HttpResponseForbidden("Invalid signature") # Process the webhook data data = json.loads(raw_body) logger.info(f"Webhook data: {data}") event_type = data.get('type') if event_type == 'message.new': message = data['message'] channel_id = message['cid'] room_name = channel_id.split(':')[1] channel_layer = get_channel_layer() async_to_sync(channel_layer.group_send)( f'chat_{room_name}', { 'type': 'chat_message', 'message': message } ) return HttpResponse(status=200) except Exception as e: return HttpResponse(status=500)

This sets up our webhook for interacting with Stream. Without this, Stream would work in terms of storing users and message history, but it wouldn’t be real-time–you’d have to refresh the page to see each new message. Here, we're creating a secure endpoint that receives real-time notifications from Stream whenever a new message is sent.

The code first verifies the webhook's authenticity using HMAC signatures, then processes incoming messages by broadcasting them to all connected clients in the appropriate chat room using Django Channels' group_send functionality. The CSRFExemptMixin is necessary because Stream's webhooks can't provide CSRF tokens, but we maintain security through signature verification instead. This creates the bridge between Stream's service and our real-time WebSocket connections, ensuring all connected clients receive messages instantly.

Two things here. First, we’re developing all this locally, but Stream needs to access this webhook, and localhost:8000/stream-webhook is inaccessible to the outside world. Thus we need a tunneling tool such as ngrok. Install ngrok, then run:

ngrok http 8000

This will then give you a URL that anyone (or any service like Stream) can use to access your local deployment. Head back to your app dashboard in Stream and add this webhook URL to your account:

Webhook URL

Secondly, Django likes to lock down its services tightly–which is excellent! But sometimes you need to bypass some of the security features and add your own (as we did in our views above). To work with the webhook, you might need to add this to settings.py:

python
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
ALLOWED_HOSTS = [ 'localhost', '127.0.0.1', '.ngrok-free.app', # Allows all subdomains of ngrok-free.app '34.225.10.29', '34.198.125.61', '52.22.78.160', '3.215.161.238', ] MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', # Keep this but we'll add exemptions 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', # May need to remove ] # Update security settings CSRF_TRUSTED_ORIGINS = [ 'https://*.ngrok-free.app', 'http://localhost:8000', 'http://127.0.0.1:8000', 'https://34.225.10.29', 'https://34.198.125.61', 'https://52.22.78.160', 'https://3.215.161.238', ] # Modify these security settings for webhook compatibility CSRF_COOKIE_SECURE = False if DEBUG else True CSRF_COOKIE_HTTPONLY = False CSRF_USE_SESSIONS = False CSRF_COOKIE_NAME = 'csrftoken' CSRF_HEADER_NAME = 'HTTP_X_CSRFTOKEN' # Add this to allow missing Referer header CSRF_TRUSTED_ORIGINS_ONLY = False CSRF_REFERER_REQUIRED = False

The additional IP addresses are Stream addresses that allow us to restrict access to just those servers. Check out more about Stream webhooks.

And we’re done. If you haven’t kept the server up and running and hot reloading like hell, then run:

python manage.py runserver

Now, you can head to either localhost:8000/chat or your ngrok URL, create a room, and start chatting (to yourself):

Building Real-Time Chat With Stream, Channels, & Django

OK, there is a lot of code on this page. But with this, we’ve built something that usually only has the purview of JavaScript–real-time, async chat. JS is involved, but the heavy lifting is all Python, with help from Channels and Stream.

This isn't the easiest way to create real-time chat, but it should show you the possibilities. With this foundation, you can extend the functionality to include features like typing indicators, message reactions, or file uploads. And perhaps most importantly, it demonstrates that Python and Django remain relevant and powerful tools for modern, real-time web applications, even two decades after Django's initial release.

Integrating Video With Your App?
We've built a Video and Audio solution just for you. Check out our APIs and SDKs.
Learn more ->