Send a Message

Message a guest on the channel their conversation already uses — Airbnb, Booking.com, SMS, email or your direct-booking site — with one call, and send photos and videos where the channel can carry them.

When to use this endpoint

  • Reply to a guest's question
  • Send check-in instructions, with a photo of the door or a parking map
  • Automate confirmation and reminder messages
  • Build an AI guest-communication workflow

Send a message

POST/v1/conversations/{id}/messages

{id} is the Repull conversation id from GET /v1/conversations. The message goes out on the conversation's own channel, so a guest who booked on Airbnb gets it in their Airbnb inbox, and it is recorded in the conversation.

curl -X POST "https://api.repull.dev/v1/conversations/164743/messages" \
  -H "Authorization: Bearer sk_live_YOUR_KEY" \
  -H "Idempotency-Key: checkin-164743" \
  -H "Content-Type: application/json" \
  -d '{"message": "Your check-in details are ready. The door code is active from 16:00."}'

Body parameters

messagestring

The text to send, up to 4000 characters. Required unless you send attachments.

attachmentsarray

Files to send, 1 to 5, each by URL. See Attachments below.

urlstringRequired

Public https:// URL of the file, up to 2048 characters. A signed URL valid for a few minutes is fine.

contentTypestring

Optional hint such as image/jpeg. The real type is read from the file itself; this never overrides it.

filenamestring

Optional display name, up to 200 characters. Defaults to the last segment of the URL.

channelstring

airbnb, booking, sms, email or website. Omit it: the conversation's own channel is the right default. Pass it only to force a specific one.

Unknown fields are refused by name with 422 invalid_params, so a typo such as text for message can never look like a successful send.

Response

{
  "id": "1847911",
  "conversationId": "164743",
  "externalMessageId": "32837172381",
  "channel": "airbnb",
  "status": "sent",
  "direction": "outbound",
  "contentRewritten": false,
  "submittedContent": "Your check-in details are ready. The door code is active from 16:00.",
  "deliveredContent": "Your check-in details are ready. The door code is active from 16:00.",
  "statusReason": null,
  "attachments": []
}
idstringnullable

Repull's id for the message. With attachments on Airbnb, the text message, or the last file message when there is no text.

externalMessageIdstringnullable

The channel's own id for the message.

channelstringnullable

The channel it went out on.

contentRewrittenboolean

true when the channel changed the text before delivering it. See below.

submittedContentstringnullable

The text you sent.

deliveredContentstringnullable

The text the guest actually received.

statusReasonstringnullable

The channel's own note, when it gave one.

attachmentsarray

The files delivered, in request order. Empty for a text-only send.

partsarray

Present only when attachments were sent: one entry per channel message the send produced.

Check contentRewritten

Airbnb refuses guest messages that contain a link, an email address or a phone number. When the offending part can be removed, it is removed and the rest is delivered, so the guest receives a message that is not quite the one you wrote. The response says so with contentRewritten: true, and deliveredContent is what arrived. When nothing useful is left, nothing is sent and the call returns 422 message_not_sentwith the channel's own words in statusReason.

Attachments

Send photos and videos by URL in attachments. Repull downloads each file, reads its real type from the file's bytes, keeps a durable copy, and delivers it through the channel's own file mechanism.

curl -X POST "https://api.repull.dev/v1/conversations/164743/messages" \
  -H "Authorization: Bearer sk_live_YOUR_KEY" \
  -H "Idempotency-Key: parking-map-164743" \
  -H "Content-Type: application/json" \
  -d '{
    "message": "Here is the parking map. The gate code is 4821.",
    "attachments": [
      { "url": "https://cdn.example.com/parking-map.jpg", "filename": "parking-map.jpg" }
    ]
  }'

The URL

  • Public https:// only. No private or internal addresses, no username or password in the URL, and at most 3 redirects (each one is checked too). A URL that breaks these rules returns attachment_url_not_allowed.
  • Private files are fine behind a short-lived signed URL (S3, Google Cloud Storage, Supabase and the like). A few minutes is plenty: the file is copied during the request, and the message links to Repull's copy from then on.
  • The URL must answer within 20 seconds with the file itself. An error status, an empty body or a timeout returns attachment_unreachable.
  • Uploading the file in the request body is not supported.

What each channel accepts

1 to 5 files per request, each up to 10 MB, on every channel that takes files.

ChannelFile typesTextHow it arrives
AirbnbJPEG, PNG, GIF, WebP (images are delivered as JPEG); MP4, QuickTimeOptionalEach file as its own message, in order, then the text as a separate message
Booking.comJPEG, PNGRequired (attachment_requires_message)One message carrying the text and every file
SMS, email, site chatNo files. 422 attachments_not_supported, and nothing is sent. Put a link to the file in message instead.
  • The type is read from the file, not from contentType, the file name or the URL. A type the channel does not take returns attachment_type_not_supported. Documents such as PDFs are not accepted as files on either channel; send a link.
  • Airbnb does not allow files in a conversation where the guest has not booked yet (an inquiry). That refusal comes back as message_not_sent.

Every file is checked before anything is sent

If one file is unreachable, too large or of a type the channel refuses, the call returns 422 naming the file (index and field, such as attachments[1]) and the guest receives nothing, not even the text.

What the response tells you

attachments lists each file as delivered, and parts lists every channel message the send produced. On Airbnb a caption and one photo are two messages, photo first:

{
  "id": "1847911",
  "conversationId": "164743",
  "externalMessageId": "32837172381",
  "channel": "airbnb",
  "status": "sent",
  "direction": "outbound",
  "contentRewritten": false,
  "submittedContent": "Here is the parking map. The gate code is 4821.",
  "deliveredContent": "Here is the parking map. The gate code is 4821.",
  "statusReason": null,
  "attachments": [
    {
      "url": "https://files.example.com/message-attachments/outbound/1/1714575600000-k3j9x2m1q.jpg",
      "type": "image",
      "contentType": "image/jpeg",
      "filename": "parking-map.jpg",
      "sizeBytes": 184233,
      "sourceUrl": "https://cdn.example.com/parking-map.jpg"
    }
  ],
  "parts": [
    { "attachmentIndexes": [0], "hasText": false, "sent": true, "messageId": "1847910", "externalMessageId": "32837172380", "error": null },
    { "attachmentIndexes": [], "hasText": true, "sent": true, "messageId": "1847911", "externalMessageId": "32837172381", "error": null }
  ]
}
attachments[].urlstring

Repull's stored copy. It keeps working after your own URL expires, and it is the same url you will see when you read the message back. Treat it as opaque.

attachments[].typestring

image or video.

attachments[].contentTypestring

The type read from the file's bytes.

attachments[].sourceUrlstring

The URL you sent.

parts[].attachmentIndexesarray

Which of your attachments this channel message carried, as indexes into the request's attachments.

parts[].hasTextboolean

Whether this message carried the text.

parts[].sentboolean

Whether it reached the guest.

parts[].errorstringnullable

Why this part was not delivered.

When only part of it arrives

Because Airbnb delivers files one message at a time, a later file can be refused after earlier ones arrived. Then the call returns 422 message_partially_sent with the same parts. Sending stops at the first refusal, so later files and the text are not attempted. Do not resend the whole request: resend only what no sent: true part carried, with a new Idempotency-Key. Booking.com sends everything as one message, so it never partially sends.

Attachment errors

All are 422 and mean nothing was sent, except the last two.

Reading attachments back

Every message from GET /v1/conversations/{id}/messages carries an attachments array: photos the guest sent and files you sent, in one shape. It is empty when there are none, and a message that is only a file has an empty body. The reservation.message.received webhook carries the same array, so a photo a guest sends reaches you in the delivery itself.

"attachments": [
  {
    "id": "88412",
    "url": "https://files.example.com/message-attachments/1847801-1714575600000-k3j9x2m1q.jpg",
    "imageUrl": "https://files.example.com/message-attachments/1847801-1714575600000-k3j9x2m1q.jpg",
    "type": "image",
    "contentType": "image/jpeg",
    "createdAt": "2026-05-01T15:00:02.000Z"
  }
]
  • url is where to download the file. Files are copied to durable storage, so it keeps working after the channel's own link expires.
  • type is image, video, audio or file, from contentType.
  • imageUrl is the same value as url, kept for older clients even though not every file is an image. Use url.

Conversations a PMS relays

When the conversation comes from a connected PMS (Guesty, Hostaway), the message is sent through the PMS. Omit channelto send on the conversation's own channel. To choose, pass the PMS's own name — Guesty airbnb2, platform, email, sms, whatsapp or note (an internal note the guest does not see); Hostaway channel, email, sms, whatsapp— or one of Repull's, which the PMS maps. Neither PMS can send files through its API: a message with attachments answers 422 pms_write_unsupported and nothing is sent. See Writing through a PMS.

Retrying safely

Send an Idempotency-Key header. Without one, retrying after a network timeout can send the guest the same message twice. With one, a repeat with the same key returns the first response instead of sending again. Answers where nothing was sent are not stored: server errors (5xx), 408, 425 and 429, and refusals such as connection_reauth_required. Retry those with the same key. Any other answer is stored, so after fixing a 4xx, send the corrected request with a new key. See Which responses are stored.

  • message_send_failed — the send failed for an unclassified reason, usually the channel being down; retry with the same key
  • service_misconfigured — 500 with retryable: false, a fault on our side; nothing was sent, so do not resend in a loop
  • idempotency_key_in_use — the first attempt with this key is still running; wait and retry with the same key
  • idempotency_key_reused — the key was already used for a different body; use a new key per message
  • invalid_json — the body is not valid JSON; nothing was sent

Channel-specific endpoints

If you only hold a channel's own ids, each channel also has its own send endpoint. They are narrower than the one above:

  • POST /v1/channels/airbnb/messaging/{threadId}/messages takes the Airbnb thread id and {"message"}, or one file as mediaUrl (same types, size limit and errors as above; the text follows the file as a separate message). See Airbnb Guest Messaging.
  • POST /v1/channels/booking/messaging takes property_id, conversation_id and message, and sends text only: a file field returns 422 attachments_not_supported. See Booking.com Guest Messaging.

API Reference

See the Send Message reference and the rest of the Conversations API.

AI