Overview
1. Before you begin
| Base URL | https://api.guestly.ai/api/v1 |
|---|---|
| Test environment | https://dev-api.guestly.ai/api/v1 (keys start with gst_test_) |
| Authentication | x-api-key: <key> header on every request |
| Format | JSON (UTF-8). File uploads use multipart/form-data. |
On the hotel's side:
- The hotel is onboarded on Guestly and has connected its WhatsApp Business number.
- The hotel's owner creates your key in Settings → Tools & Integrations → API access → Create key and gives it to you. The key is shown once; keep it on your server, never in a browser or app.
- To send templates, the hotel has at least one template approved by WhatsApp (you can list them with the API, see section 4).
- For real-time sync, the owner enters your webhook URL on the same page and gives you the signing secret (section 10).
Check a key with:
GET /partner/me
x-api-key: gst_live_…{
"business": { "id": "66e42ceeb622697a6f788bb3", "name": "Hotel Alpenrose" },
"whatsapp": { "connected": true, "phone_number": "+39 0471 123456", "display_name": "Hotel Alpenrose" },
"key": { "id": "6ab98b77754244405c647b2e", "label": "Booking system" },
"rate_limit_per_minute": 120
}2. Errors and rate limits
Every error has the same shape:
{
"error": {
"code": "OUTSIDE_24H_WINDOW",
"message": "The guest has not written in the last 24 hours, so WhatsApp only allows an approved template.",
"details": { "last_inbound_at": "2026-09-23T09:21:02.586Z" }
}
}| HTTP | code | Meaning |
|---|---|---|
| 400 | VALIDATION_ERROR | A field is missing or invalid (the message says which). |
| 400 | INVALID_PHONE | The phone number can't be parsed. Use +<country code><number>. |
| 400 | MISSING_TEMPLATE_VARIABLE | A template variable has no value (details.missing lists them). |
| 401 | INVALID_API_KEY | Missing, wrong or revoked key. |
| 404 | CONTACT_NOT_FOUND / ROOM_NOT_FOUND / TEMPLATE_NOT_FOUND | Not found for this hotel. |
| 409 | TEMPLATE_NOT_APPROVED | The template exists but WhatsApp hasn't approved it. |
| 409 | OUTSIDE_24H_WINDOW | Free-form messages need the guest to have written in the last 24 hours. Send a template instead. |
| 409 | WHATSAPP_NOT_CONNECTED | The hotel has no WhatsApp number connected. |
| 422 | NOT_ON_WHATSAPP | The number isn't a WhatsApp account. |
| 429 | RATE_LIMITED | Too many requests; retry after Retry-After seconds. |
| 502 | WHATSAPP_ERROR | WhatsApp rejected the message; meta_code carries WhatsApp's error code. |
Rate limit: 120 requests per minute per key (sliding window). Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset. A 429 carries Retry-After. Ask Guestly if you need more.
WhatsApp also limits how many new conversations a hotel can open per day (its messaging tier). A send over that limit returns 502 WHATSAPP_ERROR with meta_code: 131048.
3. Concepts
- Contact: a guest of the hotel, identified by
id. You can always address a guest by phone number instead; Guestly finds the existing contact or creates one (you can passname,surname,email,language). Existing data is never overwritten — only empty fields are filled. - Room: the one WhatsApp conversation between the hotel and a contact.
- 24-hour window: WhatsApp allows free-form messages (text, media) only within 24 hours of the guest's last message. Outside it, only approved templates can be sent. Rooms report
window.openandwindow.expires_at. - Phone numbers: send
+39 333 123 4567,0039…or, for guests in the hotel's own country, the national number. Responses use E.164 (+393331234567).
4. List templates
GET /partner/templates?language=itlanguage (en, it, de) is optional. Only templates approved by WhatsApp are returned.
{
"data": [
{
"name": "cloud_offer_invite",
"language": "it",
"category": "MARKETING",
"header": { "format": "IMAGE", "text": null, "media_url": "https://…/hero.jpg", "media_overridable": true },
"body": "Ciao {{1}}, la Sua offerta per il soggiorno dal {{2}} è pronta.",
"footer": null,
"variables": [
{ "position": 1, "name": "FIRSTNAME", "auto_filled": true, "example": "Mario" },
{ "position": 2, "name": "arrival", "auto_filled": false, "example": "12 maggio" }
],
"buttons": [
{ "index": 0, "type": "URL", "text": "Apri l'offerta", "url": "https://www.cloudoffer.it/{{1}}", "dynamic": true }
]
}
]
}variablesare the body placeholders{{1}},{{2}}, … in order.auto_filled: truemeans Guestly can fill the value from its own contact or booking data (first name, last name, title, check-in/out dates, nights, guests, room, booking number, pre-check-in link). You can still pass your own value, and should if the hotel's bookings aren't in Guestly.exampleis the sample the hotel entered when creating the template. It is never sent in place of a missing value.dynamic: truebuttons need a value for the{{1}}in their URL (for example an offer id).
5. Send a message
POST /partner/messages Every message has a recipient to and a type: text, image, video, document or template.
Recipient
"to": { "contact_id": "65f1c0b8a2e3f4c5d6e7f8a9" }or
"to": { "phone": "+393331234567", "name": "Mario", "surname": "Rossi", "email": "[email protected]", "language": "it" }5.1 Template (works any time)
{
"to": { "phone": "+393331234567", "name": "Mario" },
"type": "template",
"template": {
"name": "cloud_offer_invite",
"language": "it",
"variables": {
"body": ["Mario", "12 maggio"],
"header_media_url": "https://cdn.example.com/offers/abc123.jpg",
"buttons": ["abc123"]
}
}
}| Field | Required | Description |
|---|---|---|
template.name, template.language | yes | As returned by GET /partner/templates. |
variables.body | if the template has variables | Either an array in placeholder order, or an object by variable name: { "FIRSTNAME": "Mario", "arrival": "12 maggio" }. Names are case-insensitive. Leave an auto_filled variable out (or empty) to let Guestly fill it. |
variables.header_media_url | no | Replaces the approved header image, video or document for this send. |
variables.buttons | if the template has dynamic URL buttons | Values for the dynamic buttons, in order. With https://www.cloudoffer.it/{{1}} and "buttons": ["abc123"] the guest opens https://www.cloudoffer.it/abc123. variables.button is accepted as a shorthand for one value. |
5.2 Text (within the 24-hour window)
{
"to": { "contact_id": "65f1c0b8a2e3f4c5d6e7f8a9" },
"type": "text",
"text": "Hi Mario, your offer is ready — let me know if you have any questions!"
}5.3 Image, video or document (within the 24-hour window)
By URL:
{
"to": { "contact_id": "65f1c0b8a2e3f4c5d6e7f8a9" },
"type": "document",
"media": { "url": "https://cdn.example.com/invoices/2026-0142.pdf", "filename": "Invoice 2026-0142.pdf", "caption": "Your invoice" }
} Or upload the file with multipart/form-data:
curl -X POST https://api.guestly.ai/api/v1/partner/messages \
-H "x-api-key: $GUESTLY_KEY" \
-F "to[contact_id]=65f1c0b8a2e3f4c5d6e7f8a9" \
-F "type=image" \
-F "media[caption]=Your room is ready" \
-F "[email protected]"Response (201)
The sent message, in the same shape as everywhere else (section 9):
{
"data": {
"id": "6ab98bb73f66ba571c7380fa",
"message_id": "wamid.HBgMMzkz…",
"room_id": "69134ad2f633ce7c42606a54",
"contact": { "id": "65f1c0b8a2e3f4c5d6e7f8a9", "phone": "+393331234567", "name": "Mario Rossi" },
"direction": "outbound",
"sent_by": "api",
"sender_name": "Booking system",
"type": "template",
"text": "Ciao Mario, la Sua offerta per il soggiorno dal 12 maggio è pronta.",
"status": "sent",
"created_at": "2026-09-28T10:14:03.000Z"
}
}status: "sent" means WhatsApp accepted the message. Delivery and read receipts follow as message.status webhooks (section 10). Store data.id and data.contact.id: you can address the guest by contact_id from then on, and the same id appears in webhooks.
6. Message format rules
| Message part | Limit |
|---|---|
| Text message | up to 4,096 characters |
| Media caption | up to 1,024 characters |
| Image | JPEG or PNG, up to 5 MB |
| Video | MP4 or 3GPP (H.264 video, AAC audio), up to 16 MB |
| Document | PDF, Word, Excel, PowerPoint, TXT and other common types, up to 100 MB |
- Formatting uses WhatsApp's own syntax, not Markdown or HTML:
*bold*,_italic_,~strikethrough~,```monospace```,`inline code`, lists starting with-or1., and quotes starting with>. - Links are sent as plain URLs; WhatsApp shows a preview.
- Media URLs must be publicly reachable (no login, no expiring signature shorter than a few minutes). WhatsApp downloads the file when the message is sent.
- Templates: the text is fixed by the approved template; only variables, header media and dynamic button values change per send. New templates are created and submitted for approval in Guestly (Template Studio).
7. List conversations (rooms)
GET /partner/rooms?status=open&updated_since=2026-09-27T00:00:00Z&page=1&limit=20| Parameter | Description |
|---|---|
status | open or closed. |
contact_id / phone | Only the room of this contact. |
updated_since | Only rooms with a message at or after this time (ISO-8601). |
page, limit | Pagination; limit up to 100 (default 20). Newest activity first. |
{
"data": [
{
"id": "69134ad2f633ce7c42606a54",
"status": "open",
"contact": { "id": "65f1…", "phone": "+393331234567", "name": "Mario", "surname": "Rossi", "email": null, "language": "it" },
"last_message_at": "2026-09-23T09:21:53.948Z",
"last_message_by": "guest",
"last_message_preview": "Do you have a room for two next weekend?",
"unread_count": 1,
"window": { "open": true, "expires_at": "2026-09-24T09:21:02.586Z" }
}
],
"pagination": { "page": 1, "limit": 20, "has_more": false }
}8. List messages in a room
GET /partner/rooms/{room_id}/messages?limit=50
GET /partner/rooms/{room_id}/messages?before={message id} older page
GET /partner/rooms/{room_id}/messages?after={message id} newer messages, oldest first{ "data": [ { …message… } ], "has_more": true, "order": "newest_first" } Use before with the oldest id you have to page back through history, and after with the newest id to catch up after downtime.
9. The message object
| Field | Description |
|---|---|
id | Guestly message id. Stable; use it to deduplicate. |
message_id | WhatsApp message id (wamid…), or null if it wasn't sent. |
room_id | The conversation. |
contact | { id, phone, name } of the guest. |
direction | inbound (guest → hotel) or outbound (hotel → guest). |
sent_by | guest, agent (hotel staff in Guestly), ai (Guestly AI assistant), api (a partner API key — including yours), whatsapp_app (the hotel's WhatsApp Business app), or system (campaign, broadcast or scheduled message). |
sender_name | Staff member, AI assistant name, or the API key's label, when known. |
type | text, template, image, video, audio, document, sticker, location, contacts or reaction. |
text | Message text, media caption, or the template text as the guest saw it. |
media | { url, filename } for media messages, otherwise null. |
location | { latitude, longitude, name?, address? } for location messages. |
reaction | { message_id, emoji } for reactions. |
template | { name, language } for template messages. |
reply_to | WhatsApp id of the message this one replies to. |
status | received (inbound), sending, sent, delivered, read or failed. |
error | { code, message } from WhatsApp when status is failed. |
created_at | ISO-8601 UTC. |
10. Webhooks
The hotel's owner enters your HTTPS endpoint in Tools & Integrations → API access → Webhook, turns it on and shares the signing secret (whsec_…) with you. The Send test event button sends a ping.
Guestly then POSTs every event to that one URL:
POST https://your-server.example.com/guestly
Content-Type: application/json
X-Guestly-Event: message.created
X-Guestly-Delivery: evt_qGlH_spKIgkHAapr
X-Guestly-Signature: t=1790545000,v1=5f2b…{
"id": "evt_qGlH_spKIgkHAapr",
"event": "message.created",
"created_at": "2026-09-28T10:14:03.512Z",
"business_id": "66e42ceeb622697a6f788bb3",
"data": { …message object (section 9)… }
}event | data |
|---|---|
message.created | A new message in any of the hotel's conversations — both directions, every sender (including messages you sent). |
message.status | { id, message_id, room_id, status, error, timestamp } when an outbound message is sent, delivered, read or failed. |
ping | Test event. |
Verify the signature
v1 is the HMAC-SHA256 of "{t}.{raw request body}" with the signing secret. Reject requests whose signature doesn't match or whose t is more than 5 minutes old.
Node.js:
const crypto = require('crypto');
function verifyGuestly(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const expected = crypto.createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex');
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
const given = Buffer.from(String(parts.v1 || ''));
return fresh && given.length === expected.length && crypto.timingSafeEqual(Buffer.from(expected), given);
}Python:
import hashlib, hmac, time
def verify_guestly(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
expected = hmac.new(secret.encode(), f"{parts['t']}.".encode() + raw_body, hashlib.sha256).hexdigest()
fresh = abs(time.time() - int(parts["t"])) < 300
return fresh and hmac.compare_digest(expected, parts["v1"])Delivery
- Answer with any
2xxwithin 5 seconds, then process the event asynchronously. - Anything else (or a timeout) is retried after 30 s, 2 min, 10 min, then every 15 min — for about 6 hours in total.
- Delivery is at least once: deduplicate on the event
id(or the messageid). - Events can arrive out of order: sort by
created_at, and treatmessage.statusas "latest wins". If you ever miss events, catch up withGET /partner/rooms/{id}/messages?after=….
11. Quick reference
| Method | Path | Purpose |
|---|---|---|
GET | /partner/me | Check the key; hotel and WhatsApp number. |
GET | /partner/templates | Approved templates and the values they need. |
POST | /partner/messages | Send a template, text, image, video or document. |
GET | /partner/rooms | Conversations, newest activity first. |
GET | /partner/rooms/{id}/messages | Messages of one conversation. |
POST | your webhook URL | Guestly → you: message.created, message.status, ping. |
All paths are relative to https://api.guestly.ai/api/v1.
Support
For test keys on our test environment, template design or higher limits, contact the Guestly integrations team.