External Notification Display System

Overview

This guide walks you through implementing a real-time notification system that displays alerts (weather notifications, school closures, etc.) on an external website. The system uses acre Access Control’s NotificationInfo objects and MQTT event streaming to instantly display notifications as they are created—without polling.

Architecture

┌─────────────────────┐       ┌──────────────────────────┐       ┌─────────────────────┐
│   acre Application  │       │    acre API & MQTT       │       │   External Website  │
│                     │       │                          │       │                     │
│  Admin creates      │──────▶│  1. Stores Notification  │       │                     │
│  NotificationInfo   │       │  2. Publishes ObjectMod  │──────▶│  Displays alert     │
│  with Start/End     │       │     event via MQTT       │       │  in real-time       │
│  dates              │       │                          │       │                     │
└─────────────────────┘       └──────────────────────────┘       └─────────────────────┘

How It Works

  1. Administrator creates a NotificationInfo in the acre application with a description (the message), start date, and end date
  2. acre API automatically publishes an “Object Modified” event via MQTT when the notification is created
  3. Your website listens for these events via WebSocket MQTT, filters for NotificationInfo objects, and displays the notification to users

Key Benefits

  • No polling required - Instant updates via MQTT push
  • Resource efficient - Single persistent WebSocket connection
  • Real-time - Notifications appear within milliseconds of creation
  • Secure - OAuth 2.0 authentication, TLS encryption

Prerequisites

Required Information

Item Description Example
API Base URL Your region’s API endpoint https://api.us.acresecurity.cloud
Username Service account username notifications-service@yourcompany.com
Password Service account password ••••••••
Instance The instance/folder for notifications YourCompanyInstance

Required Packages

C# (.NET 8+)

dotnet add package MQTTnet
dotnet add package MQTTnet.Extensions.ManagedClient
dotnet add package MongoDB.Bson
dotnet add package Feenics.Keep.WebApi.Model.Standard
dotnet add package Feenics.Keep.WebApi.Wrapper.Standard

JavaScript/TypeScript

npm install mqtt
# For browser: mqtt.js works natively with WebSocket

Other frameworks and platforms will have their own requirements


NotificationInfo Object Reference

The NotificationInfo object contains the notification data you’ll display:

Property Type Description
Key string Unique identifier for the notification
CommonName string Short title/name of the notification
Description string The notification message to display
Priority int Priority level (lower = higher priority, default: 25)
StartDate DateTime? When the notification becomes active
EndDate DateTime? When the notification expires
IsMarkdown bool Whether the Description contains Markdown
IncludeChildInstances bool Whether to show in child instances

Example Partial Notification object (as created in acre application)

{
  "CommonName": "Weather Alert",
  "Description": "School closures due to severe weather. All facilities closed until further notice.",
  "Priority": 10,
  "StartDate": "2026-01-23T08:00:00Z",
  "EndDate": "2026-01-24T18:00:00Z",
  "IsMarkdown": false,
  "IncludeChildInstances": true
}

Step 1: Authentication

Initial Login (OAuth 2.0)

First, authenticate with the API to obtain an access token.

C# Example

using Feenics.Keep.WebApi.Wrapper;

public class NotificationService
{
    private Client _client;
    private readonly string _apiUrl = "https://api.us.acresecurity.cloud";
    private readonly string _username = "{{YOUR_SERVICE_ACCOUNT_USERNAME}}";
    private readonly string _password = "{{YOUR_SERVICE_ACCOUNT_PASSWORD}}";
    private readonly string _instanceName = "{{YOUR_INSTANCE_NAME}}";
    
    public async Task<bool> AuthenticateAsync()
    {
        _client = new Client(_apiUrl, null, "CHSNotificationDisplayService/1.0");
        
        var (success, message) = await _client.LoginAsync(
            _instanceName,
            _username,
            _password
        );
        
        if (success)
        {
            Console.WriteLine($"✓ Authenticated successfully");
            Console.WriteLine($"  Token expires: {_client.TokenResponse.ExpiresUtc}");
            return true;
        }
        
        Console.WriteLine($"✗ Authentication failed: {message}");
        return false;
    }
    
    // Access token for MQTT authentication
    public string AccessToken => _client.TokenResponse.access_token;
    
    // Get instance key from current instance
    public async Task<string> GetInstanceKeyAsync()
    {
        var instance = await _client.GetCurrentInstanceAsync();
        return instance.Key;
    }
}

JavaScript Example

class NotificationService {
    constructor() {
        this.apiUrl = 'https://api.us.acresecurity.cloud';
        this.username = '{{YOUR_SERVICE_ACCOUNT_USERNAME}}';
        this.password = '{{YOUR_SERVICE_ACCOUNT_PASSWORD}}';
        this.instanceName = '{{YOUR_INSTANCE_NAME}}';
        this.tokenResponse = null;
        this.instanceKey = null;
    }

    async authenticate() {
        // Note: The /token endpoint expects username in format: instance\username
        const response = await fetch(`${this.apiUrl}/token`, {
            method: 'POST',
            headers: {
                'Content-Type': 'application/x-www-form-urlencoded',
            },
            body: new URLSearchParams({
                grant_type: 'password',
                client_id: 'consoleApp',
                client_secret: 'consoleSecret',
                username: `${this.instanceName}\\${this.username}`,
                password: this.password,
                instance: this.instanceName
            })
        });

        if (!response.ok) {
            throw new Error(`Authentication failed: ${response.status}`);
        }

        this.tokenResponse = await response.json();
        console.log('✓ Authenticated successfully');
        console.log(`  Token expires in: ${this.tokenResponse.expires_in} seconds`);
        
        // Fetch the instance key from the API
        await this.fetchInstanceKey();
        
        return true;
    }

    async fetchInstanceKey() {
        const response = await fetch(`${this.apiUrl}/api`, {
            headers: {
                'Authorization': `Bearer ${this.tokenResponse.access_token}`
            }
        });
        
        if (!response.ok) {
            throw new Error(`Failed to get current instance: ${response.status}`);
        }
        
        const instance = await response.json();
        this.instanceKey = instance.Key;
        console.log(`  Instance Key: ${this.instanceKey}`);
    }

    get accessToken() {
        return this.tokenResponse?.access_token;
    }
}

Step 2: Token Refresh (Every 6 Hours)

Access tokens expire after 24 hours, but you should refresh proactively every 6 hours to ensure uninterrupted service for long running applications and workflows.

C# Example - Token Refresh Loop

public class TokenRefreshService
{
    private readonly Client _client;
    private readonly string _instanceName;
    private readonly string _username;
    private readonly string _password;
    private CancellationTokenSource _cts;
    
    public event Action OnTokenRefreshed;
    
    public async Task StartRefreshLoopAsync(CancellationToken cancellationToken)
    {
        _cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
        
        while (!_cts.Token.IsCancellationRequested)
        {
            try
            {
                // Wait 6 hours before refreshing
                var refreshInterval = TimeSpan.FromHours(6);
                
                // Calculate time until we should refresh
                var tokenExpiry = _client.TokenResponse.ExpiresUtc;
                var timeUntilExpiry = tokenExpiry - DateTime.UtcNow;
                
                // Refresh if less than 6 hours remaining, or wait
                if (timeUntilExpiry > refreshInterval)
                {
                    var waitTime = timeUntilExpiry - refreshInterval;
                    Console.WriteLine($"Token refresh scheduled in {waitTime.TotalHours:F1} hours");
                    await Task.Delay(waitTime, _cts.Token);
                }
                
                // Attempt refresh
                await RefreshTokenAsync();
            }
            catch (OperationCanceledException)
            {
                break;
            }
            catch (Exception ex)
            {
                Console.WriteLine($"Token refresh error: {ex.Message}");
                // Wait 5 minutes before retry
                await Task.Delay(TimeSpan.FromMinutes(5), _cts.Token);
            }
        }
    }
    
    private async Task RefreshTokenAsync()
    {
        try
        {
            await _client.RefreshAsync();
            Console.WriteLine($"✓ Token refreshed. New expiry: {_client.TokenResponse.ExpiresUtc}");
            OnTokenRefreshed?.Invoke();
        }
        catch (FailedOutcomeException ex) when (ex.HttpStatus == System.Net.HttpStatusCode.BadRequest)
        {
            // Refresh token expired - need full re-authentication
            Console.WriteLine("Refresh token expired, re-authenticating...");
            var (success, _) = await _client.LoginAsync(_instanceName, _username, _password);
            
            if (success)
            {
                Console.WriteLine("✓ Re-authenticated successfully");
                OnTokenRefreshed?.Invoke();
            }
            else
            {
                throw new Exception("Re-authentication failed");
            }
        }
    }
}

JavaScript Example - Token Refresh

class TokenRefreshService {
    constructor(notificationService) {
        this.service = notificationService;
        this.refreshIntervalMs = 6 * 60 * 60 * 1000; // 6 hours
        this.refreshTimer = null;
        this.onTokenRefreshed = null;
    }

    start() {
        this.scheduleRefresh();
    }

    stop() {
        if (this.refreshTimer) {
            clearTimeout(this.refreshTimer);
            this.refreshTimer = null;
        }
    }

    scheduleRefresh() {
        // Calculate time until 6 hours before expiry
        const expiresIn = this.service.tokenResponse.expires_in * 1000;
        const refreshIn = Math.max(expiresIn - this.refreshIntervalMs, 60000); // At least 1 minute

        console.log(`Token refresh scheduled in ${(refreshIn / 3600000).toFixed(1)} hours`);

        this.refreshTimer = setTimeout(() => this.refreshToken(), refreshIn);
    }

    async refreshToken() {
        try {
            const response = await fetch(`${this.service.apiUrl}/token`, {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/x-www-form-urlencoded',
                },
                body: new URLSearchParams({
                    grant_type: 'refresh_token',
                    refresh_token: this.service.tokenResponse.refresh_token,
                    client_id: 'consoleApp',
                    client_secret: 'consoleSecret'
                })
            });

            if (!response.ok) {
                // Refresh failed - try full re-authentication
                console.log('Refresh token expired, re-authenticating...');
                await this.service.authenticate();
            } else {
                this.service.tokenResponse = await response.json();
                console.log('✓ Token refreshed');
            }

            // Notify listeners (e.g., to reconnect MQTT)
            if (this.onTokenRefreshed) {
                this.onTokenRefreshed();
            }

            // Schedule next refresh
            this.scheduleRefresh();
        } catch (error) {
            console.error('Token refresh failed:', error);
            // Retry in 5 minutes
            this.refreshTimer = setTimeout(() => this.refreshToken(), 5 * 60 * 1000);
        }
    }
}

Step 3: Get MQTT Endpoint from SysInfo

The MQTT endpoint is dynamically retrieved from the API’s /api/sysinfo endpoint.

C# Example

public async Task<string> GetMqttEndpointAsync()
{
    var sysInfo = await _client.GetSysInfo();
    var mqttEndpoint = $"{sysInfo.EventPublisherUrl}/mqtt";
    
    Console.WriteLine($"✓ MQTT Endpoint: {mqttEndpoint}");
    return mqttEndpoint;
    // Example: wss://events.us.acresecurity.cloud/mqtt
}

JavaScript Example

async getMqttEndpoint() {
    const response = await fetch(`${this.apiUrl}/api/sysinfo`);
    const sysInfo = await response.json();
    const mqttEndpoint = `${sysInfo.EventPublisherUrl}/mqtt`;
    
    console.log(`✓ MQTT Endpoint: ${mqttEndpoint}`);
    return mqttEndpoint;
    // Example: wss://events.us.acresecurity.cloud/mqtt
}

Step 4: Get the Object Modified Event Type

You need the Event Type Key for “Object Modified” events to subscribe to the correct MQTT topic. The Object Modified event type is identified by its moniker: Namespace: "KeepApiEvents", Nickname: "ObjectMod".

C# Example

public async Task<string> GetObjectModifiedEventKeyAsync()
{
    var instance = await _client.GetCurrentInstanceAsync();
    
    // Get the KeepApi app by its ApiKey
    var keepApiApp = await _client.GetAppByApiKeyAsync(instance, "KeepApi");
    
    // Find the Object Modified event type using its moniker
    var objectModifiedEvent = await _client.GetEventTypeForAppByMonikerAsync(
        keepApiApp,
        new MonikerItem { Namespace = "KeepApiEvents", Nickname = "ObjectMod" }
    );
    
    if (objectModifiedEvent == null)
    {
        throw new Exception("Object Modified event type not found");
    }
    
    Console.WriteLine($"✓ Object Modified Event Key: {objectModifiedEvent.Key}");
    return objectModifiedEvent.Key;
}

JavaScript Example

async getObjectModifiedEventKey() {
    // Get all apps for the instance
    const appsResponse = await fetch(
        `${this.apiUrl}/api/f/${this.instanceKey}/apps`,
        { headers: { 'Authorization': `Bearer ${this.accessToken}` } }
    );
    const apps = await appsResponse.json();
    
    // Find the KeepApi app by iterating through apps
    const keepApiApp = apps.find(app => app.ApiKey === 'KeepApi');
    
    if (!keepApiApp) {
        throw new Error('KeepApi app not found');
    }

    // Get the Object Modified event type using its moniker
    // The moniker for Object Modified is: Namespace="KeepApiEvents", Nickname="ObjectMod"
    const eventTypeResponse = await fetch(
        `${keepApiApp.Href}/eventtypes?namespace=KeepApiEvents&nickname=ObjectMod`,
        { headers: { 'Authorization': `Bearer ${this.accessToken}` } }
    );
    
    const objectModifiedEvent = await eventTypeResponse.json();
    
    if (!objectModifiedEvent || !objectModifiedEvent.Key) {
        throw new Error('Object Modified event type not found');
    }
    
    console.log(`✓ Object Modified Event Key: ${objectModifiedEvent.Key}`);
    return objectModifiedEvent.Key;
}

Step 5: Load Initial Active Notifications

Before subscribing to MQTT for real-time updates, you should fetch any existing active notifications that are currently valid. This ensures users see all relevant notifications when the service starts or reconnects.

The GetUnreadNotificationsForUserAsync method retrieves notifications that:

  • Have not been marked as “read” by the current user
  • Can be filtered by date ranges to show only currently active notifications

API Endpoint: GET /api/f/{folderId}/userreadnotifications/getunread

Date Filter Parameters

Parameter Description Usage for Active Notifications
afterStartDate Only include notifications where StartDate > this value Usually not needed for active
beforeStartDate Only include notifications where StartDate < this value Set to DateTime.UtcNow to only show started notifications
afterEndDate Only include notifications where EndDate > this value Set to DateTime.UtcNow to exclude expired notifications
beforeEndDate Only include notifications where EndDate < this value Usually not needed for active
skip Pagination offset Optional
limit Maximum results Optional

To get currently active notifications (started but not expired), use:

  • beforeStartDate: DateTime.UtcNow - Only notifications that have already started
  • afterEndDate: DateTime.UtcNow - Only notifications that haven’t expired yet

C# Example

public async Task<IEnumerable<NotificationInfo>> GetActiveNotificationsAsync()
{
    var instance = await _client.GetCurrentInstanceAsync();
    var now = DateTime.UtcNow;
    
    // Get unread notifications that are currently active:
    // - StartDate is before now (already started)
    // - EndDate is after now (not yet expired) OR EndDate is null (no expiry)
    var activeNotifications = await _client.GetUnreadNotificationsForUserAsync(
        folder: instance,
        user: null,  // Current user
        beforeStartDate: now,  // Only notifications that have started
        beforeEndDate: null,
        skip: null,
        limit: null,
        afterStartDate: null,
        afterEndDate: now  // Only notifications that haven't expired
    );
    
    Console.WriteLine($"✓ Loaded {activeNotifications.Count()} active notifications");
    return activeNotifications;
}

JavaScript Example

async getActiveNotifications() {
    const now = new Date().toISOString();
    
    // Build query parameters for active notifications
    const params = new URLSearchParams({
        beforeStartDate: now,  // Only notifications that have started
        afterEndDate: now      // Only notifications that haven't expired
    });
    
    const response = await fetch(
        `${this.apiUrl}/api/f/${this.instanceKey}/userreadnotifications/getunread?${params}`,
        {
            headers: {
                'Authorization': `Bearer ${this.accessToken}`
            }
        }
    );
    
    if (!response.ok) {
        throw new Error(`Failed to get notifications: ${response.status}`);
    }
    
    const notifications = await response.json();
    console.log(`✓ Loaded ${notifications.length} active notifications`);
    return notifications;
}

Integration with MQTT Subscriber

Call this method when initializing and after MQTT reconnection to ensure no notifications are missed:

C# Example

// After connecting to MQTT, load initial notifications
var initialNotifications = await service.GetActiveNotificationsAsync();
foreach (var notification in initialNotifications)
{
    DisplayNotification(notification, "ACTIVE");
}

JavaScript Example

// After MQTT connects, load initial notifications
mqttClient.on('connect', async () => {
    // ... subscribe to topic ...
    
    // Load existing active notifications
    const activeNotifications = await getActiveNotifications();
    for (const notification of activeNotifications) {
        displayNotification(notification);
    }
});

Step 6: Subscribe to MQTT Events

Connect to MQTT and subscribe to the Object Modified event topic.

C# Complete MQTT Subscriber

using MQTTnet;
using MQTTnet.Client;
using MQTTnet.Extensions.ManagedClient;
using MongoDB.Bson.Serialization;
using Feenics.Keep.WebApi.Model;

public class NotificationMqttSubscriber
{
    private IManagedMqttClient _mqttClient;
    private readonly string _instanceKey;
    private readonly string _objectModifiedEventKey;
    private readonly Func<string> _getAccessToken;
    
    public event Action<NotificationInfo> OnNotificationCreated;
    public event Action<NotificationInfo> OnNotificationUpdated;
    public event Action<string> OnNotificationDeleted;
    
    public NotificationMqttSubscriber(
        string instanceKey,
        string objectModifiedEventKey,
        Func<string> getAccessToken)
    {
        _instanceKey = instanceKey;
        _objectModifiedEventKey = objectModifiedEventKey;
        _getAccessToken = getAccessToken;
    }
    
    public async Task ConnectAsync(string mqttEndpoint)
    {
        var options = new ManagedMqttClientOptionsBuilder()
            .WithAutoReconnectDelay(TimeSpan.FromSeconds(5))
            .WithClientOptions(new MqttClientOptionsBuilder()
                .WithTimeout(TimeSpan.FromSeconds(10))
                .WithKeepAlivePeriod(TimeSpan.FromSeconds(15))
                .WithCredentials(_getAccessToken(), (string)null)
                .WithWebSocketServer(o => o.WithUri(mqttEndpoint))
                .Build())
            .Build();
        
        _mqttClient = new MqttFactory().CreateManagedMqttClient();
        
        _mqttClient.ConnectedAsync += OnConnected;
        _mqttClient.DisconnectedAsync += OnDisconnected;
        _mqttClient.ApplicationMessageReceivedAsync += OnMessageReceived;
        
        await _mqttClient.StartAsync(options);
        
        // Subscribe to Object Modified events for this instance
        var topic = $"/{_instanceKey}/{_objectModifiedEventKey}";
        await _mqttClient.SubscribeAsync(new MqttTopicFilterBuilder()
            .WithTopic(topic)
            .Build());
        
        Console.WriteLine($"✓ Subscribed to topic: {topic}");
    }
    
    private Task OnConnected(MqttClientConnectedEventArgs e)
    {
        Console.WriteLine("✓ Connected to MQTT broker");
        return Task.CompletedTask;
    }
    
    private Task OnDisconnected(MqttClientDisconnectedEventArgs e)
    {
        Console.WriteLine($"⚠ Disconnected from MQTT: {e.Exception?.Message}");
        return Task.CompletedTask;
    }
    
    private Task OnMessageReceived(MqttApplicationMessageReceivedEventArgs e)
    {
        try
        {
            var payload = e.ApplicationMessage.PayloadSegment.ToArray();
            var eventMessage = BsonSerializer.Deserialize<EventMessageData>(payload);
            
            // Verify this is an Object Modified event
            if (eventMessage.EventTypeKey != _objectModifiedEventKey)
            {
                return Task.CompletedTask;
            }
            
            // Deserialize the ObjectMod event data
            var objectModData = DeserializeObjectModEventData(eventMessage.EventDataBsonBase64);
            
            // ═══════════════════════════════════════════════════════════════
            // CRITICAL FILTER: Only process NotificationInfo objects
            // ═══════════════════════════════════════════════════════════════
            if (!objectModData.Types.Contains("Notification"))
            {
                // Not a notification - ignore this event
                return Task.CompletedTask;
            }
            
            Console.WriteLine($"📢 Notification event received: {objectModData.Action}");
            
            // Handle based on action type
            switch (objectModData.Action)
            {
                case "POST": // New notification created
                    if (objectModData.AfterData is NotificationInfo newNotification)
                    {
                        Console.WriteLine($"  ✓ New: {newNotification.CommonName}");
                        OnNotificationCreated?.Invoke(newNotification);
                    }
                    break;
                    
                case "PUT": // Notification updated
                    if (objectModData.AfterData is NotificationInfo updatedNotification)
                    {
                        Console.WriteLine($"  ✓ Updated: {updatedNotification.CommonName}");
                        OnNotificationUpdated?.Invoke(updatedNotification);
                    }
                    break;
                    
                case "DELETE": // Notification deleted
                    var deletedKey = objectModData.Key;
                    Console.WriteLine($"  ✓ Deleted: {deletedKey}");
                    OnNotificationDeleted?.Invoke(deletedKey);
                    break;
            }
        }
        catch (Exception ex)
        {
            Console.WriteLine($"Error processing message: {ex.Message}");
        }
        
        return Task.CompletedTask;
    }
    
    private ObjectModEventData DeserializeObjectModEventData(string eventDataBsonBase64)
    {
        // Handle both Base64-encoded BSON and raw JSON
        try
        {
            var bytes = Convert.FromBase64String(eventDataBsonBase64);
            return BsonSerializer.Deserialize<ObjectModEventData>(bytes);
        }
        catch
        {
            // Fallback to parsing as JSON/BSON document
            var doc = MongoDB.Bson.BsonDocument.Parse(eventDataBsonBase64);
            return BsonSerializer.Deserialize<ObjectModEventData>(doc);
        }
    }
    
    public async Task DisconnectAsync()
    {
        if (_mqttClient != null)
        {
            await _mqttClient.StopAsync();
            _mqttClient.Dispose();
        }
    }
}

JavaScript Complete MQTT Subscriber (Browser)

import mqtt from 'mqtt';

class NotificationMqttSubscriber {
    constructor(instanceKey, objectModifiedEventKey, getAccessToken) {
        this.instanceKey = instanceKey;
        this.objectModifiedEventKey = objectModifiedEventKey;
        this.getAccessToken = getAccessToken;
        this.client = null;
        
        // Event callbacks
        this.onNotificationCreated = null;
        this.onNotificationUpdated = null;
        this.onNotificationDeleted = null;
    }

    connect(mqttEndpoint) {
        const options = {
            username: this.getAccessToken(),
            password: '',
            reconnectPeriod: 5000,
            connectTimeout: 10000,
            keepalive: 15
        };

        this.client = mqtt.connect(mqttEndpoint, options);

        this.client.on('connect', () => {
            console.log('✓ Connected to MQTT broker');
            
            // Subscribe to Object Modified events
            const topic = `/${this.instanceKey}/${this.objectModifiedEventKey}`;
            this.client.subscribe(topic, (err) => {
                if (err) {
                    console.error('Subscribe error:', err);
                } else {
                    console.log(`✓ Subscribed to topic: ${topic}`);
                }
            });
        });

        this.client.on('disconnect', () => {
            console.log('⚠ Disconnected from MQTT');
        });

        this.client.on('error', (error) => {
            console.error('MQTT error:', error);
        });

        this.client.on('message', (topic, payload) => {
            this.handleMessage(payload);
        });
    }

    handleMessage(payload) {
        try {
            // For browser, payload arrives as Buffer - parse as JSON
            // Note: In production, you may need BSON parsing library
            const eventMessage = JSON.parse(payload.toString());

            // Verify this is an Object Modified event
            if (eventMessage.EventTypeKey !== this.objectModifiedEventKey) {
                return;
            }

            // Parse the event data
            const objectModData = this.parseObjectModData(eventMessage.EventDataBsonBase64);

            // ═══════════════════════════════════════════════════════════════
            // CRITICAL FILTER: Only process NotificationInfo objects
            // ═══════════════════════════════════════════════════════════════
            if (!objectModData.Types || !objectModData.Types.includes('Notification')) {
                // Not a notification - ignore
                return;
            }

            console.log(`📢 Notification event: ${objectModData.Action}`);

            switch (objectModData.Action) {
                case 'POST': // New notification
                    if (objectModData.AfterData && this.onNotificationCreated) {
                        console.log(`  ✓ New: ${objectModData.AfterData.CommonName}`);
                        this.onNotificationCreated(objectModData.AfterData);
                    }
                    break;

                case 'PUT': // Updated notification
                    if (objectModData.AfterData && this.onNotificationUpdated) {
                        console.log(`  ✓ Updated: ${objectModData.AfterData.CommonName}`);
                        this.onNotificationUpdated(objectModData.AfterData);
                    }
                    break;

                case 'DELETE': // Deleted notification
                    if (this.onNotificationDeleted) {
                        console.log(`  ✓ Deleted: ${objectModData.Key}`);
                        this.onNotificationDeleted(objectModData.Key);
                    }
                    break;
            }
        } catch (error) {
            console.error('Error processing message:', error);
        }
    }

    parseObjectModData(eventDataBsonBase64) {
        // For browser, you may receive JSON string or need BSON library
        // This example assumes JSON encoding
        try {
            // Try Base64 decode first
            const decoded = atob(eventDataBsonBase64);
            return JSON.parse(decoded);
        } catch {
            // Fallback to direct JSON parse
            return JSON.parse(eventDataBsonBase64);
        }
    }

    disconnect() {
        if (this.client) {
            this.client.end();
            this.client = null;
        }
    }
}

Step 7: Display Notifications

Understanding What to Filter

The Object Modified event fires for every object change in the system. You must filter carefully:

Filter Criteria Value Why
EventTypeKey Your Object Modified event key Only Object Modified events
Types array Contains "Notification" Only NotificationInfo objects
Action "POST", "PUT", or "DELETE" Create, update, or delete actions

Checking Active Dates

Before displaying a notification, verify it’s currently active:

private bool IsNotificationActive(NotificationInfo notification)
{
    var now = DateTime.UtcNow;
    
    // Check start date (if specified)
    if (notification.StartDate.HasValue && notification.StartDate.Value > now)
    {
        return false; // Not yet active
    }
    
    // Check end date (if specified)
    if (notification.EndDate.HasValue && notification.EndDate.Value < now)
    {
        return false; // Expired
    }
    
    return true;
}
function isNotificationActive(notification) {
    const now = new Date();
    
    if (notification.StartDate && new Date(notification.StartDate) > now) {
        return false; // Not yet active
    }
    
    if (notification.EndDate && new Date(notification.EndDate) < now) {
        return false; // Expired
    }
    
    return true;
}

Working Example

C# Console Application

using System;
using System.Threading;
using System.Threading.Tasks;

class Program
{
    static async Task Main(string[] args)
    {
        var cts = new CancellationTokenSource();
        Console.CancelKeyPress += (s, e) => { e.Cancel = true; cts.Cancel(); };
        
        // Configuration
        const string apiUrl = "https://api.us.acresecurity.cloud";
        const string username = "{{YOUR_SERVICE_ACCOUNT_USERNAME}}";
        const string password = "{{YOUR_SERVICE_ACCOUNT_PASSWORD}}";
        const string instanceName = "{{YOUR_INSTANCE_NAME}}";
        
        Console.WriteLine("=== Notification Display Service ===\n");
        
        // 1. Authenticate
        var service = new NotificationService(apiUrl, username, password, instanceName);
        if (!await service.AuthenticateAsync())
        {
            Console.WriteLine("Failed to authenticate. Exiting.");
            return;
        }
        
        // 2. Get MQTT endpoint
        var mqttEndpoint = await service.GetMqttEndpointAsync();
        
        // 3. Get Object Modified event key - STATIC VALUE for API URL -> SHOULD BE SAVED OR CACHED
        var objectModEventKey = await service.GetObjectModifiedEventKeyAsync();
        
        // 4. Get instance key
        var instanceKey = await service.GetInstanceKeyAsync();
        
        // 5. Start token refresh loop
        var tokenRefresh = new TokenRefreshService(service);
        _ = tokenRefresh.StartRefreshLoopAsync(cts.Token);
        
        // 6. Load initial active notifications
        Console.WriteLine("\n--- Loading Active Notifications ---");
        var activeNotifications = await service.GetActiveNotificationsAsync();
        foreach (var notification in activeNotifications)
        {
            DisplayNotification(notification, "ACTIVE");
        }
        
        // 7. Connect to MQTT and subscribe
        var subscriber = new NotificationMqttSubscriber(
            instanceKey,
            objectModEventKey,
            () => service.AccessToken
        );
        
        // Handle notification events
        subscriber.OnNotificationCreated += notification =>
        {
            if (IsNotificationActive(notification))
            {
                DisplayNotification(notification, "NEW");
            }
        };
        
        subscriber.OnNotificationUpdated += notification =>
        {
            if (IsNotificationActive(notification))
            {
                DisplayNotification(notification, "UPDATED");
            }
        };
        
        subscriber.OnNotificationDeleted += key =>
        {
            Console.WriteLine($"\n╔════════════════════════════════════════╗");
            Console.WriteLine($"║ NOTIFICATION REMOVED                   ║");
            Console.WriteLine($"║ Key: {key,-32} ║");
            Console.WriteLine($"╚════════════════════════════════════════╝\n");
        };
        
        // Reconnect on token refresh
        tokenRefresh.OnTokenRefreshed += async () =>
        {
            await subscriber.DisconnectAsync();
            await subscriber.ConnectAsync(mqttEndpoint);
        };
        
        await subscriber.ConnectAsync(mqttEndpoint);
        
        Console.WriteLine("\n✓ Listening for notifications... (Press Ctrl+C to exit)\n");
        
        // Keep running
        try
        {
            await Task.Delay(Timeout.Infinite, cts.Token);
        }
        catch (OperationCanceledException) { }
        
        await subscriber.DisconnectAsync();
        Console.WriteLine("Service stopped.");
    }
    
    static bool IsNotificationActive(NotificationInfo notification)
    {
        var now = DateTime.UtcNow;
        if (notification.StartDate.HasValue && notification.StartDate.Value > now) return false;
        if (notification.EndDate.HasValue && notification.EndDate.Value < now) return false;
        return true;
    }
    
    static void DisplayNotification(NotificationInfo notification, string status)
    {
        Console.WriteLine($"\n╔════════════════════════════════════════════════════════════╗");
        Console.WriteLine($"║ 📢 {status} NOTIFICATION                                    ");
        Console.WriteLine($"╠════════════════════════════════════════════════════════════╣");
        Console.WriteLine($"║ Title: {notification.CommonName}");
        Console.WriteLine($"║ Priority: {notification.Priority}");
        Console.WriteLine($"╟────────────────────────────────────────────────────────────╢");
        Console.WriteLine($"║ {notification.Description}");
        Console.WriteLine($"╟────────────────────────────────────────────────────────────╢");
        Console.WriteLine($"║ Active: {notification.StartDate?.ToString("g") ?? "Immediately"}");
        Console.WriteLine($"║ Until:  {notification.EndDate?.ToString("g") ?? "No expiry"}");
        Console.WriteLine($"╚════════════════════════════════════════════════════════════╝\n");
    }
}

JavaScript Browser Example

<!DOCTYPE html>
<html>
<head>
    <title>Notification Display</title>
    <script src="https://unpkg.com/mqtt/dist/mqtt.min.js"></script>
    <style>
        .notification-banner {
            background: linear-gradient(135deg, #ff6b6b, #feca57);
            color: white;
            padding: 20px;
            margin: 10px;
            border-radius: 8px;
            box-shadow: 0 4px 6px rgba(0,0,0,0.1);
            animation: slideIn 0.3s ease-out;
        }
        .notification-banner.priority-high { background: linear-gradient(135deg, #ff4757, #ff6b81); }
        .notification-banner.priority-normal { background: linear-gradient(135deg, #1e90ff, #3742fa); }
        .notification-title { font-size: 1.2em; font-weight: bold; margin-bottom: 8px; }
        .notification-message { font-size: 1em; }
        .notification-meta { font-size: 0.8em; opacity: 0.8; margin-top: 10px; }
        @keyframes slideIn { from { transform: translateY(-20px); opacity: 0; } }
        #status { padding: 10px; margin: 10px; border-radius: 4px; }
        #status.connected { background: #d4edda; color: #155724; }
        #status.disconnected { background: #f8d7da; color: #721c24; }
    </style>
</head>
<body>
    <h1>🔔 Live Notifications</h1>
    <div id="status" class="disconnected">Connecting...</div>
    <div id="notifications"></div>

    <script>
        // Configuration - Replace with your values
        const CONFIG = {
            apiUrl: 'https://api.us.acresecurity.cloud',
            username: '{{YOUR_SERVICE_ACCOUNT_USERNAME}}',
            password: '{{YOUR_SERVICE_ACCOUNT_PASSWORD}}',
            instanceName: '{{YOUR_INSTANCE_NAME}}'
        };

        let tokenResponse = null;
        let mqttClient = null;
        let objectModEventKey = null;
        const activeNotifications = new Map();

        async function initialize() {
            try {
                // 1. Authenticate
                updateStatus('Authenticating...');
                await authenticate();
                
                // 2. Get MQTT endpoint
                updateStatus('Getting MQTT endpoint...');
                const mqttEndpoint = await getMqttEndpoint();
                
                // 3. Get Object Modified event key
                updateStatus('Getting event type...');
                objectModEventKey = await getObjectModEventKey();
                
                // 4. Load initial active notifications
                updateStatus('Loading active notifications...');
                await loadActiveNotifications();
                
                // 5. Connect to MQTT
                updateStatus('Connecting to MQTT...');
                connectMqtt(mqttEndpoint);
                
                // 6. Start token refresh
                startTokenRefresh();
                
            } catch (error) {
                updateStatus(`Error: ${error.message}`, false);
                console.error(error);
            }
        }

        async function loadActiveNotifications() {
            const now = new Date().toISOString();
            const params = new URLSearchParams({
                beforeStartDate: now,  // Only notifications that have started
                afterEndDate: now      // Only notifications that haven't expired
            });
            
            const response = await fetch(
                `${CONFIG.apiUrl}/api/f/${tokenResponse.instanceKey}/userreadnotifications/getunread?${params}`,
                {
                    headers: {
                        'Authorization': `Bearer ${tokenResponse.access_token}`
                    }
                }
            );
            
            if (!response.ok) {
                console.error('Failed to load active notifications');
                return;
            }
            
            const notifications = await response.json();
            console.log(`Loaded ${notifications.length} active notifications`);
            
            for (const notification of notifications) {
                displayNotification(notification);
            }
        }

        async function authenticate() {
            // Note: The /token endpoint expects username in format: instance\username
            const response = await fetch(`${CONFIG.apiUrl}/token`, {
                method: 'POST',
                headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
                body: new URLSearchParams({
                    grant_type: 'password',
                    client_id: 'consoleApp',
                    client_secret: 'consoleSecret',
                    username: `${CONFIG.instanceName}\\${CONFIG.username}`,
                    password: CONFIG.password,
                    instance: CONFIG.instanceName
                })
            });
            
            if (!response.ok) throw new Error('Authentication failed');
            tokenResponse = await response.json();
            
            // Get instance key from the API
            const instanceResp = await fetch(`${CONFIG.apiUrl}/api`, {
                headers: { 'Authorization': `Bearer ${tokenResponse.access_token}` }
            });
            const instance = await instanceResp.json();
            tokenResponse.instanceKey = instance.Key;
        }

        async function getMqttEndpoint() {
            const response = await fetch(`${CONFIG.apiUrl}/api/sysinfo`);
            const sysInfo = await response.json();
            return `${sysInfo.EventPublisherUrl}/mqtt`;
        }

        async function getObjectModEventKey() {
            // Get all apps for the instance
            const appsResp = await fetch(
                `${CONFIG.apiUrl}/api/f/${tokenResponse.instanceKey}/apps`,
                { headers: { 'Authorization': `Bearer ${tokenResponse.access_token}` } }
            );
            const apps = await appsResp.json();
            
            // Find KeepApi app by iterating through apps
            const keepApiApp = apps.find(app => app.ApiKey === 'KeepApi');
            if (!keepApiApp) throw new Error('KeepApi app not found');
            
            // Get Object Modified event type using its moniker
            // Moniker: Namespace="KeepApiEvents", Nickname="ObjectMod"
            const eventResp = await fetch(
                `${keepApiApp.Href}/eventtypes?namespace=KeepApiEvents&nickname=ObjectMod`,
                { headers: { 'Authorization': `Bearer ${tokenResponse.access_token}` } }
            );
            const objModEvent = await eventResp.json();
            
            if (!objModEvent || !objModEvent.Key) throw new Error('Object Modified event not found');
            return objModEvent.Key;
        }

        function connectMqtt(endpoint) {
            mqttClient = mqtt.connect(endpoint, {
                username: tokenResponse.access_token,
                password: '',
                reconnectPeriod: 5000
            });

            mqttClient.on('connect', () => {
                updateStatus('Connected - Listening for notifications', true);
                const topic = `/${tokenResponse.instanceKey}/${objectModEventKey}`;
                mqttClient.subscribe(topic);
                console.log('Subscribed to:', topic);
            });

            mqttClient.on('disconnect', () => {
                updateStatus('Disconnected - Reconnecting...', false);
            });

            mqttClient.on('message', (topic, payload) => {
                handleMessage(payload);
            });
        }

        function handleMessage(payload) {
            try {
                const event = JSON.parse(payload.toString());
                if (event.EventTypeKey !== objectModEventKey) return;

                // Parse event data (simplified - may need BSON library for production)
                let objectModData;
                try {
                    objectModData = JSON.parse(atob(event.EventDataBsonBase64));
                } catch {
                    objectModData = JSON.parse(event.EventDataBsonBase64);
                }

                // FILTER: Only NotificationInfo objects
                if (!objectModData.Types?.includes('Notification')) return;

                console.log('Notification event:', objectModData.Action);

                switch (objectModData.Action) {
                    case 'POST':
                    case 'PUT':
                        if (objectModData.AfterData && isActive(objectModData.AfterData)) {
                            displayNotification(objectModData.AfterData);
                        }
                        break;
                    case 'DELETE':
                        removeNotification(objectModData.Key);
                        break;
                }
            } catch (e) {
                console.error('Error handling message:', e);
            }
        }

        function isActive(notification) {
            const now = new Date();
            if (notification.StartDate && new Date(notification.StartDate) > now) return false;
            if (notification.EndDate && new Date(notification.EndDate) < now) return false;
            return true;
        }

        function displayNotification(notification) {
            activeNotifications.set(notification.Key, notification);
            renderNotifications();
        }

        function removeNotification(key) {
            activeNotifications.delete(key);
            renderNotifications();
        }

        function renderNotifications() {
            const container = document.getElementById('notifications');
            container.innerHTML = '';
            
            // Sort by priority (lower = more important)
            const sorted = [...activeNotifications.values()].sort((a, b) => a.Priority - b.Priority);
            
            for (const n of sorted) {
                const div = document.createElement('div');
                div.className = `notification-banner ${n.Priority <= 10 ? 'priority-high' : 'priority-normal'}`;
                div.innerHTML = `
                    <div class="notification-title">${n.CommonName}</div>
                    <div class="notification-message">${n.Description}</div>
                    <div class="notification-meta">
                        Active: ${n.StartDate ? new Date(n.StartDate).toLocaleString() : 'Now'} 
                        ${n.EndDate ? ' - ' + new Date(n.EndDate).toLocaleString() : ''}
                    </div>
                `;
                container.appendChild(div);
            }
        }

        function updateStatus(message, connected = null) {
            const el = document.getElementById('status');
            el.textContent = message;
            if (connected !== null) {
                el.className = connected ? 'connected' : 'disconnected';
            }
        }

        function startTokenRefresh() {
            // Refresh every 6 hours
            setInterval(async () => {
                try {
                    const response = await fetch(`${CONFIG.apiUrl}/token`, {
                        method: 'POST',
                        headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
                        body: new URLSearchParams({
                            grant_type: 'refresh_token',
                            refresh_token: tokenResponse.refresh_token,
                            client_id: 'consoleApp',
                            client_secret: 'consoleSecret'
                        })
                    });
                    
                    if (response.ok) {
                        tokenResponse = await response.json();
                        console.log('Token refreshed');
                        // Reconnect MQTT with new token
                        mqttClient.end();
                        connectMqtt(await getMqttEndpoint());
                    }
                } catch (e) {
                    console.error('Token refresh failed:', e);
                }
            }, 6 * 60 * 60 * 1000); // 6 hours
        }

        // Start the application
        initialize();
    </script>
</body>
</html>

ObjectModEventData Reference

The ObjectModEventData object contains details about what was modified:

Property Type Description
Action string "POST" (create), "PUT" (update), "DELETE" (delete)
Types string[] Object type(s) - filter for "Notification"
Key string Object key (useful for delete events)
CommonName string Object name
BeforeData Item Object state before modification (null for POST)
AfterData Item Object state after modification (null for DELETE)
User ObjectLinkItem User who made the change

Filtering Logic Summary

// Only process Object Modified events
if (eventMessage.EventTypeKey != objectModifiedEventKey) return;

// Only process NotificationInfo objects
var objectModData = Deserialize(eventMessage.EventDataBsonBase64);
if (!objectModData.Types.Contains("Notification")) return;

// Handle the action
switch (objectModData.Action)
{
    case "POST":  // Created - show notification
    case "PUT":   // Updated - refresh notification
    case "DELETE": // Removed - hide notification
}