Back to settings

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:

  1. Store channelId, channelSecret and channelAccessToken from the agent (secret and token are shown once)
  2. Register your HTTPS webhook with PUT /channel/webhook/endpoint then POST /channel/webhook/test
  3. On each POST to your URL: verify the signature on the raw body, return 2xx quickly, persist the event, then process it asynchronously.
  4. Room key = the other person’s userId (on a message webhook, source.userId ). Do not key rooms on conversationId . That id is only for GET /conversations/:id/messages
  5. 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/messaging

Auth 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 seeMeaning
channelId / destinationch_9f2c...This agent’s channel. Not a person.
userIdph_user_42A PropertyHub person. On REST, push.to and /profile/:userId also accept 42. Webhooks always send ph_user_42.
conversationId / id1042Use only to fetch message history. Not the chat-room key.
message id88213One 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, userId is the agent who just connected.
  • On a message event, source.userId is the other person (the sender).
  • destination is this agent’s channelId . It is not a person. Do not use it as the room key or as to

Credentials

The agent turns on Messaging API in PropertyHub settings and copies three values:

ValueWhat you do with it
channelIdStore it. It arrives again as webhook destination.
channelSecretVerify webhook signatures.
channelAccessTokenBearer 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.

HTTP
PUT /channel/webhook/endpoint
Content-Type: application/json

{ "endpoint": "https://your-inbox.example/webhooks/propertyhub" }

PUT and GET return the same 200 object:

JSON
{
  "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:

JSON
{
  "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:

JSON
{
  "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

QueryDefaultNotes
page11-based
perPageconversations 30, messages 10Max 100
orderascMessages only. asc or desc by time
JSON
{
  "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.

JSON
{
  "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
  }
}
FieldMeaning
idconversationId for history GET
userIdOther person. Use this as the room key and as to on push.
nameDisplay name
pictureUrlAvatar URL, or null
lastMessageTimestampISO time of the last message
unreadCountMessages 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

JSON
{
  "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
  }
}
FieldMeaning
senderIdWho sent it (ph_user_...)
createdAtISO time
typeSame 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

JSON
{
  "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.

JSON
{ "type": "text", "text": "ห้องนี้ยังว่างครับ" }
JSON
{
  "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.

JSON
{ "type": "listing", "listingId": "99" }
JSON
{ "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.

JSON
{ "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.

JSON
{
  "id": "7",
  "type": "listing",
  "text": "https://propertyhub.in.th/listings/99",
  "listingId": "99",
  "url": "https://propertyhub.in.th/listings/99"
}
JSON
{
  "id": "8",
  "type": "link",
  "text": "https://example.com",
  "url": "https://example.com"
}
JSON
{
  "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:

JSON
{
  "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"
    }
  ]
}
JSON
{
  "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/replyPOST /message/push
Who you send toImplied by replyTokento is ph_user_... of the other person in the room
When you have itOnly after a message webhook (customer wrote to the agent)Any time the 1:1 room already exists
Limit24 hours, onceUntil the conversation exists
NotQuoting a bubble. No quoteTokenStarting 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

JSON
{
  "replyToken": "5206668982094dcd98c5b784bafca70c",
  "messages": [{ "type": "text", "text": "ได้ครับ ว่างอยู่" }]
}

replyToken is valid 24 hours and single use.

JSON
{
  "message": "Invalid replyToken",
  "error": { "code": "INVALID_REPLY_TOKEN", "message": "Invalid replyToken" }
}

Optional header X-PropertyHub-Retry-Key

JSON
{
  "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

JSON
{
  "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.

JSON
{
  "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 statusPropertyHub 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 bodyFail 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).

JavaScript
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

JSON
{
  "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

JSON
{
  "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"
    }
  ]
}
FieldMeaning
destinationThis agent’s channelId, not the person
source.userIdOther person. Room key
messageSee message types
replyTokenUse with /message/reply
conversationIdHistory GET only (string)
webhookEventIdDedup key

Errors

You can read the text from either field.

JSON
{
  "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.

JSON
{
  "error": { "code": "UNAUTHORIZED", "message": "Invalid channel access token" }
}
HTTPerror.codeWhen
400INVALID_USER_IDBad to / :userId, or push to the agent themselves
400INVALID_REPLY_TOKENMissing, used, or expired replyToken
400INVALID_MESSAGESmessages missing or empty
400TOO_MANY_MESSAGESMore than 5 messages in one request
400INVALID_MESSAGEimage/video missing originalContentUrl
400UNSUPPORTED_MESSAGE_TYPEUnknown type
400INVALID_REQUESTWebhook PUT missing endpoint
400MESSAGING_INVALID_WEBHOOK_URLBad, http, or private webhook URL
400MESSAGING_CHANNEL_DISABLEDTest webhook while the channel is disabled
401UNAUTHORIZEDMissing, wrong, or disabled access token
404CONVERSATION_NOT_FOUNDPush/history for a chat this agent does not have
404USER_NOT_FOUNDProfile for someone they have not chatted with
409REQUEST_IN_PROGRESSSame X-PropertyHub-Retry-Key is still running
500INTERNAL_ERRORUnexpected server error

There is no INVALID_SIGNATURE on REST. Signatures are only on webhooks you receive.

Zimple Internet Co., Ltd.
242,244,246 Room A210A, 2nd Floor Building A
Watcharapol Road Tha Raeng Bang Khen Bangkok 10230

Download Application

Contact with us

Verified by

Copyright © 2019-2020 Zimple Internet Co., Ltd. , All rights reserved.