LatteStream®
Quick Start
Getting Started
Building Custom SDKs
SDKs & Libraries
JavaScript/TypeScript
Node.js / Bun / Deno
Python
Go
PHP
API Reference
WebSocket API
REST API
Webhooks
Authentication
Server-side event notifications for real-time applications
Webhooks allow your server to receive notifications about events happening in your LatteStream channels. This enables you to track user activity, persist messages, trigger business logic, and integrate with other systems.
LatteStream webhooks deliver HTTP POST requests to your server when specific events occur:
Benefits:
member_addedTriggered when a user joins a presence channel.
Payload:
{ "time_ms": 1674123456789, "events": [ { "name": "member_added", "channel": "presence-room-1", "user_id": "user123" } ] }
Use Cases:
member_removedTriggered when a user leaves a presence channel.
Payload:
{ "time_ms": 1674123456789, "events": [ { "name": "member_removed", "channel": "presence-room-1", "user_id": "user123" } ] }
Use Cases:
channel_occupiedTriggered when the first subscriber joins a previously empty channel.
Payload:
{ "time_ms": 1674123456789, "events": [ { "name": "channel_occupied", "channel": "chat-room-1" } ] }
Use Cases:
channel_vacatedTriggered when the last subscriber leaves a channel.
Payload:
{ "time_ms": 1674123456789, "events": [ { "name": "channel_vacated", "channel": "chat-room-1" } ] }
Use Cases:
subscription_countTriggered when the subscription count changes for a channel.
Payload:
{ "time_ms": 1674123456789, "events": [ { "name": "subscription_count", "channel": "chat-room-1", "subscription_count": 42 } ] }
Use Cases:
client_eventTriggered when a client sends a custom event on a private or presence channel.
Payload:
{ "time_ms": 1674123456789, "events": [ { "name": "client_event", "channel": "private-chat", "event": "client-message", "data": "{\"text\":\"Hello, world!\",\"user\":\"Alice\"}", "socket_id": "123.456", "user_id": "user123" } ] }
Use Cases:
Note: data is a JSON string that needs to be parsed.
Configure webhooks using environment variables in your LatteStream deployment:
# Enable webhooks LATTESTREAM_WEBHOOK_ENABLED=true # Your webhook endpoint URL LATTESTREAM_WEBHOOK_URL=https://your-app.com/webhooks/lattestream # Secret key for signature verification LATTESTREAM_WEBHOOK_SECRET=your_webhook_secret_key # Comma-separated list of events to send LATTESTREAM_WEBHOOK_EVENTS=member_added,member_removed,client_event # Batching configuration LATTESTREAM_WEBHOOK_BATCH_TIMEOUT_MS=1000 LATTESTREAM_WEBHOOK_MAX_BATCH_SIZE=10 # Retry configuration LATTESTREAM_WEBHOOK_RETRY_ATTEMPTS=3
Subscribe only to events you need by setting LATTESTREAM_WEBHOOK_EVENTS:
# All member events only LATTESTREAM_WEBHOOK_EVENTS=member_added,member_removed # All channel events only LATTESTREAM_WEBHOOK_EVENTS=channel_occupied,channel_vacated,subscription_count # Client events only LATTESTREAM_WEBHOOK_EVENTS=client_event # All events (not recommended - high volume) LATTESTREAM_WEBHOOK_EVENTS=member_added,member_removed,channel_occupied,channel_vacated,subscription_count,client_event
Webhooks are batched to reduce HTTP overhead and improve performance:
WEBHOOK_BATCH_TIMEOUT_MS (default: 1000ms)WEBHOOK_MAX_BATCH_SIZE events per batch (default: 10)Example batch:
{ "time_ms": 1674123456789, "events": [ { "name": "member_added", "channel": "presence-room-1", "user_id": "user123" }, { "name": "client_event", "channel": "private-chat", "event": "client-message", "data": "{\"text\":\"Hello\"}", "socket_id": "123.456" }, { "name": "member_removed", "channel": "presence-room-1", "user_id": "user456" } ] }
All webhooks include an HMAC-SHA256 signature for verification:
Header: X-LatteStream-Signature
Algorithm: HMAC-SHA256
Input: Raw request body (JSON string)
Secret: Your WEBHOOK_SECRET
Node.js (Express):
const crypto = require('crypto'); const express = require('express'); const app = express(); app.post('/webhooks/lattestream', express.raw({ type: 'application/json' }), (req, res) => { const signature = req.headers['x-lattestream-signature']; const body = req.body; // Raw buffer // Compute HMAC signature const expectedSignature = crypto .createHmac('sha256', process.env.WEBHOOK_SECRET) .update(body) .digest('hex'); // Verify signature if (signature !== expectedSignature) { console.error('Invalid webhook signature'); return res.status(401).send('Unauthorized'); } // Parse and process webhook const webhook = JSON.parse(body.toString()); processWebhook(webhook); res.status(200).send('OK'); });
Python (Flask):
import hmac import hashlib import json from flask import Flask, request app = Flask(__name__) WEBHOOK_SECRET = 'your_webhook_secret' @app.route('/webhooks/lattestream', methods=['POST']) def webhook(): signature = request.headers.get('X-LatteStream-Signature') body = request.get_data() # Compute HMAC signature expected_signature = hmac.new( WEBHOOK_SECRET.encode('utf-8'), body, hashlib.sha256 ).hexdigest() # Verify signature if signature != expected_signature: return 'Unauthorized', 401 # Parse and process webhook webhook = json.loads(body) process_webhook(webhook) return 'OK', 200
Go:
package main import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "io" "net/http" ) const webhookSecret = "your_webhook_secret" func webhookHandler(w http.ResponseWriter, r *http.Request) { signature := r.Header.Get("X-LatteStream-Signature") body, _ := io.ReadAll(r.Body) // Compute HMAC signature mac := hmac.New(sha256.New, []byte(webhookSecret)) mac.Write(body) expectedSignature := hex.EncodeToString(mac.Sum(nil)) // Verify signature if signature != expectedSignature { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } // Parse and process webhook var webhook map[string]interface{} json.Unmarshal(body, &webhook) processWebhook(webhook) w.WriteHeader(http.StatusOK) }
PHP:
<?php $webhookSecret = 'your_webhook_secret'; $signature = $_SERVER['HTTP_X_LATTESTREAM_SIGNATURE']; $body = file_get_contents('php://input'); // Compute HMAC signature $expectedSignature = hash_hmac('sha256', $body, $webhookSecret); // Verify signature if ($signature !== $expectedSignature) { http_response_code(401); echo 'Unauthorized'; exit; } // Parse and process webhook $webhook = json_decode($body, true); processWebhook($webhook); http_response_code(200); echo 'OK';
DO:
DON'T:
const express = require('express'); const { createClient } = require('@supabase/supabase-js'); const app = express(); const supabase = createClient(process.env.SUPABASE_URL, process.env.SUPABASE_KEY); app.post('/webhooks/lattestream', express.raw({ type: 'application/json' }), async (req, res) => { // Verify signature (see Security section) if (!verifySignature(req)) { return res.status(401).send('Unauthorized'); } const webhook = JSON.parse(req.body.toString()); for (const event of webhook.events) { if (event.name === 'client_event' && event.event === 'client-message') { const data = JSON.parse(event.data); // Save message to database await supabase.from('messages').insert({ channel: event.channel, user_id: event.user_id, message: data.text, created_at: new Date(webhook.time_ms) }); } } res.status(200).send('OK'); });
app.post('/webhooks/lattestream', async (req, res) => { if (!verifySignature(req)) { return res.status(401).send('Unauthorized'); } const webhook = JSON.parse(req.body.toString()); for (const event of webhook.events) { if (event.name === 'member_added') { // User joined presence channel await redis.sadd(`online:${event.channel}`, event.user_id); await redis.hset(`presence:${event.user_id}`, 'joined_at', webhook.time_ms); } if (event.name === 'member_removed') { // User left presence channel await redis.srem(`online:${event.channel}`, event.user_id); const joinedAt = await redis.hget(`presence:${event.user_id}`, 'joined_at'); const duration = webhook.time_ms - parseInt(joinedAt); // Log session duration console.log(`User ${event.user_id} was online for ${duration}ms`); } } res.status(200).send('OK'); });
const { Expo } = require('expo-server-sdk'); const expo = new Expo(); app.post('/webhooks/lattestream', async (req, res) => { if (!verifySignature(req)) { return res.status(401).send('Unauthorized'); } const webhook = JSON.parse(req.body.toString()); for (const event of webhook.events) { if (event.name === 'client_event' && event.event === 'client-message') { const data = JSON.parse(event.data); // Get offline users in channel const offlineUsers = await getOfflineUsersInChannel(event.channel); // Send push notifications const messages = offlineUsers.map(user => ({ to: user.pushToken, sound: 'default', title: `New message in ${event.channel}`, body: data.text, data: { channel: event.channel } })); await expo.sendPushNotificationsAsync(messages); } } res.status(200).send('OK'); });
const Filter = require('bad-words'); const filter = new Filter(); app.post('/webhooks/lattestream', async (req, res) => { if (!verifySignature(req)) { return res.status(401).send('Unauthorized'); } const webhook = JSON.parse(req.body.toString()); for (const event of webhook.events) { if (event.name === 'client_event' && event.event === 'client-message') { const data = JSON.parse(event.data); // Check for inappropriate content if (filter.isProfane(data.text)) { // Flag message for review await flagMessage({ channel: event.channel, user_id: event.user_id, message: data.text, reason: 'profanity' }); // Optionally: kick user from channel await kickUserFromChannel(event.user_id, event.channel); } } } res.status(200).send('OK'); });
If your webhook endpoint returns a non-200 status code or times out, LatteStream will retry:
Retry Configuration:
WEBHOOK_RETRY_ATTEMPTS (default: 3)Retry Schedule:
Idempotency Recommendation:
Since webhooks may be retried, implement idempotency to handle duplicate events:
const processedEvents = new Set(); app.post('/webhooks/lattestream', async (req, res) => { const webhook = JSON.parse(req.body.toString()); for (const event of webhook.events) { // Generate unique event ID const eventId = `${event.name}:${event.channel}:${event.user_id}:${webhook.time_ms}`; // Skip if already processed if (processedEvents.has(eventId)) { continue; } // Process event await processEvent(event); // Mark as processed processedEvents.add(eventId); } res.status(200).send('OK'); });
1. Install ngrok:
npm install -g ngrok
2. Start your local server:
node server.js # Running on http://localhost:3000
3. Expose local server to internet:
ngrok http 3000
4. Use ngrok URL in webhook configuration:
LATTESTREAM_WEBHOOK_URL=https://abc123.ngrok.io/webhooks/lattestream
// test-webhook.js const crypto = require('crypto'); const fetch = require('node-fetch'); const webhookUrl = 'http://localhost:3000/webhooks/lattestream'; const webhookSecret = 'your_webhook_secret'; const payload = { time_ms: Date.now(), events: [ { name: 'client_event', channel: 'private-chat', event: 'client-message', data: JSON.stringify({ text: 'Test message' }), socket_id: '123.456', user_id: 'user123' } ] }; const body = JSON.stringify(payload); const signature = crypto .createHmac('sha256', webhookSecret) .update(body) .digest('hex'); fetch(webhookUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-LatteStream-Signature': signature }, body: body }) .then(res => console.log('Status:', res.status)) .catch(err => console.error('Error:', err));
Check:
LATTESTREAM_WEBHOOK_ENABLED=true is setLATTESTREAM_WEBHOOK_URL is correct and accessibleLATTESTREAM_WEBHOOK_EVENTS includes the events you expectCheck:
WEBHOOK_SECRET matches on both sidesCheck:
Next Steps: Implement webhook handling in your application and explore the SDK Implementation Guide.