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.next_cursor 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-200.
cursorstringOpaque cursor from the previous response's pagination.next_cursor. 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": {
"next_cursor": "eyJsYXN0SWQiOjQxMjk4OH0",
"has_more": 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-200.
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": {
"next_cursor": null,
"has_more": 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.created_at
let cursor: string | null | undefined
do {
const page = await repull.conversations.messages(thread.id, {
cursor: cursor ?? undefined,
order: 'asc',
limit: 200,
})
for (const msg of page.data) {
if (msg.sent_at > lastSeen) renderInUi(msg)
}
cursor = page.pagination.next_cursor
} 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.
const inbox: Conversation[] = []
let cursor: string | null | undefined
do {
const { data, pagination } = await repull.conversations.list({
status: 'open',
cursor: cursor ?? undefined,
limit: 200,
})
for (const t of data) if (t.unread_count > 0) inbox.push(t)
cursor = pagination.next_cursor
} 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.has_more, never on a count.
async function* allThreads() {
let cursor: string | null | undefined
do {
const page = await repull.conversations.list({
cursor: cursor ?? undefined,
limit: 200,
})
for (const t of page.data) yield t
cursor = page.pagination.next_cursor
} 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.