DEV Community

unifyport for UnifyPort

Posted on Originally published at unifyport.ai

Building Reliable WhatsApp Read/Unread Sync for a Shared Inbox

A shared inbox receives a new WhatsApp message.

Should the conversation immediately become “read”?

Usually, no.

Receiving a webhook only proves that your system received an event. It does not prove that an agent accepted the conversation, understood the request, or completed the work.

A reliable shared inbox must keep three different kinds of state separate:

  1. Your application’s queue state.
  2. The connected WhatsApp account’s read/unread state.
  3. Message-level read receipts.

Combining these concepts can make unattended conversations disappear from the queue or create synchronization loops between your application and WhatsApp.

This article uses the UnifyPort API to demonstrate a safer design.

Three meanings of “read”

The word “read” can refer to three different things in a messaging system.

1. Local queue state

This is the workflow state stored by your application:

new
assigned
waiting
resolved
Enter fullscreen mode Exit fullscreen mode

Your database should also know:

  • which agent owns the conversation;
  • when it was accepted;
  • why it was reopened;
  • whether a response is still required;
  • when the next follow-up is due.

This is your support system’s source of truth.

2. WhatsApp conversation state

The connected WhatsApp account also maintains a chat-list state:

read
unread
Enter fullscreen mode Exit fullscreen mode

Changing this state affects how the conversation appears in the connected account.

It does not replace your application’s assignment and resolution model.

3. Message read receipts

A message.read event describes a recipient reading one or more messages sent through the account.

That is different from an agent opening an inbound support ticket.

The three states may influence one another, but they should not be stored as one Boolean value.

Define an explicit synchronization policy

Do not mark every conversation as read as soon as message.received reaches your webhook.

That policy can make an unattended queue look healthy.

Instead, connect provider state to meaningful team actions:

Team action Local queue state WhatsApp action
Inbound message stored new None
Agent accepts the conversation assigned Optionally mark through the accepted message
Agent resolves the conversation resolved Mark the conversation read
Agent requests follow-up waiting Mark the conversation unread
Automation fails before assignment new None

This prevents webhook retries, background previews, and browser refreshes from clearing work accidentally.

Mark a WhatsApp conversation as read

To mark an entire conversation as read, call:

POST /v1/accounts/{account_id}/conversations/read
Enter fullscreen mode Exit fullscreen mode

Pass the provider conversation identifier in the JSON body:

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"
  }'
Enter fullscreen mode Exit fullscreen mode

The conversation_id belongs in the body instead of the URL because provider identifiers may contain characters such as @ and :.

Mark through a specific message

For a message-level WhatsApp receipt, include both:

  • up_to_message_id
  • up_to_message_sender_id

Example:

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

These two fields form a pair.

Sending only one of them returns:

400 invalid_request
Enter fullscreen mode Exit fullscreen mode

Copy all three identifiers from the same verified message.received event:

const identifiers = {
  conversationId: event.data.conversation.id,
  messageId: event.data.message.id,
  senderId: event.data.sender.id,
};
Enter fullscreen mode Exit fullscreen mode

For a group conversation, do not derive the sender ID from the conversation ID. Use the matching event.data.sender.id.

If you do not need a message-specific receipt, omit both up_to_message_* fields and mark the whole conversation as read.

Build a small read-state helper

A helper can enforce the field-pair rule 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();
}
Enter fullscreen mode Exit fullscreen mode

Keep the API key on the server. Do not call this endpoint directly from browser code.

After verifying and storing an inbound event, an agent-acceptance workflow could call:

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

Mark a conversation unread

When an agent intentionally reopens a conversation, call the unread action:

POST /v1/accounts/{account_id}/conversations/unread
Enter fullscreen mode Exit fullscreen mode

Using the same helper:

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

The unread action only requires conversation_id.

Do not use the provider’s unread state as your only reminder mechanism. Store the follow-up owner, reason, and due time in your own database.

For example:

await saveFollowUp({
  conversationId: event.data.conversation.id,
  ownerId: agent.id,
  reason: "Waiting for customer documents",
  dueAt: nextBusinessDay,
});

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

If the provider action fails, the local follow-up record still preserves the work that must be completed.

Avoid synchronization feedback loops

When your application changes conversation state, a corresponding conversation.updated event may arrive at your webhook.

A naive implementation can produce a loop:

Application marks conversation read
        ↓
conversation.updated arrives
        ↓
Webhook handler marks conversation read again
        ↓
Another update arrives
Enter fullscreen mode Exit fullscreen mode

Treat conversation.updated as a reconciliation signal, not a command to repeat the same action.

A safer workflow is:

1. Verify the webhook signature
2. Store the event idempotently
3. Update the local ticket state
4. Record the intended provider-state operation
5. Call the read/unread endpoint
6. Record success after the API confirms it
7. Use conversation.updated as confirmation
Enter fullscreen mode Exit fullscreen mode

An operation record could contain:

const operation = {
  conversationId,
  requestedState: "read",
  source: "agent_acceptance",
  status: "pending",
};
Enter fullscreen mode Exit fullscreen mode

When conversation.updated arrives, compare it with the pending operation.

If it matches, confirm the operation instead of issuing the API request again.

If there is no matching local operation, the state may have changed from the connected WhatsApp account or another system. Reconcile it according to your product policy.

Store webhook events idempotently

Webhook delivery can be retried.

Before updating queue state, ensure the event has not already been processed:

async function handleWebhook(event) {
  const existing = await findProcessedEvent(event.id);

  if (existing) {
    return;
  }

  await database.transaction(async (transaction) => {
    await transaction.saveEvent(event);
    await transaction.updateConversation(event);
  });
}
Enter fullscreen mode Exit fullscreen mode

The exact database implementation will vary, but the invariant should remain:

Processing the same webhook more than once must not create additional state transitions.

Also verify the webhook signature against the raw request body before trusting any event fields.

Keep local state authoritative

Provider read state is useful for synchronization and operator visibility, but it is not a complete support database.

The provider does not know:

  • which agent accepted the ticket;
  • whether the issue was resolved;
  • why the conversation was reopened;
  • whether a service-level deadline is approaching;
  • which internal team owns the next action.

A practical local model might look like this:

const conversation = {
  id: "local-conversation-id",
  providerConversationId: "8613912345678@s.whatsapp.net",
  queueState: "assigned",
  providerReadState: "read",
  assignedAgentId: "agent-42",
  followUpAt: null,
  lastProcessedEventId: "event-123",
};
Enter fullscreen mode Exit fullscreen mode

Here, providerReadState is a projection of WhatsApp state. It does not determine whether the support task is finished.

Handle unsupported providers correctly

Read and unread conversation actions are currently supported for WhatsApp.

An unsupported provider/action combination returns:

501 unsupported_by_provider
Enter fullscreen mode Exit fullscreen mode

Do not treat this response as a temporary network failure. Retrying the same unsupported operation will not make it succeed.

For a multi-channel inbox, check capabilities before displaying the control:

function canChangeProviderReadState(provider) {
  return provider === "whatsapp";
}
Enter fullscreen mode Exit fullscreen mode

If the provider is unsupported:

  • hide or disable the provider-state control;
  • keep the local queue workflow available;
  • explain that the action only affects internal state;
  • do not continuously retry 501 unsupported_by_provider.

A unified API route does not imply that every provider implements every action.

Implementation checklist

Before enabling read/unread synchronization, confirm that:

  • [ ] Queue state and provider read state are stored separately.
  • [ ] Receiving message.received does not automatically clear work.
  • [ ] Webhook signatures are verified using the raw request body.
  • [ ] Webhook processing is idempotent.
  • [ ] up_to_message_id and up_to_message_sender_id are always sent together.
  • [ ] Group sender IDs come from event.data.sender.id.
  • [ ] Locally initiated operations are recorded.
  • [ ] conversation.updated is used for reconciliation.
  • [ ] Follow-up ownership and reasons are stored locally.
  • [ ] Unsupported providers do not receive repeated read/unread requests.
  • [ ] API keys remain on the server.

Takeaway

A reliable shared inbox should not reduce every kind of read state to one Boolean value.

Keep these concepts separate:

Local queue state
Provider conversation state
Message receipt state
Enter fullscreen mode Exit fullscreen mode

Change the WhatsApp read state only when a meaningful workflow action occurs, such as agent acceptance, resolution, or an intentional follow-up.

Then treat provider events as reconciliation signals while keeping assignment, ownership, and resolution state in your own database.

That separation prevents unattended conversations from disappearing and keeps your shared inbox reliable when webhooks are retried or multiple systems update the same chat.

References


This article was adapted from an original UnifyPort technical guide with AI-assisted editing.

Top comments (0)