This guide explains how to receive real-time notifications when a guest sends a message to a host on the Plum Guide platform.
Overview
Webhooks let you know the moment a guest writes to a host, so you do not have to poll for new messages. Plum calls your endpoint with the ids of the conversation and the message; you then fetch the text through the messaging API.
The callback carries no message text. That keeps guest content out of your logs and out of anything that inspects the request on its way to you.
Quick Start
1. Configure Messages Webhook Endpoint
POST /v2/webhooks/config
Authorization: Bearer {your_token}
Content-Type: application/json
{
"isActive": true,
"endpoints": {
"messages": "https://your-domain.com/webhooks/messages"
}
}The response includes a signingSecret. It is returned only on create and on rotation — store it now.
2. Receive Message Events
When a guest sends a message, your endpoint receives:
{
"eventId": "506f6996-2445-499a-abe9-2ca1e1feebcf",
"timestamp": "2026-08-24T10:15:24.5949004Z",
"bookingCode": null,
"listingId": 7,
"eventType": "GuestMessageReceived",
"conversationId": 23722,
"messageId": 325924
}3. Fetch the Message
Use the conversation id to read the thread:
GET /v2/messaging/conversations/23722/messages
Authorization: Bearer {your_token}Message Events
| Event | Description | Action Required |
|---|---|---|
| Guest Message Received | A guest sent a message to the host | Fetch the thread and reply if needed |
A host's own reply does not produce a callback. You are notified about messages the host needs to see, not about the ones they sent — including the replies you send through this API.
Webhook Payload
{
"eventId": "506f6996-2445-499a-abe9-2ca1e1feebcf",
"timestamp": "2026-08-24T10:15:24.5949004Z",
"bookingCode": null,
"listingId": 7,
"eventType": "GuestMessageReceived",
"conversationId": 23722,
"messageId": 325924
}| Field | Type | Description |
|---|---|---|
eventId | UUID | Unique identifier for this event (use for idempotency) |
timestamp | dateTime | When the event occurred |
bookingCode | null | Always null for message events |
listingId | integer | The listing the conversation is about |
eventType | string | GuestMessageReceived |
conversationId | integer | The conversation the message belongs to |
messageId | integer | The message that was sent |
eventType distinguishes message events from the booking and listing channels, which share the same payload shape. Treat any value you do not recognize as one to ignore rather than one to reject, so a future event type does not break your handler.
Configuration API
Create Configuration
POST /v2/webhooks/config
Content-Type: application/json
Authorization: Bearer {token}
{
"isActive": true,
"endpoints": {
"messages": "https://your-domain.com/webhooks/messages"
}
}POST creates. If a configuration already exists it returns 400 with "Webhook configuration already exists. Use PUT to update." — it does not overwrite.
Update Configuration
PUT /v2/webhooks/config
Content-Type: application/json
Authorization: Bearer {token}
{
"isActive": true,
"endpoints": {
"bookings": "https://your-domain.com/webhooks/bookings",
"listings": "https://your-domain.com/webhooks/listings",
"messages": "https://your-domain.com/webhooks/messages"
}
}Get Configuration
GET /v2/webhooks/config
Authorization: Bearer {token}The signing secret is never returned here. Only create and rotation return it.
Toggle On/Off
PATCH /v2/webhooks/config/toggle?isActive=false
Authorization: Bearer {token}isActive is required — omitting it returns 400, and it sets the state rather than flipping it.
Delete Configuration
DELETE /v2/webhooks/config
Authorization: Bearer {token}
Note:PUTis the only way to change an existing configuration, and it replaces the whole thing. Any channel you leave out of the body is cleared, so aPUTcarrying onlybookingssilently removes yourmessagesendpoint. Always send every endpoint you want to keep — read the current configuration first if you are unsure. An endpoint cannot be cleared by sending an empty string, which is rejected as an invalid URL; omit it instead.
Requirements:
- Endpoint URL must use HTTPS
- Your endpoint must return
200 OKwithin 30 seconds
Handling Message Events
Guest Message Received
Acknowledge first, then fetch the thread:
app.post('/webhooks/messages', async (req, res) => {
res.status(200).send('OK');
const { conversationId } = req.body;
const { data: messages } = await plumApi.getMessages(conversationId);
const latest = messages[messages.length - 1];
if (latest.sender === 'Guest') {
await notifyHostOfNewMessage(conversationId, latest.body);
}
});Messages come back newest first, so the one that just arrived is the first entry. Matching on messageId from the payload is steadier than relying on position, and it is what the fuller example below does.
Complete Example Handler
const processedEvents = new Set();
app.post('/webhooks/messages', async (req, res) => {
const { eventId, eventType, conversationId, messageId } = req.body;
// Ignore event types we do not handle yet
if (eventType !== 'GuestMessageReceived') {
return res.status(200).send('Ignored');
}
// Idempotency check
if (processedEvents.has(eventId)) {
return res.status(200).send('Already processed');
}
// Acknowledge receipt immediately
res.status(200).send('OK');
processedEvents.add(eventId);
try {
const { data: messages } = await plumApi.getMessages(conversationId);
const message = messages.find(m => m.messageId === messageId);
await deliverToHostInbox(conversationId, message);
} catch (error) {
console.error('Failed to process message webhook:', error);
}
});Best Practices
- Respond quickly - Return
200 OKimmediately, process asynchronously - Implement idempotency - Use
eventIdto avoid processing duplicates - Fetch the text - The payload carries ids only, never message content
- Ignore unknown
eventTypevalues - Return200so new event types do not break you - Do not expect callbacks for host replies - Only guest messages raise an event
Troubleshooting
Not receiving webhooks?
- Verify configuration:
GET /v2/webhooks/config - Check
isActiveistrue - Ensure
endpoints.messagesis set — and that an earlierPUTdid not clear it - Confirm URL is HTTPS and publicly accessible
- Confirm you are expecting a guest message; host replies do not fire
Receiving duplicates?
This is expected. Use eventId for deduplication:
const processedEvents = new Set();
if (processedEvents.has(eventId)) {
return res.status(200).send('Already processed');
}
processedEvents.add(eventId);Duplicates also arrive when your endpoint answers with a non-2xx or times out, because the delivery is retried.
Events stop arriving after an outage
A delivery that keeps failing is retried a limited number of times and then given up on. Once your endpoint is healthy again, use GET /v2/messaging/activities?fromDate=... to catch up on anything missed while it was down.
Testing Checklist
-
Endpoint returns
200 OKwithin 30 seconds -
Handles duplicate events (same
eventId) -
Ignores unrecognized
eventTypevalues with a200 -
Fetches message text via the messaging API
-
Catches up through the activity feed after downtime
