Designing Multi-Platform Push Notification Strategies for Mobile App Messaging Handoffs

Designing Multi-Platform Push Notification Strategies for Mobile App Messaging Handoffs

What This Guide Covers

You are engineering the notification layer for a mobile app messaging integration with Genesys Cloud Web Messaging or NICE CXone Digital - specifically the mechanism that fires an iOS (APNs) or Android (FCM) push notification when an agent replies to a customer who has backgrounded the app, ensuring the customer re-engages with the conversation rather than abandoning. When complete, a customer who starts a chat, backgrounds the app, and receives an agent response 10 minutes later gets a native push notification that deep-links them back to the open conversation thread.


Prerequisites, Roles & Licensing

Platform

  • Genesys Cloud: CX 2 or CX 3 with Web Messaging + Mobile App integration (requires Genesys Cloud SDK for iOS/Android or custom WebSocket client in your mobile app)
  • NICE CXone: Digital First Omnichannel with Mobile SDK integration
  • Licensing: No additional CCaaS license beyond the digital channel entitlement; push notification delivery costs are borne by your APNs/FCM account (free)

Infrastructure

  • Apple Push Notification service (APNs): An Apple Developer account with a Push Notification certificate or APNs auth key (.p8 file) for your app
  • Firebase Cloud Messaging (FCM): A Firebase project with FCM API enabled; server key for HTTP v1 API
  • Backend notification service: A microservice (Node.js, Python, or Go) that receives conversation events from Genesys/CXone and dispatches push notifications to APNs/FCM
  • Mobile app: Your iOS/Android app with push notification permissions already implemented (this guide covers the backend notification dispatch, not the client-side registration)

The Implementation Deep-Dive

1. Understanding the Notification Architecture

Push notifications for CCaaS messaging handoffs require a bridge between the CCaaS event system and the mobile platform notification gateways. Neither Genesys Cloud nor CXone natively dispatches APNs/FCM pushes - you own this layer.

[Agent sends reply in Genesys Cloud / CXone]
  |
  v
[CCaaS fires event: conversation.message.received]
  |
  v
[Your Notification Service (webhook consumer)]
  |-- Is the customer's app backgrounded? (from presence registry)
  |-- What device tokens does this customer have? (from your device token store)
  |
  v
[APNs (iOS)] or [FCM (Android)]
  |
  v
[Customer's phone: push notification fires]
  |
  v
[Customer taps notification → deep-link to app → conversation resumes]

Device token management is your responsibility. The CCaaS platform knows nothing about the customer’s device. Your mobile app must:

  1. Register for push notifications on startup
  2. Send the resulting device token to your backend (paired with the customer’s identity)
  3. Your backend stores {customerId → [deviceToken_ios, deviceToken_android]}

When a conversation event fires, your backend looks up the customer’s device tokens and dispatches the push.


2. Capturing Conversation Events from Genesys Cloud

Option A: Notification Service WebSocket (for conversation events)

Genesys Cloud’s Notification Service pushes real-time events to your backend via WebSocket:

const WebSocket = require("ws");

// Authenticate and get a WebSocket channel
async function subscribeToConversationEvents(accessToken) {
  // Create a channel
  const channelResp = await fetch("https://api.mypurecloud.com/api/v2/notifications/channels", {
    method: "POST",
    headers: { "Authorization": `Bearer ${accessToken}` }
  });
  const channel = await channelResp.json();
  
  // Subscribe to all conversation message events in the org
  await fetch(`https://api.mypurecloud.com/api/v2/notifications/channels/${channel.id}/subscriptions`, {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${accessToken}`,
      "Content-Type": "application/json"
    },
    body: JSON.stringify([
      { "id": "v2.conversations.{id}.messages" }
    ])
  });
  
  // Connect WebSocket
  const ws = new WebSocket(channel.connectUri);
  
  ws.on("message", (rawData) => {
    const event = JSON.parse(rawData);
    
    // Only process agent→customer messages (not customer→agent)
    if (event.topicName?.startsWith("v2.conversations.") && 
        event.eventBody?.fromUser?.id !== event.eventBody?.toUser?.id) {
      handleAgentMessage(event);
    }
  });
}

Option B: Genesys Cloud EventBridge (for AWS-native architectures)

If your notification service runs on AWS, use Genesys Cloud’s Amazon EventBridge integration to fan out conversation events to Lambda functions without managing WebSocket connections:

{
  "EventBridgeIntegration": {
    "awsAccountId": "123456789012",
    "awsRegion": "us-east-1",
    "topics": [
      "v2.conversations.*.messages",
      "v2.conversations.*.participants.*.attributes"
    ]
  }
}

The Trap - subscribing to ALL conversation events without filtering: The v2.conversations.{id}.messages wildcard subscription fires for every message from every participant on every interaction - including agent-to-agent transfers, bot messages, and system events. Without filtering, your notification service may dispatch thousands of push notifications per hour for internal events. Filter on: eventBody.direction === "outbound" (agent-to-customer direction) AND eventBody.type === "Text" or "Structured".


3. Determining if the Customer is App-Backgrounded

Sending a push notification to a customer who is actively viewing the conversation is redundant and slightly jarring. The app should suppress push delivery when the customer has the conversation screen active.

Client-side app presence signaling:

When the customer opens the messaging screen, the app should send a presence update to your backend:

// iOS - when the conversation view appears
func sendPresence(conversationId: String, status: String) {
    let payload = ["conversationId": conversationId, "status": status, "deviceToken": deviceToken]
    // POST to your backend presence registry
    URLSession.shared.dataTask(with: presenceRequest(payload)).resume()
}

// Call when view appears
override func viewDidAppear(_ animated: Bool) {
    sendPresence(conversationId: currentConversationId, status: "active")
}

// Call when view disappears or app backgrounds
override func viewWillDisappear(_ animated: Bool) {
    sendPresence(conversationId: currentConversationId, status: "background")
}

Your backend’s presence registry stores {conversationId → customerStatus}. On an agent message event, check the status before dispatching:

def handle_agent_message(conversation_id: str, customer_id: str, message: str):
    status = presence_registry.get(conversation_id, "unknown")
    
    if status == "active":
        # Customer is viewing the conversation - no push needed
        return
    
    # Customer is backgrounded or unknown - send push
    device_tokens = device_token_store.get_tokens(customer_id)
    for token in device_tokens:
        dispatch_push(token, conversation_id, message)

The Trap - presence state becoming stale on app crash or network loss: If the app crashes while status: active, the presence registry holds the stale “active” value and the customer misses the push notification. Use a TTL on presence records: if the presence entry is older than 60 seconds without a refresh heartbeat, treat it as background. The app sends heartbeat presence updates every 30 seconds while active.


4. Dispatching via APNs (iOS)

Use Apple’s HTTP/2 APNs endpoint with the token-based authentication (.p8 key) - not the deprecated certificate-based method.

import jwt
import time
import httpx

APNS_KEY_ID = "YOUR_KEY_ID"
APNS_TEAM_ID = "YOUR_TEAM_ID"
APNS_BUNDLE_ID = "com.yourcompany.yourapp"
APNS_PRIVATE_KEY = open("AuthKey_YOURKEYID.p8").read()
APNS_HOST = "https://api.push.apple.com"  # Production; use api.sandbox.push.apple.com for dev

def get_apns_jwt():
    """Generate a short-lived JWT for APNs authentication (valid 60 minutes)."""
    claims = {
        "iss": APNS_TEAM_ID,
        "iat": int(time.time())
    }
    return jwt.encode(claims, APNS_PRIVATE_KEY, algorithm="ES256", 
                      headers={"kid": APNS_KEY_ID, "alg": "ES256"})

def send_apns_notification(device_token: str, conversation_id: str, message_preview: str):
    token = get_apns_jwt()
    
    payload = {
        "aps": {
            "alert": {
                "title": "New message",
                "body": message_preview[:50] + "..." if len(message_preview) > 50 else message_preview
            },
            "badge": 1,
            "sound": "default",
            "mutable-content": 1  # Enables Notification Service Extension for media attachments
        },
        "conversationId": conversation_id  # Custom data for deep-linking
    }
    
    headers = {
        "Authorization": f"bearer {token}",
        "apns-topic": APNS_BUNDLE_ID,
        "apns-push-type": "alert",
        "apns-priority": "10",  # Immediate delivery
        "apns-expiration": str(int(time.time()) + 3600)  # Push expires in 1 hour if undeliverable
    }
    
    resp = httpx.post(
        f"{APNS_HOST}/3/device/{device_token}",
        json=payload,
        headers=headers,
        http2=True  # APNs requires HTTP/2
    )
    
    if resp.status_code == 410:
        # Device token is no longer valid - remove from store
        device_token_store.remove_token(device_token)
    elif resp.status_code != 200:
        logger.error(f"APNs error {resp.status_code}: {resp.text}")

The Trap - message preview content in push payloads: The body field in the APNs alert is visible on the lock screen before the customer unlocks their device. If agent messages contain PHI (healthcare use case) or sensitive support details, do not include message content in the push body. Use a generic prompt instead: "You have a new message from Support." The customer sees the full message content only after unlocking and opening the app.


5. Dispatching via FCM (Android)

import google.oauth2.service_account
import google.auth.transport.requests
import requests as http_requests

FCM_PROJECT_ID = "your-firebase-project-id"
FCM_API_URL = f"https://fcm.googleapis.com/v1/projects/{FCM_PROJECT_ID}/messages:send"

def get_fcm_access_token():
    credentials = google.oauth2.service_account.Credentials.from_service_account_file(
        "firebase-service-account.json",
        scopes=["https://www.googleapis.com/auth/firebase.messaging"]
    )
    request = google.auth.transport.requests.Request()
    credentials.refresh(request)
    return credentials.token

def send_fcm_notification(device_token: str, conversation_id: str, message_preview: str):
    access_token = get_fcm_access_token()
    
    payload = {
        "message": {
            "token": device_token,
            "notification": {
                "title": "New message",
                "body": message_preview[:100]
            },
            "data": {
                "conversationId": conversation_id,
                "type": "agent_message"
            },
            "android": {
                "priority": "high",
                "ttl": "3600s",
                "notification": {
                    "channel_id": "support_messages",  # Must match channel registered in app
                    "sound": "default",
                    "click_action": "OPEN_CONVERSATION"
                }
            }
        }
    }
    
    resp = http_requests.post(
        FCM_API_URL,
        json=payload,
        headers={
            "Authorization": f"Bearer {access_token}",
            "Content-Type": "application/json"
        }
    )
    
    result = resp.json()
    if "error" in result:
        if result["error"].get("status") == "UNREGISTERED":
            device_token_store.remove_token(device_token)
        else:
            logger.error(f"FCM error: {result['error']}")

6. Deep-Link Handling in the Mobile App

The push notification must navigate the customer directly to the open conversation, not just the app home screen. Pass conversationId as a custom data field (as shown in both examples above). The app’s notification tap handler resolves this to the correct conversation view:

iOS (Swift):

func userNotificationCenter(_ center: UNUserNotificationCenter,
                             didReceive response: UNNotificationResponse,
                             withCompletionHandler completionHandler: @escaping () -> Void) {
    let userInfo = response.notification.request.content.userInfo
    if let conversationId = userInfo["conversationId"] as? String {
        NavigationRouter.shared.navigateTo(.conversation(id: conversationId))
    }
    completionHandler()
}

Android (Kotlin):

// In the Activity handling the notification deep-link intent
val conversationId = intent.getStringExtra("conversationId")
conversationId?.let {
    supportFragmentManager.beginTransaction()
        .replace(R.id.fragment_container, ConversationFragment.newInstance(it))
        .commit()
}

Validation, Edge Cases & Troubleshooting

Edge Case 1: Customer Has Multiple Devices (Tablet + Phone)

The device token store should support one-to-many: one customerId mapped to multiple device tokens. Send the push to all active tokens. Handle duplicate notification rendering on the app side by including a conversationId in the notification payload - the app can deduplicate taps regardless of which device the customer responds from.

Edge Case 2: Push Notifications Arriving After the Customer Has Already Returned to the App

If the customer returns to the app before the push is delivered (fast LTE/WiFi), they see both the new message in the UI and the push notification. Set apns-expiration to 0 for time-sensitive messages to instruct APNs to discard undelivered notifications immediately if the device comes online after the conversation has been read. Alternatively, use the APNs collapse ID (apns-collapse-id: {conversationId}) so multiple rapid notifications are collapsed into one.

Edge Case 3: CXone Event Delivery to Notification Service

For CXone, the equivalent event source is the CXone Real-Time Data WebSocket or the Contact Start/End Notification webhook configured under Admin > API Integrations > Event Destinations. Conversation message events from CXone’s Digital First engine follow a different schema than Genesys - parse contactEvent.type === "ChatMessageReceived" for the equivalent trigger.

Edge Case 4: Push Delivery Rate During Peak Hours

At shift-change times when agent responses are batched, your notification service may dispatch hundreds of pushes per minute. APNs has no documented rate limit, but FCM throttles at the project level under sustained load. Implement a priority queue for your notification dispatcher: push notifications for conversations with the longest agent response delay are dispatched first.


Official References