Contextual Messaging — Tenant Onboarding Guide
Step-by-step guide for tenants to set up entity-scoped conversations in their application.
This guide walks you through integrating PlatformXe Contextual Messaging into your application. By the end, your users will be able to have multi-party conversations scoped to your business entities — bookings, orders, appointments, claims, or whatever your domain requires.
Prerequisites
- A PlatformXe organization with an active plan
- An API key with
threads:admin,threads:read, andthreads:writescopes - Your application's backend can make authenticated HTTP calls to PlatformXe
Step 1: Define Your Channels
A channel represents an entity type in your domain. Think about what your users have conversations about:
| Your Domain | Entity Type | Example Channel Slug | Participant Roles |
|---|---|---|---|
| Property management | Lease | lease | TENANT, LANDLORD, MANAGER |
| Healthcare | Appointment | appointment | PATIENT, PROVIDER, SPECIALIST |
| Insurance | Claim | claim | CLAIMANT, ADJUSTER, BROKER |
| Logistics | Shipment | shipment | SHIPPER, CARRIER, BROKER |
| Hospitality | Booking | booking | GUEST, HOST, SUPPORT |
| Services marketplace | Order | service-order | CUSTOMER, PROVIDER, SUPPORT |
Create each channel:
curl -X POST https://api.platformxe.com/api/v1/threads/channels \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"slug": "booking",
"displayName": "Booking Conversations",
"entityType": "BOOKING",
"participantRoles": ["GUEST", "HOST", "SUPPORT"],
"defaultVisibility": ["ALL"],
"lifecycleRules": {
"autoClose": {
"onEntityStatus": ["COMPLETED", "CANCELLED"]
},
"autoArchive": {
"afterClosedDays": 90
},
"systemMessages": {
"onThreadCreated": "A new conversation has been started.",
"onThreadClosed": "This conversation has been closed ({closedReason})."
}
}
}'
Key decisions:
- Participant roles are strings you define. They mean nothing to PlatformXe — they're labels your application uses to identify who's who.
- Default visibility controls who sees messages when the sender doesn't specify.
["ALL"]means everyone by default. - Lifecycle rules automate thread closure when your entity changes state.
Step 2: Create Threads When Entities Are Created
When a business entity is created in your application (e.g., a booking is confirmed), create a thread:
// Your backend — on entity creation
const thread = await platformx.threads.createThread({
channelSlug: 'booking',
entityId: booking.id, // Your entity's ID
subject: `Booking ${booking.reference}`,
metadata: { // Optional — store context
propertyName: booking.property.name,
checkInDate: booking.checkInDate,
},
participants: [
{
role: 'GUEST',
externalId: booking.guestId, // User ID in YOUR system
displayName: booking.guestName,
},
{
role: 'HOST',
externalId: booking.hostId,
displayName: booking.hostName,
},
{
role: 'SUPPORT',
externalId: 'system',
displayName: 'Support',
},
],
});
Important: externalId is the user's ID in your application, not in PlatformXe. PlatformXe never authenticates your end users — your application is the authority on identity.
Step 3: Build Your Proxy API
Your frontend should never call PlatformXe directly. Build thin proxy routes in your backend that:
- Authenticate the user using your own auth system
- Determine their role (GUEST, HOST, etc.) from the session
- Resolve the thread by entity ID
- Proxy to PlatformXe with the correct participant context
Frontend Your Backend PlatformXe
| | |
|-- GET /conversations --->| |
| (session cookie) |-- resolve user role ---> |
| |-- GET /threads?entity -->|
| | (x-api-key) |
| |<-- thread + messages ----|
|<-- filtered messages ----| |
Example proxy route:
// GET /api/conversations/:entityId/messages
export async function GET(request, { params }) {
const user = await authenticateUser(request); // Your auth
const role = resolveRole(user); // Your logic
// Find thread by entity ID
const threads = await platformx.threads.listThreads({
channelSlug: 'booking',
entityId: params.entityId,
});
if (!threads.length) {
return json({ messages: [] });
}
// List messages — PlatformXe filters by role visibility
const messages = await platformx.threads.listMessages(
threads[0].id,
{ role } // Pass as X-Participant-Role header
);
return json({ messages });
}
Step 4: Send Messages
When a user sends a message, your proxy identifies them and forwards to PlatformXe:
// POST /api/conversations/:entityId/messages
export async function POST(request, { params }) {
const user = await authenticateUser(request);
const role = resolveRole(user);
const { content, visibility } = await request.json();
const thread = await findOrCreateThread(params.entityId, user, role);
const message = await platformx.threads.sendMessage(thread.id, {
senderExternalId: user.id,
senderRole: role,
content,
visibility: visibility || ['ALL'],
});
return json({ message });
}
Visibility Control
Let users choose who sees their message:
| Visibility | Effect |
|---|---|
["ALL"] | Every participant sees it |
["HOST", "SUPPORT"] | Only host and support — guest excluded |
["GUEST", "SUPPORT"] | Only guest and support — host excluded |
["SUPPORT"] | Internal note — only your support team |
The sender's role is always implicitly included.
Step 5: Forward Entity Events for Lifecycle
When your entity changes state, tell PlatformXe so it can auto-close threads:
// When a booking completes
await platformx.threads.entityEvent({
channelSlug: 'booking',
entityId: booking.id,
event: 'STATUS_CHANGED',
newStatus: 'COMPLETED', // Must match autoClose.onEntityStatus
});
If the channel's lifecycle rules include COMPLETED in autoClose.onEntityStatus, the thread closes automatically and a system message is posted.
Step 6: Build the UI
The UI is yours to build — PlatformXe provides the data, you provide the experience. Common patterns:
Embedded in Entity Detail
Show the conversation inside the entity detail page (e.g., booking detail, order detail). This is the most natural pattern — users see the conversation in the context of what they're discussing.
Inbox / Conversation List
Use the inbox endpoint to show all conversations for a user:
const inbox = await platformx.threads.inbox({
externalId: user.id,
role: 'GUEST',
status: 'OPEN',
});
// Each item has: thread, channel, unreadCount, lastMessage
Unread Badge
Show unread count in your navigation:
const { count } = await platformx.threads.unreadCount({
externalId: user.id,
role: 'GUEST',
});
Step 7: Handle Webhooks (Optional)
Subscribe to thread events for real-time reactions:
await platformx.webhooks.create({
name: 'Thread notifications',
url: 'https://your-app.com/webhooks/threads',
events: ['message.created', 'thread.closed'],
});
When a new message arrives, you can:
- Send a push notification to the recipient
- Send an email summary
- Update unread counts in your cache
- Trigger your own workflows
Complete Integration Checklist
- Create channels for each entity type
- Create threads on entity creation (with all participants)
- Build proxy routes (authenticate, resolve role, proxy to PlatformXe)
- Build conversation UI (message list, compose bar, visibility controls)
- Forward entity status changes for lifecycle auto-close
- Add inbox/unread count to navigation
- Subscribe to webhook events for notifications (optional)
- Mark messages as read when user views the conversation
SDK Quick Reference
import { PlatformXeClient } from '@caldera/platformxe-sdk';
const client = new PlatformXeClient({
apiKey: process.env.PLATFORMXE_API_KEY,
});
// Channels
client.threads.createChannel({ ... })
client.threads.listChannels()
// Threads
client.threads.createThread({ channelSlug, entityId, participants })
client.threads.listThreads({ channelSlug, entityId, status })
client.threads.closeThread(threadId, { reason })
// Messages
client.threads.sendMessage(threadId, { senderExternalId, senderRole, content, visibility })
client.threads.listMessages(threadId, { role })
client.threads.sendSystemMessage(threadId, { content })
// Read State
client.threads.markRead(threadId, { participantExternalId, participantRole })
client.threads.inbox({ externalId, role })
client.threads.unreadCount({ externalId, role })
// Lifecycle
client.threads.entityEvent({ channelSlug, entityId, event, newStatus })