Skip to main content

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"
}
}
Pinned conversations

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"
}
}
Message ordering

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
}
}
Polling

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 participant
  • FORBIDDEN — 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 objectPath
  • FORBIDDEN — Not a participant in this conversation
  • NOT_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"
  • Auto-marks conversation as read for the sender
  • Publishes messageReceived subscription to all participants (including sender with isSelf: true)
  • Publishes unreadMessageCountChanged to non-sender participants
  • Publishes readReceiptChanged to non-sender participants (so they see the sender's blue ticks)
  • Creates MESSAGE_RECEIVED notification 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 found
  • FORBIDDEN — Can only edit your own messages
  • BAD_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 found
  • FORBIDDEN — Can only delete your own messages

Side effects:

  • Clears message content and sets status = DELETED
  • Deletes all MessageAttachment records from the database
  • Removes attachment files from S3 (fire-and-forget, non-blocking)
Deleted message display

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 lastReadAt and lastReadMessageId for the participant
  • Publishes unreadMessageCountChanged subscription to the reader
  • Publishes readReceiptChanged subscription to all other active participants in the conversation
When to call

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 found
  • BAD_REQUEST — Can only update group conversations
  • FORBIDDEN — 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 found
  • BAD_REQUEST — Can only add participants to group conversations
  • FORBIDDEN — 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 found
  • BAD_REQUEST — Can only remove participants from group conversations
  • FORBIDDEN — 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
}
}
note

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 found
  • BAD_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 MessageOutput including 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 messageReceived event 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)
  • lastSeenAt is the timestamp of the last heartbeat or activity
  • Initial presence state should be loaded via the contactPresence query, 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 markConversationAsRead or sends a message (which auto-marks as read)
  • userId is the user who read the messages (NOT the subscriber)
  • lastReadAt is the timestamp up to which the user has read
  • lastReadMessageId is 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
Read receipt channel

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

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

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 StateDisplay
status === 'SENT'Normal message bubble with content
status === 'DELETED'Italic gray "This message was deleted"
isEdited === trueNormal content + small "(edited)" label
replyTo !== nullShow reply preview bar above the message
attachments.length > 0Show attachment cards below the content (filename, size, download link)
content === null && attachments.length > 0Attachment-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:

  1. Search for users — Use the existing companyUsers(companyId) or a user search query
  2. Select a single user → Call createDirectConversation (deduplicates automatically)
  3. Select multiple users + enter title → Call createGroupConversation
  4. 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

ErrorHTTPWhen
BAD_REQUEST400Self-conversation, empty group, edit deleted message, non-group operations
FORBIDDEN403Not a participant, insufficient role, no company access
NOT_FOUND404Conversation/message not found, reply target not in conversation
UNAUTHENTICATED401Missing or invalid JWT