Best practices
Messaging API - Best Practices
Conventions
Base URL and content type
| Item | Rule |
|---|---|
| Base URL | https://supply.agoda.com/api/v1/messaging |
| Transport | HTTPS is required. |
| JSON media type | Requests and JSON responses use Content-Type: application/json; charset=utf-8. Upload uses multipart/form-data. Download success uses the file's Content-Type and is not JSON. |
| Caching | Conversation data responses use Cache-Control: no-store. Do not cache them. |
| Unknown fields | Request objects reject unknown fields. Clients MUST ignore unknown response fields and unknown response enum values. |
| Enums | Request enums are closed: send only documented values; unknown request enum values are 400 INVALID_REQUEST. Response enums are extensible: ignore unknown values; do not fail the parse. |
| Naming | snake_case throughout. |
Documented enum values in this contract (response enums are extensible):
| Enum | Documented values | Request | Response |
|---|---|---|---|
access | read_write, read_only | n/a | extensible |
conversation_type | reservation | n/a | extensible |
message_type | free_text | n/a | extensible |
sender_type, participant type, payload.sender.participant_type | property, guest, agoda | n/a | extensible |
metadata.type | MESSAGING_API_NEW_MESSAGE | n/a | extensible |
message_type on send is not a request field (the body is {"message":{"content":"..."}} with optional attachment_ids). On GET and webhook, ordinary text messages appear as free_text. Other stored types are returned as-is (special_request, agoda_auto_response, scheduled_message, auto_reply, notification, cancellation_waiver, cancellation_reminder, no_show_confirmation, host_email, engage_email, and similar). Do not drop non-free_text values.
No request field in v1 is an enum. If a later compatible revision adds a request enum, only the documented values are accepted.
The Message object has no status field, no read_by field, and no reply_to field. v1 has no threading. Delivery is not a partner-visible processing enum. Acceptance is the 202. This API does not expose per-message read receipts; the read signal is the participant unread counter.
Envelope
Every JSON response body contains all four fields: data, errors, warnings, and meta.
On success, errors is [] and warnings is []. On error, data is null, errors is a non-empty array, warnings is [], and meta is present.
Exceptions: PUT .../read returns 204 No Content with no body. Download success returns the file bytes with no JSON envelope. Those are the only success responses without the envelope.
meta contains ruid, an opaque string for support correlation. The X-Request-Id response header is present on every response, including 204 No Content, and equals meta.ruid whenever a JSON body is present.
Success (every JSON success body includes data.ok):
{
"data": {
"ok": true
},
"errors": [],
"warnings": [],
"meta": {
"ruid": "dd28b426-70c2-4b16-8f16-0edddac1ac2f"
}
}| Field | Type | Required | Meaning |
|---|---|---|---|
data | object or null | Yes | Success payload, or null on error. |
data.ok | boolean | Yes, on every JSON success body | JSON boolean true (not the string "true"). Absent on error (data is null), on 204, and on download success. |
errors | array | Yes | [] on success. One or more error objects on failure. |
warnings | array | Yes | Always present. This contract currently returns []. No element schema is published; do not assume warning objects until one is documented. |
meta | object | Yes | Correlation metadata. |
meta.ruid | string | Yes | Opaque request correlation ID. Include it when contacting support. |
Error:
{
"data": null,
"errors": [
{
"code": "INVALID_REQUEST",
"message": "The request is invalid.",
"field": "message.content"
}
],
"warnings": [],
"meta": {
"ruid": "dd28b426-70c2-4b16-8f16-0edddac1ac2f"
}
}| Field | Type | Required | Meaning |
|---|---|---|---|
errors[].code | string | Yes | Registry code such as INVALID_REQUEST. |
errors[].message | string | Yes | Safe, human-readable explanation. |
errors[].field | string | No | Request field that failed validation, using a dotted path for nested fields (for example message.content or page_id). The property name is field, not member. |
Identifiers
All public identifiers are strings. Treat them as opaque. Do not convert them to JavaScript numbers or another narrower numeric type.
Clients MUST NOT construct identifiers or infer ownership from their values. Use only values returned by this API or already issued to your integration for the entitled property (Agoda property ID and Agoda booking ID).
| Identifier | Type | Required | Meaning |
|---|---|---|---|
property_id | string | Yes | Authorized Agoda property ID. Canonical positive ASCII decimal string from 1 through 2147483647. No sign, leading zero, or whitespace. |
conversation_reference | string | Yes, on reservation lookup | Agoda booking ID for the entitled property. Matches ^[1-9][0-9]{0,14}$. One to 15 ASCII digits, with no leading zero or numeric narrowing. |
conversation_id | string | Yes, when addressing a conversation | Conversation identifier. Positive signed-64 decimal string. Treat as opaque. Not a UUID. |
message_id | string | Yes, on each message | Message identifier. Positive signed-64 decimal string. Treat as opaque. Not a UUID. |
attachment_id | string | Yes, when addressing an attachment | Opaque attachment identifier returned by upload. Treat as opaque. |
page_id | string | No | Pagination state for a later page. Opaque string returned as data.next_page_id. Do not construct it. |
Webhook metadata.uuid | string (UUID) | Yes, on each notification | Delivery identity for a notification. Stable across retries of the same notification. |
Timestamps and ordering
- Timestamps use RFC 3339 UTC with a
Zsuffix and millisecond precision, for example2026-07-23T07:15:30.123Z. - Conversation lists are ordered by latest externally visible activity first, then
conversation_idas a deterministic tie-breaker. - Messages are ordered by
message_iddescending, most recent first.message_id, nottimestamp, is the paging and ordering authority.
Pagination
List and conversation-detail pages contain at most 50 items. Agoda may return fewer items. Keep requesting the next page until next_page_id is null. A page with fewer than 50 items is not necessarily the last page. Do not send limit, page_size, offset, or cursor.
The reservation-conversation endpoint returns the newest at most 50 eligible messages and does not paginate. data.next_page_id is present and always null on that endpoint. Passing page_id to that endpoint returns 400 INVALID_PAGE_ID. To read messages older than the returned page, use the conversation_id from that response with Get conversation detail.
Unexpected query parameters are rejected with 400 INVALID_REQUEST. This is deliberate: a client sending an undocumented pagination parameter fails immediately and visibly, rather than silently re-reading page one.
Paginated success responses put the continuation token in data.next_page_id, as a sibling of data.conversations (list) or data.conversation (reservation and detail). data.next_page_id is always present. On list and conversation-detail, it is a string when a later page is currently known, and null on the last page. On the reservation-conversation endpoint, it is always null. Pass a non-null data.next_page_id back as the page_id query parameter to fetch the next page of a paginated endpoint. Send 202 success bodies do not include next_page_id.
{
"data": {
"ok": true,
"conversations": [],
"next_page_id": "eyJvIjoiYyIsInAiOiI0MTgwOTIzNDUifQ"
},
"errors": [],
"warnings": [],
"meta": {
"ruid": "dd28b426-70c2-4b16-8f16-0edddac1ac2f"
}
}A page_id is opaque and bound to the authenticated integration, property, filters, ordering version, and last position. Reusing it with different parameters returns 400 INVALID_PAGE_ID. Pages are mutable: deduplicate conversation rows by conversation_id and refresh from the first page when you need a current view. Deduplicate messages by message_id.
This API does not impose a three-month retrieval window. Message history for an eligible conversation is not truncated by age in v1.
Send attempts
Every POST that sends a message is a new send attempt.
Do not send Idempotency-Key, a client message ID, a request fingerprint, or a replay key. Those headers and fields are not part of this API. Repeating a POST creates another send attempt and can create another guest-visible message.
A send timeout, connection loss, or 500/503 after a POST has an unknown acceptance outcome. Do not blindly repeat the same body.
See Best practices and retry guidance.
Conversation access and write window
| Value | Meaning |
|---|---|
read_write | A point-in-time hint that sending is currently eligible. Upload is a write and is eligible on the same window. |
read_only | The conversation remains readable, but sending is not eligible. Upload is a write and is rejected the same way. |
Partners MUST treat access as the send and upload eligibility signal and recheck it on every send POST and every upload POST. Agoda rechecks send and upload eligibility for every write POST. A previous read_write response does not guarantee that a later write will be accepted. A conversation that is not writable returns 403 CONVERSATION_READ_ONLY for send and upload.
Agoda messaging lifetime:
- The usual default is that the property may send until checkout + 14 days. That default is not the only expiry. A conversation can have a stored expiration that is not 14 days after checkout; when that stored expiration is present, it is the write-window authority. Recheck
access; do not assume a hard 14-day rule. - A guest message extends the writable window to
max(current expiration, that guest message's created timestamp + 7 days). A guest message inside the base window can have no effect. Example: a guest message on day 3 after checkout extends to day 10, which is earlier than checkout + 14, so the base window still wins. Property messages never extend the window. - After the writable window, property send and upload are not eligible while the conversation may remain readable (
read_only). Reads (list, detail, download, metadata, mark-read) may continue. - Cancellation does not move the clock. The window is computed from checkout, not from a cancellation date.
- Guests are not capped at a fixed number of days after checkout.
Do not treat the window as "checkout + 21 days". Rely on access.
The Conversation object has no status field. Writability is access only.
Resource models
All resources below are JSON objects. Required response fields are non-null unless explicitly stated as nullable. The field sets below are closed: a conforming response includes exactly these fields for each object (unknown additive keys on a later compatible revision must be ignored).
Participant
{
"type": "property",
"unread_messages_count": 1
}| Field | Type | Required | Meaning |
|---|---|---|---|
type | string | Yes | property, guest, or agoda. |
unread_messages_count | integer | Yes | The unread counter Agoda maintains for this participant on this conversation. See below. |
A conversation returns exactly one property projection, exactly one guest projection, and at most one agoda projection. Include agoda when Agoda is a participant on the thread. At most three unique participants: at most one of each. No participant ID, email address, name, or delivery address is exposed.
unread_messages_count is a counter Agoda maintains for each participant on the conversation. It increases when a message arrives that the participant has not seen, and it is reset to zero both by the mark-read call and when that participant themselves sends a message. It is not a live count of unread messages within the page you were returned, and it may legitimately differ from the messages visible in the response.
This API does not expose per-message read receipts. The participant unread counter is the read signal.
Participants expose type and unread_messages_count only.
Message
The Message object has exactly these seven fields: message_id, message_type, content, timestamp, sender_type, attributes, attachment_ids. There is no status field, no read_by field, and no reply_to field.
{
"message_id": "918273644",
"message_type": "free_text",
"content": "Can we check in early?",
"timestamp": "2026-07-23T07:15:30.123Z",
"sender_type": "guest",
"attributes": {},
"attachment_ids": []
}| Field | Type | Required | Meaning |
|---|---|---|---|
message_id | string | Yes | Opaque message ID (positive signed-64 decimal string). |
message_type | string | Yes | Documented example free_text. Ordinary text messages appear as free_text. Other stored types are returned as-is (special_request, agoda_auto_response, scheduled_message, auto_reply, notification, cancellation_waiver, cancellation_reminder, no_show_confirmation, host_email, engage_email, and similar); ignore unknown values. Send has no message_type field. |
content | string | Yes | Text exposed by the API. It is non-empty for a Channel Manager send. |
timestamp | string (date-time) | Yes | UTC creation instant, RFC 3339 with millisecond precision, for example 2026-07-23T07:15:30.123Z. |
sender_type | string | Yes | property, guest, or agoda. No identity-level sender ID is exposed. |
attributes | object | Yes | Empty object {}. Documented additive keys may be added later. |
attachment_ids | array of string | Yes | Opaque attachment IDs on this message. Always present. Empty array when there are none. Use download to fetch file bytes. |
Messages in a page are ordered by message_id descending, most recent first.
Conversation
The Conversation object has no status field.
{
"conversation_id": "418092345",
"conversation_reference": "9384756102",
"conversation_type": "reservation",
"access": "read_write",
"participants": [
{ "type": "property", "unread_messages_count": 1 },
{ "type": "guest", "unread_messages_count": 0 },
{ "type": "agoda", "unread_messages_count": 0 }
],
"messages": [
{
"message_id": "918273644",
"message_type": "free_text",
"content": "Can we check in early?",
"timestamp": "2026-07-23T07:15:30.123Z",
"sender_type": "guest",
"attributes": {},
"attachment_ids": []
}
]
}| Field | Type | Required | Meaning |
|---|---|---|---|
conversation_id | string | Yes | Opaque conversation ID (positive signed-64 decimal string). |
conversation_reference | string | Yes | Agoda booking ID for the entitled property. |
conversation_type | string | Yes | reservation only in v1. |
access | string | Yes | read_write or read_only point-in-time hint. See Conversation access and write window. |
participants | array | Yes | Exactly one property, exactly one guest, and at most one agoda. |
messages | array | Yes | List response: latest eligible message only, or [] when the conversation has no eligible messages. Reservation and detail responses: eligible messages for the returned page. |
A newly created or existing empty conversation has "messages": []. Property listings may include empty conversations; those rows still return "messages": [].
On the list endpoint, the conversation objects live in data.conversations. On the reservation and detail endpoints, the conversation object lives in data.conversation. data.next_page_id is a sibling of that array or object, not a field inside the conversation. It is always present on those three success bodies. On list and conversation-detail it is a string, or null on the last page. On the reservation-conversation endpoint it is always null.
Message size
Agoda rejects a send with 413 MESSAGE_TOO_LARGE unless both of the following hold:
content.lengthis at most 2800 UTF-16 code units.- The modified-UTF-8 encoded size of
contentis at most 2984 bytes.
Both rules are normative. The 2800 code-unit limit is not sufficient on its own. 2,800 Thai characters are about 8,400 modified-UTF-8 bytes and are rejected by the byte rule.
Modified UTF-8 (the encoding used for the byte rule):
- BMP characters encode in 1 to 3 bytes.
U+0000encodes in 2 bytes.- Supplementary-plane characters (most emoji) encode in 6 bytes because they are stored as UTF-16 surrogate pairs.
| Content | Practical maximum |
|---|---|
| ASCII only | 2,800 characters |
| Accented Latin, Greek, Cyrillic, Hebrew, Arabic | 1,492 characters |
| Thai, Vietnamese, Japanese, Chinese, Korean and other three-byte scripts | 994 characters |
| Emoji and other supplementary-plane characters | 497 characters |
| Mixed | both rules must hold; the byte rule binds |
Malformed UTF-16 (for example an unpaired surrogate) is 400 INVALID_REQUEST, not 413. 413 is used for the size dual rule and for an uploaded file larger than 10 MiB.
Empty content is 400 INVALID_REQUEST. Unknown fields on the send body are 400 INVALID_REQUEST. Unsupported media type, a missing upload file, or an image that contains a QR code is 400 INVALID_REQUEST.
Agoda does not accept and truncate oversized content. Reduce or split the text and retry only after you confirm a send is still needed.
Best practices and retry guidance
| Situation | Required behavior |
|---|---|
| Send timeout or connection loss | Treat acceptance as unknown. Do not blindly repeat the POST. |
202 received | Keep message_id. Do not treat an immediate GET as a delivery check. Use a later read or a webhook plus confirming GET. |
400, 401, 403, 404, 409, or 413 | Correct the request, authentication, scope, or state. Do not blind-retry. |
Attachment file_info or download 404 after a known upload | Retry file_info with bounded backoff until 200, then download. Stop if the id is not from your upload. |
Send 500 or 503 | Treat acceptance as unknown unless the response proves rejection before acceptance. |
Reservation GET returns 503 after ambiguous creation | Do not blindly repeat; an empty conversation may have been created even though no response was received. |
Read 500 or 503 | Retry cautiously with bounded exponential backoff and jitter. |
| Duplicate webhook | Deduplicate by metadata.uuid. Confirm with a GET before acting. |
Treat conversation access as the write-eligibility signal on every send POST and every upload POST. Recheck it on every write. A previous read_write does not guarantee a later write. See Conversation access and write window.
Every POST is a new send attempt. Do not send Idempotency-Key, a client message ID, a request fingerprint, or a replay key.
Privacy and security
- Use the existing Supply Connectivity credential controls for every API request.
- Use HTTPS and validate TLS certificates for API and webhook traffic.
- Do not cache conversation or list data. Respect
Cache-Control: no-store. - Treat guest message content and the documented identifiers as partner data. Protect them according to your applicable privacy, security, and retention obligations.
- Do not expect private participant contact data, credentials, or dependency data in this API. Do not expect file bytes on the webhook; use download for bytes.
attachment_idsmay appear on Message and webhook payloads. - For webhook receivers, restrict the endpoint to the required public HTTPS shape, validate the payload, and maintain durable deduplication. Agoda authenticates callback POSTs with OAuth 2.0 client credentials (
Authorization: Bearer). Treat the notification as a hint and confirm with a conversation-detailGETbefore acting. See Webhook contract.
Support correlation
For integration support, provide enough information to identify the request without sending credentials or unnecessary guest content:
meta.ruidorX-Request-Id- UTC timestamp of the request or webhook delivery
property_idconversation_id, when availableconversation_reference, when relevant- HTTP status and error code
- For webhook issues,
metadata.uuidand your receiver's HTTP response status
Contact Agoda Connectivity with those fields. Do not send credentials or full guest message content unless Connectivity asks for a specific excerpt.
Updated 3 days ago

