← All posts
Tutorial

How to Sync WhatsApp Read and Unread State in a Shared Inbox

A shared inbox should mark a WhatsApp conversation as read only when your team’s workflow has actually accepted or resolved it—not merely when a webhook arrives. With UnifyPort, call the conversation read endpoint with conversation_id; optionally include both the last message ID and its sender ID for a message-level WhatsApp receipt. Mark the conversation unread when it needs another pass.

Key takeaways

  • Read/unread conversation actions are currently supported for WhatsApp; unsupported provider/action combinations return 501 unsupported_by_provider.
  • POST /v1/accounts/{account_id}/conversations/read accepts conversation_id and can optionally mark through one specific message.
  • up_to_message_id and up_to_message_sender_id are a pair. Sending only one returns 400 invalid_request.
  • Marking a conversation unread requires only conversation_id.
  • Keep your own assignment and resolution state. Provider read state is a useful projection, not your complete support database.

Separate three kinds of “read” state

A reliable inbox starts by keeping three concepts separate:

  1. Your queue state: new, assigned, waiting, resolved, or another state defined by your application.
  2. The connected WhatsApp account’s chat-list state: read or unread. The read conversation endpoint and unread conversation endpoint change this state.
  3. A recipient receipt: the message.read webhook event says the recipient read one or more messages sent through the account. It is not the same as an agent opening an inbound ticket.

UnifyPort can also map a conversation.updated event when a local conversation setting changes. Its data.conversation.id identifies the chat, and a read-state update can carry data.read. Treat that event as a reconciliation signal. Your database should still record who accepted the ticket, when they did it, and why it was reopened.

Before applying any event, verify the signed raw body and make retries idempotent. The webhook HMAC and retry guide covers that receiver boundary. If this endpoint only needs inbox events, use explicit subscriptions rather than collecting everything; see the webhook event filters tutorial.

Choose when provider state should change

Do not mark every incoming webhook as read. That makes an unattended queue look healthy. Use an explicit policy instead:

Team actionLocal queue stateWhatsApp action
Inbound message storednewNone
Agent accepts the conversationassignedMark through the accepted message, if desired
Agent resolves the conversationresolvedMark the whole conversation read
Agent flags follow-upwaitingMark the conversation unread
Automation fails before assignmentnewNone

This separation also prevents a browser refresh, webhook retry, or background preview from clearing work accidentally.

Mark a WhatsApp conversation read

The request body puts conversation_id in JSON rather than in the URL because provider identifiers may contain characters such as @ or :.

To mark the whole conversation read:

curl -X POST "https://api.unifyport.ai/v1/accounts/$UNIFYPORT_ACCOUNT_ID/conversations/read" \
  -H "X-Api-Key: $UNIFYPORT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "conversation_id": "8613912345678@s.whatsapp.net"
  }'

To send a WhatsApp receipt through one known inbound message, copy all three identifiers from the same message.received event:

{
  "conversation_id": "120363041234567890@g.us",
  "up_to_message_id": "CURRENT-MESSAGE-ID",
  "up_to_message_sender_id": "8613912345678@lid"
}

For group messages, up_to_message_sender_id must be the matching data.sender.id. Do not derive it from the conversation ID. If you do not need a message-specific receipt, omit both up_to_message_* fields.

A small Node.js helper can enforce the pair before making the request:

const apiBase = 'https://api.unifyport.ai/v1';

async function setWhatsAppReadState({ accountId, conversationId, unread, message }) {
  const action = unread ? 'unread' : 'read';
  const body = { conversation_id: conversationId };

  if (!unread && message) {
    if (!message.id || !message.senderId) {
      throw new Error('message.id and message.senderId must be supplied together');
    }
    body.up_to_message_id = message.id;
    body.up_to_message_sender_id = message.senderId;
  }

  const response = await fetch(
    `${apiBase}/accounts/${encodeURIComponent(accountId)}/conversations/${action}`,
    {
      method: 'POST',
      headers: {
        'X-Api-Key': process.env.UNIFYPORT_API_KEY,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify(body)
    }
  );

  if (!response.ok) {
    const failure = await response.json();
    throw new Error(`${response.status} ${failure.error?.code ?? 'unknown_error'}`);
  }

  return response.json();
}

Call it from a verified event handler using documented fields:

await setWhatsAppReadState({
  accountId: event.account_id,
  conversationId: event.data.conversation.id,
  unread: false,
  message: {
    id: event.data.message.id,
    senderId: event.data.sender.id
  }
});

Mark a conversation unread for follow-up

The unread action is intentionally smaller:

await setWhatsAppReadState({
  accountId: event.account_id,
  conversationId: event.data.conversation.id,
  unread: true
});

Use it when the team deliberately reopens work. Do not use provider unread state as the only reminder mechanism: store the follow-up owner, due state, and reason in your own queue.

Reconcile without creating a feedback loop

When your application calls a conversation action, a corresponding conversation.updated event may reach your webhook. Tag locally initiated operations with an internal operation record so the incoming event confirms state instead of triggering the same action again.

A safe flow is:

  1. Store the inbound message.received event idempotently.
  2. Update your local ticket state in a transaction.
  3. Call the provider-state action.
  4. Record success only after the API returns { "data": { "ok": true } }.
  5. Consume conversation.updated as confirmation or an external change from the connected account.
  6. If state is uncertain, fetch that specific conversation with the Get conversation reference and compare unread_count.

Check the current provider action support matrix before enabling the control for another channel. A normalized route does not imply every provider supports every action.

Limitations and trade-offs

The official provider path may be the better fit when your workflow depends on provider-certified business features, official template messaging, or native governance. UnifyPort’s unofficial interface is designed for ordinary-account messaging workflows, but it does not turn every provider into a feature-identical system.

For this workflow specifically, read/unread conversation actions are currently WhatsApp-only. Your shared inbox should hide or disable these controls for other providers rather than treating 501 unsupported_by_provider as a transient retry.

FAQ

Does receiving message.received automatically mark the chat read?

No. Receiving and storing the event should not clear the queue. Call the read endpoint only at the workflow point your team chooses.

What is the difference between message.read and marking a conversation read?

message.read is a receipt event about messages read by a recipient. The conversation action changes the connected account’s local chat-list read state.

Can I send only up_to_message_id?

No. Send up_to_message_id together with up_to_message_sender_id, or omit both to mark the whole conversation read.

Can I use the same read/unread controls for Telegram, LINE, TikTok, Zalo, and X?

Not currently. The provider support matrix lists the conversation read and unread actions as WhatsApp-only. Unsupported combinations return 501 unsupported_by_provider.

Next step

Start with the Mark conversation as read API reference, then add the unread action only after your local reopen policy is defined.

Sources

Checked on August 21, 2026: