Messaging API docs
Pull PropertyHub in-app chats into an agent inbox. You only need the URLs, headers, and JSON fields on this page.
Overview
Use this API to pull PropertyHub in-app chats into your inbox (Pancake, a CRM, or any HTTPS webhook).
You only call the REST and webhook fields on this page. You do not need PropertyHub admin access. The shape is LINE-like so existing inbox code can be reused.
This API is the transport (auth, webhook, reply/push, history, message shapes). It does not specify how your CRM inbox looks. Each product decides that.
Connect in this order:
- Store
channelId,channelSecretandchannelAccessTokenfrom the agent (secret and token are shown once) - Register your HTTPS webhook with
PUT /channel/webhook/endpointthenPOST /channel/webhook/test - On each POST to your URL: verify the signature on the raw body, return 2xx quickly, persist the event, then process it asynchronously.
- Room key = the other person’s
userId(on a message webhook,source.userId). Do not key rooms onconversationId. That id is only forGET /conversations/:id/messages - You can send in two ways — see . A CRM can push using userId from
GET /conversations. reply is optional (LINE-shaped, 24h token from an inbound webhook). Neither path is billed differently on PropertyHub.
The rest of this page is the field-level spec.
Production
https://api.propertyhub.in.th/api/v1/messagingAuth on every REST call: Authorization: Bearer {channelAccessToken}
What each id means
These are the only ids you will see. Treat them as opaque strings. You do not need to know how PropertyHub stores them.
| Field you see | Meaning |
|---|---|
channelId / destinationch_9f2c... | This agent’s channel. Not a person. |
userIdph_user_42 | A PropertyHub person. On REST, push.to and /profile/:userId also accept 42. Webhooks always send ph_user_42. |
conversationId / id1042 | Use only to fetch message history. Not the chat-room key. |
message id88213 | One message. |
Chat room key = userId of the other person
On webhooks that is source.userId On a conversation row that is userId On push that is to
- On webhook_verify,
userIdis the agent who just connected. - On a message event,
source.userIdis the other person (the sender). destinationis this agent’schannelId. It is not a person. Do not use it as the room key or asto
Credentials
The agent turns on Messaging API in PropertyHub settings and copies three values:
| Value | What you do with it |
|---|---|
channelId | Store it. It arrives again as webhook destination. |
channelSecret | Verify webhook signatures. |
channelAccessToken | Bearer token for every REST call. |
If you issue a new secret or token, the previous value stops working immediately.
Buyer messages are forwarded to you only after a webhook URL is registered.
Register webhook
The agent must not paste your webhook URL into PropertyHub. After you save the token, you register it.
Production URLs must be public https. endpoint is the LINE field name. webhookUrl, active, and webhookEnabled are also accepted. A non-empty URL turns the webhook on unless you send active: false.
PUT /channel/webhook/endpoint
Content-Type: application/json
{ "endpoint": "https://your-inbox.example/webhooks/propertyhub" }PUT and GET return the same 200 object:
{
"endpoint": "https://your-inbox.example/webhooks/propertyhub",
"active": true,
"userId": "ph_user_42",
"displayName": "สมชาย",
"pictureUrl": "https://cdn.example/avatar.jpg"
}userId here is the connecting agent. Bind this inbox account to ph_user_42
There is no DELETE. Turn the webhook off with { "active": false } or { "endpoint": "" }
Hosted APIs require a public https URL. Private or loopback hosts are rejected. http://localhost works only against a local PropertyHub API. To register from your laptop against the hosted API, use an https tunnel such as ngrok or Cloudflare.
GET /channel/webhook/endpoint returns the same object.
POST /channel/webhook/test makes PropertyHub POST a webhook_verify event to your URL (signed, same as a real webhook). Your URL should respond 2xx. Then the REST call returns:
{
"success": true,
"timestamp": "2026-09-14T04:00:00.000Z",
"statusCode": 200,
"reason": "OK",
"detail": "ส่ง webhook_verify ไปที่ https://your-inbox.example/webhooks/propertyhub แล้ว ปลายทางตอบ HTTP 200",
"endpoint": "https://your-inbox.example/webhooks/propertyhub",
"active": true,
"userId": "ph_user_42",
"displayName": "สมชาย",
"pictureUrl": "https://cdn.example/avatar.jpg"
}Body PropertyHub sends to you:
{
"destination": "ch_9f2c...",
"userId": "ph_user_42",
"displayName": "สมชาย",
"pictureUrl": "https://cdn.example/avatar.jpg",
"events": [
{
"mode": "active",
"type": "webhook_verify",
"timestamp": 1788156956732,
"webhookEventId": "01M1B7BMC63WBEJYT90GJXVRDZ",
"deliveryContext": { "isRedelivery": false },
"userId": "ph_user_42",
"displayName": "สมชาย",
"pictureUrl": "https://cdn.example/avatar.jpg"
}
]
}Conversations
One row is one 1:1 chat for this agent and one other person. It is not a directory of every PropertyHub user.
Pagination
Used by GET /conversations and GET /conversations/:id/messages
| Query | Default | Notes |
|---|---|---|
page | 1 | 1-based |
perPage | conversations 30, messages 10 | Max 100 |
order | asc | Messages only. asc or desc by time |
{
"pagination": {
"page": 1,
"perPage": 30,
"totalCount": 128
}
}totalCount is the full number of rows, not the length of the current page.
List conversations
GET /conversations?page=1&perPage=30
userId / name / pictureUrl are the other person, not the agent who owns the channel.
{
"conversations": [
{
"id": "1042",
"userId": "ph_user_20481",
"name": "มะลิ",
"pictureUrl": "https://cdn.example/mali.jpg",
"lastMessageTimestamp": "2026-09-14T03:55:12.000Z",
"unreadCount": 2
}
],
"pagination": {
"page": 1,
"perPage": 30,
"totalCount": 1
}
}| Field | Meaning |
|---|---|
id | conversationId for history GET |
userId | Other person. Use this as the room key and as to on push. |
name | Display name |
pictureUrl | Avatar URL, or null |
lastMessageTimestamp | ISO time of the last message |
unreadCount | Messages this agent has not read |
Message history
GET /conversations/1042/messages?page=1&perPage=10&order=asc
Use id from the conversation list, or conversationId
{
"conversationId": "1042",
"messages": [
{
"id": "88210",
"type": "text",
"text": "สวัสดีค่ะ ห้องนี้ยังว่างไหมคะ",
"senderId": "ph_user_20481",
"createdAt": "2026-09-14T03:50:00.000Z"
},
{
"id": "88211",
"type": "listing",
"text": "https://propertyhub.in.th/listings/99",
"listingId": "99",
"url": "https://propertyhub.in.th/listings/99",
"senderId": "ph_user_42",
"createdAt": "2026-09-14T03:51:00.000Z"
}
],
"pagination": {
"page": 1,
"perPage": 10,
"totalCount": 2
}
}| Field | Meaning |
|---|---|
senderId | Who sent it (ph_user_...) |
createdAt | ISO time |
type | Same shapes as message types below |
Profile
GET /profile/ph_user_20481
Works for the agent or someone they already have a 1:1 chat with. Otherwise you get 404 USER_NOT_FOUND
:userId accepts ph_user_20481 or 20481
{
"userId": "ph_user_20481",
"displayName": "มะลิ",
"pictureUrl": "https://cdn.example/mali.jpg",
"name": "มะลิ"
}name is the same as displayName
Message types
Wire type values on webhooks and history are lowercase: text, image, video, listing, link, phone
Reply/push 200 messages[].type is the stored uppercase value, such as TEXT, LISTING
A listing message in webhooks and history is type: "listing" , never type: "text" plus extra listing fields.
Webhook timestamp is epoch milliseconds. REST createdAt / lastMessageTimestamp are ISO-8601. conversationId is always a string, such as "1042".
Not supported: sticker, location, file, audio, groups, follow / unfollow / postback.
Send (your inbox → PropertyHub)
Max 5 objects per request.
{ "type": "text", "text": "ห้องนี้ยังว่างครับ" }{
"type": "image",
"originalContentUrl": "https://example.com/room.jpg",
"previewImageUrl": "https://example.com/room-thumb.jpg"
}video uses the same shape as image. originalContentUrl is required (or contentProvider.originalContentUrl). In this phase the PropertyHub app shows partner image/video as a URL, not a gallery photo.
{ "type": "listing", "listingId": "99" }{ "type": "link", "url": "https://example.com" }On link, title or text is an optional label. phone may send text instead of phoneNumber. Extra text on listing is ignored; only listingId is stored.
{ "type": "phone", "phoneNumber": "0891234567" }Receive (PropertyHub → your webhook)
Render listing.url (same value as text ) as a clickable link. Do not build a listing card from listingId.
{
"id": "7",
"type": "listing",
"text": "https://propertyhub.in.th/listings/99",
"listingId": "99",
"url": "https://propertyhub.in.th/listings/99"
}{
"id": "8",
"type": "link",
"text": "https://example.com",
"url": "https://example.com"
}{
"id": "9",
"type": "phone",
"text": "0891234567",
"phoneNumber": "0891234567"
}Images and videos from the PropertyHub app: contentProvider is the first file. If attachments has more than one item, render every file. attachments[].id is a number:
{
"id": "88213",
"type": "image",
"contentProvider": {
"type": "external",
"originalContentUrl": "https://cdn.example/original.jpg",
"previewImageUrl": "https://cdn.example/thumb.jpg"
},
"attachments": [
{
"id": 991,
"thumbnailUrl": "https://cdn.example/thumb.jpg",
"pictureUrl": "https://cdn.example/large.jpg",
"originalUrl": "https://cdn.example/original.jpg"
}
]
}{
"id": "88214",
"type": "video",
"contentProvider": {
"type": "external",
"originalContentUrl": "https://cdn.example/clip.mp4",
"previewImageUrl": "https://cdn.example/clip-thumb.jpg"
},
"attachments": [
{
"id": 992,
"thumbnailUrl": "https://cdn.example/clip-thumb.jpg",
"pictureUrl": "https://cdn.example/clip-thumb.jpg",
"originalUrl": "https://cdn.example/clip.mp4"
}
]
}There is no download-by-id URL. Use originalContentUrl or attachments[].originalUrl
Two ways to send
Both put a message in the same 1:1 PropertyHub chat. PropertyHub does not charge extra for either path (on LINE, reply is free inside a time window and push is paid). Pick whichever fits your inbox.
Unlike LINE, connecting this API already exposes existing chats via GET /conversations . You do not have to wait for a new inbound webhook before you can send.
| POST /message/reply | POST /message/push | |
|---|---|---|
| Who you send to | Implied by replyToken | to is ph_user_... of the other person in the room |
| When you have it | Only after a message webhook (customer wrote to the agent) | Any time the 1:1 room already exists |
| Limit | 24 hours, once | Until the conversation exists |
| Not | Quoting a bubble. No quoteToken | Starting a chat with a brand-new buyer |
CRM-style (recommended if you already list rooms): open the conversation, then push to that row’s userId
LINE-clone: if you just received a webhook and still have replyToken , call reply ; otherwise push
webhook_verify has no replyToken
Reply
This is a LINE-style reply to the inbound event, not a quote of a previous bubble. The message lands in the same 1:1 chat as the webhook. There is no quoteToken
When a message webhook includes replyToken
POST /message/reply
{
"replyToken": "5206668982094dcd98c5b784bafca70c",
"messages": [{ "type": "text", "text": "ได้ครับ ว่างอยู่" }]
}replyToken is valid 24 hours and single use.
{
"message": "Invalid replyToken",
"error": { "code": "INVALID_REPLY_TOKEN", "message": "Invalid replyToken" }
}Optional header X-PropertyHub-Retry-Key
{
"sentMessages": [{ "id": "88213" }],
"messages": [
{
"id": "88213",
"conversationId": "1042",
"type": "TEXT",
"createdAt": "2026-09-14T03:56:00.000Z"
}
]
}sentMessages is the LINE-shaped id list. messages[].type is the stored value (TEXT, LISTING, LINK, PHONE, …). conversationId is only for history GET.
This send is not POSTed back to your webhook.
Push
Use this to send into an existing 1:1 room (to = that conversation’s userId from GET /conversations, or source.userId from a webhook). You do not need replyToken.
POST /message/push
{
"to": "ph_user_20481",
"messages": [{ "type": "text", "text": "สวัสดีครับ" }]
}The to field accepts ph_user_20481 or 20481
A new buyer still has to start from the PropertyHub app first. After that inbound, GET /conversations lists the room and push works even if you never use reply. to must be someone this agent already chatted with. Same 200 shape as reply.
{
"message": "Can only push to a user you already have a conversation with",
"error": {
"code": "CONVERSATION_NOT_FOUND",
"message": "Can only push to a user you already have a conversation with"
}
}404 CONVERSATION_NOT_FOUND means no existing 1:1 chat. 400 INVALID_USER_ID
X-PropertyHub-Retry-Key is any non-empty string, unique per send, with no expiry. If you get 409, wait about 1 second and retry the same key.
Webhooks
PropertyHub POSTs JSON to your endpoint with Content-Type: application/json and User-Agent: PropertyHub-Messaging-Webhook/1.0
What you must return
| Your HTTP status | PropertyHub does |
|---|---|
| 2xx (body ignored — empty 200 is fine) | Success. No retry. |
| Timeout (10 seconds), network error, 3xx (redirects are not followed), 4xx including 401, 5xx, or an HTML body | Fail this attempt, then retry |
Verify the signature, enqueue the event, return 2xx. Do not wait on your own downstream I/O before responding.
A 401 from you is treated like any other failure: we retry. Return 2xx after you have accepted the body.
Retry
Same webhookEventId and replyToken . deliveryContext.isRedelivery is true.
Up to 3 attempts, waits 1s then 2s. If those fail, a background worker may try again. Deduplicate on webhookEventId and still return 2xx for a duplicate.
Chat send does not fail when your webhook is down.
Signature
Headers (same value): X-Line-Signature and X-PropertyHub-Signature
channelSecret is a 64-character hex string. Use it as a UTF-8 string (do not decode hex to bytes).
const crypto = require('crypto');
const expected = crypto
.createHmac('sha256', channelSecret)
.update(rawBody)
.digest('base64');rawBody must be the exact bytes of the request body, not a re-serialized object.
Checking destination === the stored channelId is optional. Useful if one URL receives several agents.
Events
events[] is one event per POST today. Still loop and dedupe per webhookEventId.
mode is always active . There is no standby. You can ignore mode.
source.type is always user . There is no source.type: "agent".
When the other person types in the PropertyHub app, you get a normal message event and a replyToken.
When this agent types in the PropertyHub app, your webhook is not called. Persist the bubble yourself when you call reply/push.
message event
{
"destination": "ch_9f2c...",
"events": [
{
"mode": "active",
"type": "message",
"source": { "type": "user", "userId": "ph_user_20481" },
"message": {
"id": "88210",
"type": "text",
"text": "สวัสดีค่ะ ห้องนี้ยังว่างไหมคะ"
},
"timestamp": 1788156956732,
"replyToken": "5206668982094dcd98c5b784bafca70c",
"webhookEventId": "01M1B7BMC63WBEJYT90GJXVRDZ",
"deliveryContext": { "isRedelivery": false },
"conversationId": "1042"
}
]
}Listing uses type listing , not text
{
"destination": "ch_9f2c...",
"events": [
{
"mode": "active",
"type": "message",
"source": { "type": "user", "userId": "ph_user_20481" },
"message": {
"id": "7",
"type": "listing",
"text": "https://propertyhub.in.th/listings/99",
"listingId": "99",
"url": "https://propertyhub.in.th/listings/99"
},
"timestamp": 1788157000000,
"replyToken": "5206668982094dcd98c5b784bafca70c",
"webhookEventId": "01M1B7BMC63WBEJYT90GJXVRDZ",
"deliveryContext": { "isRedelivery": false },
"conversationId": "1042"
}
]
}| Field | Meaning |
|---|---|
destination | This agent’s channelId, not the person |
source.userId | Other person. Room key |
message | See message types |
replyToken | Use with /message/reply |
conversationId | History GET only (string) |
webhookEventId | Dedup key |
Errors
You can read the text from either field.
{
"message": "Invalid userId",
"error": { "code": "INVALID_USER_ID", "message": "Invalid userId" }
}Read message or error.message. error.code
401 from auth may omit the top-level message field.
{
"error": { "code": "UNAUTHORIZED", "message": "Invalid channel access token" }
}| HTTP | error.code | When |
|---|---|---|
| 400 | INVALID_USER_ID | Bad to / :userId, or push to the agent themselves |
| 400 | INVALID_REPLY_TOKEN | Missing, used, or expired replyToken |
| 400 | INVALID_MESSAGES | messages missing or empty |
| 400 | TOO_MANY_MESSAGES | More than 5 messages in one request |
| 400 | INVALID_MESSAGE | image/video missing originalContentUrl |
| 400 | UNSUPPORTED_MESSAGE_TYPE | Unknown type |
| 400 | INVALID_REQUEST | Webhook PUT missing endpoint |
| 400 | MESSAGING_INVALID_WEBHOOK_URL | Bad, http, or private webhook URL |
| 400 | MESSAGING_CHANNEL_DISABLED | Test webhook while the channel is disabled |
| 401 | UNAUTHORIZED | Missing, wrong, or disabled access token |
| 404 | CONVERSATION_NOT_FOUND | Push/history for a chat this agent does not have |
| 404 | USER_NOT_FOUND | Profile for someone they have not chatted with |
| 409 | REQUEST_IN_PROGRESS | Same X-PropertyHub-Retry-Key is still running |
| 500 | INTERNAL_ERROR | Unexpected server error |
There is no INVALID_SIGNATURE on REST. Signatures are only on webhooks you receive.