Conversations
Read your guest message threads and the messages inside them across every connected channel — Airbnb, Booking.com, VRBO, website, email — through one API. One thread per reservation; messages stream back with sender, channel, direction, and delivery state.
What you get
- Unified inbox— one paginated feed of threads across every channel you've connected, ordered by most recent activity.
- Single-thread expand— pull the host + guest blocks (with contacts) for any thread by ID, no extra round-trip needed.
- Message history— cursor-paginated messages within a thread, with direction (
inbound/outbound), source channel, attachments, and the original platform timestamp. - Filterable— narrow by platform or status at the list level so you don't pull the world client-side.
Push, don't poll
conversation.updated via webhooks and only re-fetch the threads that changed.List threads
Returns your conversation threads ordered by most recent message. Cursor-paginated — pass the previous response's pagination.nextCursor back as cursor on the next call.
/v1/conversationscurl 'https://api.repull.dev/v1/conversations?limit=50&platform=airbnb&status=open' \ -H 'Authorization: Bearer sk_live_...'
Query parameters
limitintegerDefault: 50Max threads per page. 1-100; over 100 returns 422.
cursorstringOpaque cursor from the previous response's pagination.nextCursor. Omit on the first call.
platformstringFilter by source channel. One of airbnb, booking, vrbo, website, email.
statusstringFilter by thread status. One of open, archived. Defaults to open.
Response
{
"data": [
{
"id": 412987,
"platform": "airbnb",
"guest_id": 88341,
"listing_id": 4118,
"reservation_id": 216039,
"subject": null,
"last_message_at": "2026-04-30T18:14:09.000Z",
"last_message_preview": "What time can I check in?",
"unread_count": 2,
"status": "open",
"created_at": "2026-04-22T09:01:14.000Z",
"updated_at": "2026-04-30T18:14:09.000Z"
},
{
"id": 412988,
"platform": "booking",
"guest_id": 88342,
"listing_id": 4119,
"reservation_id": 216064,
"subject": null,
"last_message_at": "2026-04-30T17:02:11.000Z",
"last_message_preview": "Grazie!",
"unread_count": 0,
"status": "open",
"created_at": "2026-04-21T11:24:02.000Z",
"updated_at": "2026-04-30T17:02:11.000Z"
}
],
"pagination": {
"nextCursor": "eyJsYXN0SWQiOjQxMjk4OH0",
"hasMore": true
}
}idintegerStable internal thread id. Pass to /v1/conversations/{id}.
platformstringSource channel: airbnb, booking, vrbo, website, email.
guest_idintegernullableID of the linked guest. Hydrate via /v1/guests/{id}.
listing_idintegernullableID of the listing the thread belongs to.
reservation_idintegernullableID of the linked reservation.
subjectstringnullableThread subject (email/website channels) or null.
last_message_atstringnullableISO 8601 timestamp of the most recent message.
last_message_previewstringnullableShort preview of the most recent message body.
unread_countintegerInbound messages the host hasn't read yet.
statusstringopen or archived.
created_atstringISO 8601 timestamp the thread was first created in Repull.
updated_atstringISO 8601 timestamp of the last server-side update.
Get one thread
Returns the full ConversationDetail: every field on the list-row plus inline host and guestblocks (with up to 50 contacts), so a thread-detail header doesn't need a second round-trip.
/v1/conversations/{id}curl 'https://api.repull.dev/v1/conversations/412987' \ -H 'Authorization: Bearer sk_live_...'
Response
{
"id": 412987,
"platform": "airbnb",
"guest_id": 88341,
"listing_id": 4118,
"reservation_id": 216039,
"subject": null,
"last_message_at": "2026-04-30T18:14:09.000Z",
"last_message_preview": "What time can I check in?",
"unread_count": 2,
"status": "open",
"created_at": "2026-04-22T09:01:14.000Z",
"updated_at": "2026-04-30T18:14:09.000Z",
"host": {
"id": 4,
"airbnbId": "1234567",
"firstName": "Coastal",
"displayName": "Coastal Stays Lisbon",
"avatarUrl": "https://images.repull.dev/h/4.jpg"
},
"guest": {
"id": 88341,
"displayName": "Sarah Mitchell",
"avatarUrl": "https://images.repull.dev/g/88341.jpg",
"contacts": [
{ "type": "email", "value": "sarah.mitchell@example.com", "isPrimary": true, "isVerified": true },
{ "type": "phone", "value": "+15551234567", "isPrimary": false, "isVerified": false }
]
}
}Field-name conventions
snake_case (last_message_at, unread_count) to match the underlying threads model. Nested host/guest blocks use camelCase (displayName, isPrimary) because they're shared with the guests endpoints. Both are stable.Messages within a thread
Cursor-paginated. Defaults to oldest-first so you can append straight into a chat view; pass order=desc to walk back from the latest message.
/v1/conversations/{id}/messagescurl 'https://api.repull.dev/v1/conversations/412987/messages?limit=100&order=asc' \ -H 'Authorization: Bearer sk_live_...'
Query parameters
limitintegerDefault: 100Max messages per page. 1-100; over 100 returns 422.
cursorstringOpaque cursor from the previous response. Omit on the first call.
orderstringDefault: ascSort order. asc returns oldest first, desc returns newest first.
Response
{
"data": [
{
"id": 9912034,
"external_message_id": "abnb_msg_abcd1234",
"direction": "inbound",
"sender_type": "guest",
"sender_name": "Sarah Mitchell",
"sender_avatar": "https://images.repull.dev/g/88341.jpg",
"channel": "airbnb",
"body": "Hi! What time can I check in tomorrow?",
"translated_body": null,
"attachments": [],
"is_automated": false,
"ai_generated": false,
"sent_at": "2026-04-30T17:58:42.000Z",
"delivered_at": "2026-04-30T17:58:43.000Z",
"read_at": null
},
{
"id": 9912035,
"external_message_id": "abnb_msg_abcd1235",
"direction": "outbound",
"sender_type": "host",
"sender_name": "Coastal Stays Lisbon",
"sender_avatar": "https://images.repull.dev/h/4.jpg",
"channel": "airbnb",
"body": "Anytime after 3pm — door code is in your check-in guide.",
"translated_body": null,
"attachments": [],
"is_automated": true,
"ai_generated": true,
"sent_at": "2026-04-30T18:01:11.000Z",
"delivered_at": "2026-04-30T18:01:12.000Z",
"read_at": "2026-04-30T18:14:01.000Z"
}
],
"pagination": {
"nextCursor": null,
"hasMore": false
}
}idintegerStable internal message id.
external_message_idstringnullableID assigned by the source channel. Stable across syncs.
directionstringinbound (from guest) or outbound (from host or automation).
sender_typestringnullableFree-form role from the channel: guest, host, system, airbnb, etc. Use direction for binary logic.
sender_namestringDisplay name of the sender.
channelstringnullableDelivery channel: airbnb, booking, sms, email, etc.
bodystringMessage body in the original language.
translated_bodystringnullableEnglish translation when the original is non-English.
attachmentsarrayInline attachments (images, etc.).
is_automatedbooleanTrue when sent by a Vanio automation (template, schedule).
ai_generatedbooleanTrue when the body was authored by Vanio AI.
sent_atstringISO 8601 timestamp of the original send (from the source platform).
delivered_atstringISO 8601 timestamp of channel-side delivery.
read_atstringnullableISO 8601 timestamp the recipient read the message.
Common patterns
Stream new messages
Subscribe to conversation.updated via webhooks and re-fetch only threads with a fresh last_message_at. Polling the list endpoint at less than one minute is wasteful — the webhook fires within seconds.
// In your webhook handler:
// POST /your-handler { type: "conversation.updated", data: { id } }
const thread = await repull.conversations.get(event.data.id)
// Walk only the messages you haven't seen yet
const lastSeen = lastSeenAt[thread.id!] ?? thread.createdAt!
let cursor: string | undefined
do {
const page = await repull.conversations.messages(thread.id!, {
cursor,
order: 'asc',
limit: 100,
})
for (const msg of page.data) {
if (msg.sentAt! > lastSeen) renderInUi(msg)
}
cursor = page.pagination.nextCursor ?? undefined
} while (cursor)
lastSeenAt[thread.id!] = new Date().toISOString()Filter unread only
The cheapest unread query: list with status=open and filter on unread_count > 0 client-side. Cheaper than per-thread message scans.
import type { Conversation } from '@repull/sdk'
const inbox: Conversation[] = []
let cursor: string | undefined
do {
const { data, pagination } = await repull.conversations.list({ cursor, limit: 100 })
for (const t of data) if (t.status === 'open' && (t.unreadCount ?? 0) > 0) inbox.push(t)
cursor = pagination.nextCursor ?? undefined
} while (cursor)
console.log(inbox.length, 'threads need a reply')Pagination across thousands of threads
Cursors are stable across writes — new threads pushed in mid-walk won't shift your position. Always loop on pagination.hasMore, never on a count.
async function* allThreads() {
let cursor: string | undefined
do {
const page = await repull.conversations.list({ cursor, limit: 100 })
for (const t of page.data) yield t
cursor = page.pagination.nextCursor ?? undefined
} while (cursor)
}
for await (const thread of allThreads()) {
// process — works the same for 50 or 50,000
}Related
- Send a Message — post a reply back to the originating channel.
- Connect (multi-channel) — how Airbnb, Booking, VRBO, and direct accounts get linked into a single inbox.
- Guests — hydrate the guest behind a thread with full contacts, flags, and stay history.
- Webhooks — subscribe to
conversation.updatedfor push instead of poll.