Conversations
When to Use
Use this skill when building anything that reads an inbox: a shared inbox UI, a helpdesk sync, a "show me this customer's history" tool, or an agent that needs the thread before it answers.
GET /v1/messages returns a flat log with no thread to hang it on. Conversations are the thread.
What a conversation is
One thread with one contact, spanning every channel. A contact who writes on WhatsApp and later by email stays in the same conversation, and channels lists what it has carried.
| Field | Meaning |
|---|---|
id |
Thread ID. Same value as conversationId on messages and webhooks. |
contactIdentifier |
The key the thread is filed under. Not always a phone number — see below. |
contactId |
The contact, when resolved. Absent on group threads. |
channels |
Every channel this thread has carried. |
lastMessage |
Denormalized preview (id, text, channel, direction, at) so a thread list needs no extra fetch. |
senderId |
Sender that last handled the thread. Pass it back as Zavu-Sender when replying. |
unreadCount |
Inbound messages not yet marked read. |
whatsapp |
bsuid + username, when the contact adopted a WhatsApp username. |
group |
Present on group chats: id, subject, participantCount. |
contactIdentifier is not a phone number
It holds whichever identifier keys the thread: an E.164 phone, a WhatsApp BSUID (US.13491208655302741918), a numeric chat ID (Telegram/Instagram/Messenger), or a group JID (<id>@g.us). Parsing it as a phone number breaks on every non-SMS channel. Use channels and group to decide how to render it.
Endpoints
These are not in the generated SDKs yet. Use REST.
| Method | Path | Purpose |
|---|---|---|
GET |
/v1/conversations |
List threads, most recently active first. |
GET |
/v1/conversations/{conversationId} |
One thread. |
GET |
/v1/conversations/{conversationId}/messages |
Messages in the thread, newest first. |
POST |
/v1/conversations/{conversationId}/read |
Reset unreadCount to zero. |
List threads
curl "https://api.zavu.dev/v1/conversations?limit=25" \
-H "Authorization: Bearer $ZAVUDEV_API_KEY"
Filters: senderId scopes to one number, channel keeps only threads that have carried that channel.
curl "https://api.zavu.dev/v1/conversations?senderId=sender_12345&channel=whatsapp" \
-H "Authorization: Bearer $ZAVUDEV_API_KEY"
TypeScript:
const res = await fetch("https://api.zavu.dev/v1/conversations?limit=25", {
headers: { Authorization: `Bearer ${process.env.ZAVUDEV_API_KEY}` },
});
const { items, nextCursor } = await res.json();
Search threads
search finds a thread by who it is with: phone number, email address, WhatsApp group subject, WhatsApp username, or BSUID. Phone formatting does not matter — +1 (555) 123-4567 and 15551234567 both match the same thread.
curl "https://api.zavu.dev/v1/conversations?search=%2B56912345678" \
-H "Authorization: Bearer $ZAVUDEV_API_KEY"
# by email, by its local part, or by group name
curl "https://api.zavu.dev/v1/conversations?search=maria" \
-H "Authorization: Bearer $ZAVUDEV_API_KEY"
Matching is by whole word with prefix matching on the last term: mar finds maria@example.com, and +1555 finds +15551234567. A fragment from the middle or the end of a number (4567) does not match — search the full number or a prefix.
Three things to know before you build on it:
- It does not search message bodies.
searchmatches the thread's identity, never what was said inside it. - Results are ranked by relevance, not recency. The usual "most recently active first" ordering does not apply while
searchis set. - An empty
searchreturns nothing, not everything. Drop the parameter to list all threads.
search combines with senderId and channel, and paginates with cursor like any other list.
const params = new URLSearchParams({ search: "+56912345678", channel: "whatsapp" });
const res = await fetch(`https://api.zavu.dev/v1/conversations?${params}`, {
headers: { Authorization: `Bearer ${process.env.ZAVUDEV_API_KEY}` },
});
const { items } = await res.json();
Paginate
nextCursor is opaque. Pass it back verbatim as cursor; never build one by hand. nextCursor is null on the last page.
async function allConversations() {
const out = [];
let cursor: string | undefined;
do {
const url = new URL("https://api.zavu.dev/v1/conversations");
url.searchParams.set("limit", "100");
if (cursor) url.searchParams.set("cursor", cursor);
const res = await fetch(url, {
headers: { Authorization: `Bearer ${process.env.ZAVUDEV_API_KEY}` },
});
const page = await res.json();
out.push(...page.items);
cursor = page.nextCursor ?? undefined;
} while (cursor);
return out;
}
The channel filter is applied to each page after it is fetched, so a filtered page can come back shorter than limit — even empty — while nextCursor is still set. Keep paginating until nextCursor is null; do not stop on a short page.
Telling the two sides apart
Every message carries direction (inbound or outbound). Use it — status cannot tell them apart, because an inbound message is stored as delivered too. Deriving it by comparing to against contactIdentifier works for one-to-one threads and breaks on groups, where from is the participant rather than the thread key.
for (const m of items) {
const mine = m.direction === "outbound";
console.log(`${mine ? "→" : "←"} ${m.text}`);
}
Read a thread
curl "https://api.zavu.dev/v1/conversations/$CONV_ID/messages?limit=50" \
-H "Authorization: Bearer $ZAVUDEV_API_KEY"
Returns the same Message objects as GET /v1/messages, newest first, across every channel in the thread.
Mark read
curl -X POST "https://api.zavu.dev/v1/conversations/$CONV_ID/read" \
-H "Authorization: Bearer $ZAVUDEV_API_KEY"
This clears the counter in your inbox. It does not send a read receipt to the contact. To show a WhatsApp read receipt plus a typing indicator, use POST /v1/messages/{messageId}/typing on the inbound message instead.
Replying in a thread
Send with POST /v1/messages, addressing the thread's identifier and passing its senderId as Zavu-Sender so the reply leaves from the number the contact already knows.
const conv = await getConversation(conversationId);
await zavu.messages.send({
to: conv.contactIdentifier,
channel: conv.lastMessage.channel,
text: "Thanks — shipping today.",
"Zavu-Sender": conv.senderId,
});
The sender override is a header param inside the send params object, not a second argument.
Two constraints still apply when replying:
- WhatsApp 24-hour window. Outside it, free-form text is rejected with
whatsapp_window_closed; send an approved template instead. See thewhatsapp-templatesskill. - Channel availability. Reply on a channel the sender actually has. Trust
channelsonGET /v1/senders/{senderId}, not the presence of a phone number.
Getting a conversation from a webhook
message.inbound carries data.conversationId. It is null while the thread row is still being created — the first inbound message of a brand-new thread, or several near-simultaneous first messages from an unknown address.
Recovery order:
- Use
data.conversationIdwhen present. - Otherwise take it from the
conversation.newevent, which carries the id. - Otherwise
GET /v1/messages/{messageId}, whoseconversationIdis always populated.
Do not fall back to listing conversations and matching on the phone number: on a busy project the thread may not be at the top yet, and the identifier may be a BSUID rather than the number you expected.
Building an inbox
The API gives you threads, messages, and read state. It does not store assignment, open/done status, internal notes, or saved replies — that workspace state belongs to your app. Key it by conversation.id, which is stable across channels and survives WhatsApp identity re-keying (a BSUID-only thread that later gains a phone number keeps its id).
For live updates, subscribe a webhook to message.inbound, message.sent, message.delivered, message.read, message.failed, and conversation.new, then patch your local view. Polling GET /v1/conversations is the fallback when you cannot receive webhooks. See the webhook-setup skill.
Gotchas
| Trap | Reality |
|---|---|
Treating contactIdentifier as a phone |
It can be a BSUID, chat ID, or group JID. |
| Building a cursor | Cursors are opaque; only echo nextCursor back. |
| Stopping pagination on a short page | The channel filter shrinks pages. Stop on nextCursor === null. |
Expecting contactId everywhere |
Absent on group threads and unresolved contacts. |
Expecting /read to notify the contact |
It only clears your own counter. |
Assuming conversationId is always on message.inbound |
It is null on a brand-new thread. Recover it as described above. |