Messaging API
GraphQL API for the messaging system. All operations require JWT authentication via the Authorization: Bearer <token> header.
Endpoint: https://api.aiqlick.com/graphql (production) | https://api-dev.aiqlick.com/graphql (development)
GraphQL Types
Enums
enum ConversationType {
DIRECT
GROUP
}
enum ConversationParticipantRole {
OWNER
ADMIN
MEMBER
}
enum MessageStatus {
SENT
DELIVERED
DELETED
}
Object Types
type ConversationOutput {
id: ID!
type: ConversationType!
title: String
companyId: ID
lastMessageAt: DateTime
lastMessageText: String
messageCount: Int!
unreadCount: Int! # Computed per-user, not stored
archivedAt: DateTime
createdAt: DateTime!
updatedAt: DateTime!
participants: [ParticipantOutput!]!
}
type ConversationListOutput {
conversations: [ConversationOutput!]!
hasMore: Boolean!
nextCursor: String
}
type ParticipantOutput {
id: ID!
userId: ID!
role: ConversationParticipantRole!
lastReadAt: DateTime
lastReadMessageId: ID
isMuted: Boolean!
isPinned: Boolean!
notificationsEnabled: Boolean!
isArchived: Boolean!
isFlagged: Boolean!
isMarkedUnread: Boolean!
joinedAt: DateTime!
leftAt: DateTime
firstName: String # Resolved from User (falls back to email prefix if null)
lastName: String # Resolved from User
profileImageUrl: String # Resolved from User
}
type MessageOutput {
id: ID!
conversationId: ID!
senderId: ID!
content: String # Nullable — empty when message has only attachments
status: MessageStatus!
replyToId: ID
isEdited: Boolean!
editedAt: DateTime
metadata: JSON
deletedAt: DateTime
createdAt: DateTime!
updatedAt: DateTime!
senderFirstName: String # Resolved from User
senderLastName: String # Resolved from User
senderProfileImageUrl: String # Resolved from User
replyTo: MessageOutput # Resolved reply preview (one level only)
isSelf: Boolean # True when the message was sent by the current user (subscription only)
attachments: [MessageAttachmentOutput!] # File attachments (if any)
}
type MessageAttachmentOutput {
id: ID!
messageId: ID!
filename: String!
contentType: String!
sizeBytes: Int!
objectPath: String!
createdAt: DateTime!
downloadUrl: String # Presigned S3 URL (1-hour expiry), null if generation fails
}
type MessageListOutput {
messages: [MessageOutput!]!
hasMore: Boolean!
nextCursor: String
}
type UnreadMessageCountOutput {
count: Int!
companyId: ID
}
type MessagingSuccessResponse {
success: Boolean!
message: String!
}
"""Real-time read receipt event sent to other participants when someone reads messages"""
type ReadReceiptOutput {
conversationId: ID!
userId: ID! # The user who read the messages
lastReadAt: DateTime!
lastReadMessageId: ID # The most recent message they read up to
}
Input Types
input CreateDirectConversationInput {
recipientId: ID! # UUID of the other user
companyId: ID # Optional company scope
}
input CreateGroupConversationInput {
title: String! # Required, max 255 chars
participantIds: [ID!]! # 1–50 user UUIDs (creator is added automatically as OWNER)
companyId: ID # Optional company scope
}
input SendMessageInput {
conversationId: ID!
content: String # Max 10,000 chars (optional when attachments provided)
replyToId: ID # Optional, must belong to same conversation
attachments: [MessageAttachmentInput!] # Up to 10 file attachments
}
input MessageAttachmentInput {
filename: String! # Max 255 chars
contentType: String! # Must be in allowed list (images, PDFs, docs, text, archives)
sizeBytes: Int! # 1 byte to 25 MB (26,214,400 bytes)
objectPath: String! # S3 key, must start with "uploads/"
}
input GetConversationsInput {
cursor: String # Cursor for pagination (conversation ID)
limit: Int = 20 # 1–50
companyId: ID # Filter by company
type: ConversationType # Filter by DIRECT or GROUP
search: String # Search in title, lastMessageText, participant names
}
input GetMessagesInput {
cursor: String # Cursor for pagination (message ID)
limit: Int = 30 # 1–100
}
input UpdateGroupConversationInput {
conversationId: ID!
title: String # New title, max 255 chars
}
input AddGroupParticipantsInput {
conversationId: ID!
userIds: [ID!]! # User UUIDs to add (max 50)
}
input RemoveGroupParticipantInput {
conversationId: ID!
userId: ID! # User UUID to remove
}
Queries
myConversations
Returns the current user's conversations, paginated and ordered by most recent message.
query MyConversations($input: GetConversationsInput) {
myConversations(input: $input) {
conversations {
id
type
title
companyId
lastMessageAt
lastMessageText
messageCount
unreadCount
participants {
id
userId
role
firstName
lastName
profileImageUrl
isMuted
isPinned
}
}
hasMore
nextCursor
}
}
Variables:
{
"input": {
"limit": 20,
"companyId": "optional-company-uuid",
"type": "DIRECT",
"search": "john"
}
}
The API does not separate pinned conversations — it returns them in lastMessageAt order. The frontend should sort pinned conversations to the top of the list client-side by checking participants[currentUser].isPinned.
conversation
Returns a single conversation with participants and computed unread count.
query Conversation($id: ID!) {
conversation(id: $id) {
id
type
title
companyId
messageCount
unreadCount
participants {
id
userId
role
firstName
lastName
profileImageUrl
lastReadAt
lastReadMessageId
isMuted
isPinned
notificationsEnabled
}
}
}
messages
Returns messages for a conversation, paginated in reverse chronological order (newest first).
query Messages($conversationId: ID!, $input: GetMessagesInput) {
messages(conversationId: $conversationId, input: $input) {
messages {
id
conversationId
senderId
content
status
replyToId
isEdited
editedAt
metadata
deletedAt
createdAt
senderFirstName
senderLastName
senderProfileImageUrl
replyTo {
id
content
senderId
senderFirstName
senderLastName
}
attachments {
id
filename
contentType
sizeBytes
downloadUrl
}
}
hasMore
nextCursor
}
}
Variables:
{
"conversationId": "conversation-uuid",
"input": {
"limit": 30,
"cursor": "last-message-uuid-from-previous-page"
}
}
Messages are returned newest first. The frontend should reverse the array for display (oldest at top, newest at bottom) and use "load more" scrolling upward to fetch older messages via nextCursor.
unreadMessageCount
Returns the total unread message count across all conversations. Use this for the navigation badge.
query UnreadMessageCount($companyId: ID) {
unreadMessageCount(companyId: $companyId) {
count
companyId
}
}
For reliability, poll this query with pollInterval: 30000 (30 seconds) as a fallback alongside the unreadMessageCountChanged subscription. This matches the pattern used by UNREAD_NOTIFICATION_COUNT.
Mutations
createDirectConversation
Find or create a 1:1 conversation with another user. If a DIRECT conversation already exists between the two users, it returns the existing one.
mutation CreateDirectConversation($input: CreateDirectConversationInput!) {
createDirectConversation(input: $input) {
id
type
participants {
userId
firstName
lastName
profileImageUrl
}
}
}
Variables:
{
"input": {
"recipientId": "other-user-uuid",
"companyId": "optional-company-uuid"
}
}
Errors:
BAD_REQUEST— Cannot create a conversation with yourself
createGroupConversation
Create a new group conversation. The creator is automatically added as OWNER.
mutation CreateGroupConversation($input: CreateGroupConversationInput!) {
createGroupConversation(input: $input) {
id
type
title
participants {
userId
role
firstName
lastName
profileImageUrl
}
}
}
Variables:
{
"input": {
"title": "Hiring Team - Backend Developer",
"participantIds": ["user-uuid-1", "user-uuid-2", "user-uuid-3"],
"companyId": "company-uuid"
}
}
Errors:
BAD_REQUEST— Group requires at least one other participantFORBIDDEN— No access to the specified company
sendMessage
Send a message to a conversation. Supports optional reply threading (one level deep) and file attachments.
A message must have content OR attachments (or both). Whitespace-only content is rejected.
mutation SendMessage($input: SendMessageInput!) {
sendMessage(input: $input) {
id
conversationId
senderId
content
status
replyToId
createdAt
senderFirstName
senderLastName
senderProfileImageUrl
replyTo {
id
content
senderFirstName
senderLastName
}
attachments {
id
filename
contentType
sizeBytes
downloadUrl
}
}
}
Variables (text only):
{
"input": {
"conversationId": "conversation-uuid",
"content": "Hello team! Let's discuss the candidate."
}
}
Variables (with attachments):
{
"input": {
"conversationId": "conversation-uuid",
"content": "Here's the candidate's resume",
"attachments": [
{
"filename": "resume.pdf",
"contentType": "application/pdf",
"sizeBytes": 245760,
"objectPath": "uploads/messaging/abc123/resume.pdf"
}
]
}
}
Variables (attachment only, no text):
{
"input": {
"conversationId": "conversation-uuid",
"attachments": [
{
"filename": "screenshot.png",
"contentType": "image/png",
"sizeBytes": 102400,
"objectPath": "uploads/messaging/abc123/screenshot.png"
}
]
}
}
Errors:
BAD_REQUEST— No content or attachments provided, whitespace-only content with no attachments, invalid content type, file too large (>25MB), invalid objectPathFORBIDDEN— Not a participant in this conversationNOT_FOUND— Reply target message not found in this conversation
Side effects:
- Updates conversation
lastMessageAt,lastMessageText,messageCount- For attachment-only messages,
lastMessageText= "Sent an attachment" / "Sent N attachments"
- For attachment-only messages,
- Auto-marks conversation as read for the sender
- Publishes
messageReceivedsubscription to all participants (including sender withisSelf: true) - Publishes
unreadMessageCountChangedto non-sender participants - Publishes
readReceiptChangedto non-sender participants (so they see the sender's blue ticks) - Creates
MESSAGE_RECEIVEDnotification for offline participants (respects mute)
editMessage
Edit a message you sent. Only the original sender can edit.
mutation EditMessage($messageId: ID!, $content: String!) {
editMessage(messageId: $messageId, content: $content) {
id
content
isEdited
editedAt
}
}
Errors:
NOT_FOUND— Message not foundFORBIDDEN— Can only edit your own messagesBAD_REQUEST— Cannot edit a deleted message
deleteMessage
Soft-delete a message. The message content is cleared, status set to DELETED, and any attachments are removed from both the database and S3.
mutation DeleteMessage($messageId: ID!) {
deleteMessage(messageId: $messageId) {
id
status
content
deletedAt
}
}
Errors:
NOT_FOUND— Message not foundFORBIDDEN— Can only delete your own messages
Side effects:
- Clears message content and sets
status = DELETED - Deletes all
MessageAttachmentrecords from the database - Removes attachment files from S3 (fire-and-forget, non-blocking)
After deletion, content will be an empty string, status will be DELETED, and attachments will be empty. The frontend should render these as "This message was deleted" and hide the content area.
markConversationAsRead
Mark a conversation as read up to a specific message (or the latest message if none specified).
mutation MarkConversationAsRead($conversationId: ID!, $messageId: ID) {
markConversationAsRead(conversationId: $conversationId, messageId: $messageId) {
success
message
}
}
Side effects:
- Updates
lastReadAtandlastReadMessageIdfor the participant - Publishes
unreadMessageCountChangedsubscription to the reader - Publishes
readReceiptChangedsubscription to all other active participants in the conversation
Call this mutation when the user opens a conversation or scrolls to the latest message. For optimistic UX, set unreadCount = 0 in the Apollo cache immediately.
muteConversation
Toggle mute for a conversation. Muted conversations do not generate notifications.
mutation MuteConversation($conversationId: ID!, $muted: Boolean!) {
muteConversation(conversationId: $conversationId, muted: $muted) {
success
message
}
}
pinConversation
Toggle pin for a conversation. Pinned conversations should be shown at the top of the list.
mutation PinConversation($conversationId: ID!, $pinned: Boolean!) {
pinConversation(conversationId: $conversationId, pinned: $pinned) {
success
message
}
}
updateGroupConversation
Rename a group conversation. Requires OWNER or ADMIN role.
mutation UpdateGroupConversation($input: UpdateGroupConversationInput!) {
updateGroupConversation(input: $input) {
id
title
}
}
Errors:
NOT_FOUND— Conversation not foundBAD_REQUEST— Can only update group conversationsFORBIDDEN— Insufficient permissions (requires OWNER or ADMIN)
addGroupParticipants
Add users to a group conversation. Requires OWNER or ADMIN role. Users who previously left can be re-added.
mutation AddGroupParticipants($input: AddGroupParticipantsInput!) {
addGroupParticipants(input: $input) {
id
participants {
userId
role
firstName
lastName
joinedAt
}
}
}
Errors:
NOT_FOUND— Conversation not foundBAD_REQUEST— Can only add participants to group conversationsFORBIDDEN— Insufficient permissions (requires OWNER or ADMIN)
removeGroupParticipant
Remove a user from a group conversation. OWNER or ADMIN can remove others. Any user can remove themselves (equivalent to leaving).
mutation RemoveGroupParticipant($input: RemoveGroupParticipantInput!) {
removeGroupParticipant(input: $input) {
id
participants {
userId
role
firstName
lastName
}
}
}
Errors:
NOT_FOUND— Conversation not foundBAD_REQUEST— Can only remove participants from group conversationsFORBIDDEN— Insufficient permissions (OWNER/ADMIN required to remove others)
archiveConversation
Archive a conversation. Archived conversations are hidden from the default conversation list.
mutation ArchiveConversation($conversationId: ID!, $archived: Boolean!) {
archiveConversation(conversationId: $conversationId, archived: $archived) {
success
message
}
}
flagConversation
Flag a conversation for follow-up. Flagged conversations can be filtered in the conversation list.
mutation FlagConversation($conversationId: ID!, $flagged: Boolean!) {
flagConversation(conversationId: $conversationId, flagged: $flagged) {
success
message
}
}
markConversationAsUnread
Manually mark a conversation as unread (even if all messages have been read). Useful for "remind me later" workflows.
mutation MarkConversationAsUnread($conversationId: ID!) {
markConversationAsUnread(conversationId: $conversationId) {
success
message
}
}
Calling markConversationAsRead will also clear the isMarkedUnread flag.
leaveGroupConversation
Leave a group conversation. Sets leftAt on the participant (soft-leave).
mutation LeaveGroupConversation($conversationId: ID!) {
leaveGroupConversation(conversationId: $conversationId) {
success
message
}
}
Errors:
NOT_FOUND— Conversation not foundBAD_REQUEST— Can only leave group conversations
Subscriptions
All subscriptions use the graphql-ws protocol over WebSocket.
messageReceived
Receives new, edited, and deleted messages in real-time for all conversations the user participates in.
subscription MessageReceived {
messageReceived {
id
conversationId
senderId
content
status
replyToId
isEdited
editedAt
deletedAt
createdAt
senderFirstName
senderLastName
senderProfileImageUrl
replyTo {
id
content
senderFirstName
senderLastName
}
attachments {
id
filename
contentType
sizeBytes
downloadUrl
}
}
}
Behavior:
- Triggered when any participant in any of the user's conversations sends, edits, or deletes a message
- Includes the sender's own messages with
isSelf: true— the frontend should use this for immediate sync instead of relying on mutation response alone - The payload is the full
MessageOutputincluding resolved sender info - For edited messages:
isEdited = true, content reflects the new text - For deleted messages:
status = DELETED, content is empty
Cache update strategy:
On messageReceived:
1. If conversationId matches the currently open conversation:
- Append (or update) the message in the message list
- Call markConversationAsRead
2. Update the conversation list:
- Move this conversation to the top
- Update lastMessageText and lastMessageAt
- If not the currently open conversation, increment unreadCount
unreadMessageCountChanged
Receives updated total unread message count.
subscription UnreadMessageCountChanged {
unreadMessageCountChanged {
count
companyId
}
}
Behavior:
- Triggered when unread count changes (new message received, conversation marked as read)
- Use this to update the navigation badge
typingIndicatorChanged
Receives typing start/stop events for conversations the user participates in.
subscription TypingIndicatorChanged {
typingIndicatorChanged {
conversationId
userId
isTyping
firstName
lastName
}
}
Behavior:
- Triggered when any participant starts or stops typing in a shared conversation
isTyping: true= started typing,isTyping: false= stopped typing- Frontend should auto-expire typing indicators after ~4 seconds (in case the stop event is lost)
- When a
messageReceivedevent arrives from the same user, clear their typing indicator immediately
presenceChanged
Receives online/offline status changes for users the current user has conversations with.
subscription PresenceChanged {
presenceChanged {
userId
isOnline
lastSeenAt
}
}
Behavior:
- Triggered when a user goes online (heartbeat) or offline (disconnect/explicit goOffline)
lastSeenAtis the timestamp of the last heartbeat or activity- Initial presence state should be loaded via the
contactPresencequery, then kept updated by this subscription
readReceiptChanged
Receives read receipt updates when another participant reads messages in a shared conversation. Use this to update the "blue double tick" indicators on sent messages.
subscription ReadReceiptChanged {
readReceiptChanged {
conversationId
userId
lastReadAt
lastReadMessageId
}
}
Behavior:
- Triggered when any other participant calls
markConversationAsReador sends a message (which auto-marks as read) userIdis the user who read the messages (NOT the subscriber)lastReadAtis the timestamp up to which the user has readlastReadMessageIdis the specific message they read up to (may be null if no messages exist)- Only published to other active participants — the reader does NOT receive their own read receipt
Frontend update strategy:
On readReceiptChanged:
1. Find the conversation by conversationId in local state
2. Update the matching participant's lastReadAt and lastReadMessageId
3. ReadReceiptTicks re-evaluates: if any participant.lastReadAt >= message.createdAt → blue double tick
Each user has their own Redis PubSub channel: messages:readreceipt:{userId}. This means read receipts for all conversations are delivered through a single channel per user, matching the pattern used by messageReceived.
Presence & Typing Mutations
# Send typing indicator (debounce at 2.5s on frontend)
mutation SendTypingIndicator($conversationId: ID!) {
sendTypingIndicator(conversationId: $conversationId) {
success
}
}
# Send heartbeat every 60s to maintain online status
mutation Heartbeat {
heartbeat {
success
}
}
# Signal going offline (call on page close via fetch keepalive)
mutation GoOffline {
goOffline {
success
}
}
contactPresence Query
Load initial presence state for conversation participants:
query ContactPresence($userIds: [ID!]!) {
contactPresence(userIds: $userIds) {
userId
isOnline
lastSeenAt
}
}
Frontend Implementation Guide
Recommended File Structure
graphql/operations/messaging/
├── fragments.ts # MESSAGE_FIELDS, CONVERSATION_FIELDS, PARTICIPANT_FIELDS
├── queries.ts # MY_CONVERSATIONS, CONVERSATION_MESSAGES, UNREAD_MESSAGE_COUNT, CONTACT_PRESENCE
├── mutations.ts # All 14 mutations (incl. SEND_TYPING_INDICATOR, HEARTBEAT, GO_OFFLINE)
└── subscriptions.ts # MESSAGE_RECEIVED, UNREAD_MESSAGE_COUNT_CHANGED, TYPING_INDICATOR, PRESENCE_CHANGED
components/messaging/
├── MessagesLayout.tsx # Shared layout (conversation list + thread + empty state + modal)
├── ConversationList.tsx # Scrollable sidebar with search, pinned first, unread badges, presence
├── ConversationItem.tsx # Avatar + name + preview + timestamp + unread count + context menu
├── MessageThread.tsx # Scrollable message area with typing indicator, presence, group mgmt
├── MessageBubble.tsx # Message bubble with sender avatar, content, time, reply preview
├── MessageInput.tsx # Text input + send button + reply indicator bar + typing emission
├── NewConversationModal.tsx # User search/select to start new conversation
└── TypingIndicator.tsx # Animated typing dots
app/(shared)/messages/
├── page.tsx # Unified inbox (both company + job seeker)
└── [conversationId]/page.tsx # Direct link to a specific conversation
lib/hooks/
└── useMessaging.ts # Combined hook for messaging state, typing, presence
Navigation Integration
Messages are accessible via the navbar dropdown (NavbarMessages.tsx), which deep-links to /messages?conversationId=xxx. The unified route works for both company and job seeker roles:
// Single path for all roles — companyId is derived from user.selectedCompanyId
const messagesPath = "/messages"
Unread Badge
Use unreadMessageCount query with pollInterval: 30000 alongside the unreadMessageCountChanged subscription:
const { data } = useQuery(UNREAD_MESSAGE_COUNT, {
pollInterval: 30000,
variables: { companyId: user?.selectedCompanyId },
});
useSubscription(UNREAD_MESSAGE_COUNT_CHANGED, {
onData: ({ data }) => {
// Update Apollo cache with new count
},
});
Conversation List Display
For DIRECT conversations, display the other participant's name and avatar. For GROUP conversations, display the group title.
function getConversationDisplayName(conversation, currentUserId) {
if (conversation.type === 'GROUP') {
return conversation.title;
}
// DIRECT: show the other participant's name
const other = conversation.participants.find(p => p.userId !== currentUserId);
return other ? `${other.firstName} ${other.lastName}` : 'Unknown';
}
Message Rendering
| Message State | Display |
|---|---|
status === 'SENT' | Normal message bubble with content |
status === 'DELETED' | Italic gray "This message was deleted" |
isEdited === true | Normal content + small "(edited)" label |
replyTo !== null | Show reply preview bar above the message |
attachments.length > 0 | Show attachment cards below the content (filename, size, download link) |
content === null && attachments.length > 0 | Attachment-only message — show attachment cards without text bubble |
Optimistic Updates
For sendMessage, use Apollo's optimisticResponse to immediately show the message in the thread before the server responds:
sendMessage({
variables: { input },
optimisticResponse: {
sendMessage: {
__typename: 'MessageOutput',
id: `temp-${Date.now()}`,
conversationId: input.conversationId,
senderId: currentUser.id,
content: input.content,
status: 'SENT',
isEdited: false,
createdAt: new Date().toISOString(),
// ... other fields
},
},
update(cache, { data }) {
// Append to messages list
// Update conversation's lastMessageText and lastMessageAt
// Move conversation to top of list
},
});
Starting a New Conversation
The "New Message" button should open a modal where the user can:
- Search for users — Use the existing
companyUsers(companyId)or a user search query - Select a single user → Call
createDirectConversation(deduplicates automatically) - Select multiple users + enter title → Call
createGroupConversation - Navigate to the returned conversation ID
Infinite Scroll (Messages)
Load older messages as the user scrolls up:
function loadMore() {
if (!data?.messages.hasMore) return;
fetchMore({
variables: {
input: { cursor: data.messages.nextCursor, limit: 30 },
},
updateQuery: (prev, { fetchMoreResult }) => ({
messages: {
...fetchMoreResult.messages,
messages: [...prev.messages.messages, ...fetchMoreResult.messages.messages],
},
}),
});
}
Apollo Cache Updates on Subscription
When a messageReceived event arrives:
useSubscription(MESSAGE_RECEIVED, {
onData: ({ client, data }) => {
const message = data.data.messageReceived;
// 1. Update message list if conversation is open
client.cache.modify({
id: client.cache.identify({ __typename: 'MessageListOutput', ... }),
fields: {
messages(existing = []) {
const ref = client.cache.writeFragment({
fragment: MESSAGE_FIELDS,
data: message,
});
// Check for edit/delete (update existing) vs new (prepend)
const existingIndex = existing.findIndex(
e => client.cache.identify(e) === client.cache.identify(ref)
);
if (existingIndex >= 0) {
const updated = [...existing];
updated[existingIndex] = ref;
return updated;
}
return [ref, ...existing];
},
},
});
// 2. Update conversation list
client.cache.modify({
fields: {
myConversations(existing) {
// Update lastMessageText, lastMessageAt, unreadCount
// Move conversation to top
},
},
});
},
});
Error Codes Reference
| Error | HTTP | When |
|---|---|---|
BAD_REQUEST | 400 | Self-conversation, empty group, edit deleted message, non-group operations |
FORBIDDEN | 403 | Not a participant, insufficient role, no company access |
NOT_FOUND | 404 | Conversation/message not found, reply target not in conversation |
UNAUTHENTICATED | 401 | Missing or invalid JWT |