Developers

Guestly Partner API

Guestly's partner API lets your platform run WhatsApp conversations for every hotel you manage.

Base URLhttps://api.guestly.ai/api/v1

Overview

Send approved WhatsApp templates (booking offers, check-in messages, links to your own offer pages) and free-form replies with text, images, videos or documents.
Read every conversation and message, so your team sees WhatsApp next to the email threads it already uses.
Stay in sync in real time: Guestly calls your server for every new message — sent by the guest, the hotel's staff, the Guestly AI assistant, a campaign, or you — and for every delivery status change.
One API key belongs to one hotel. Integrate once and use one key per property.

1. Before you begin

Base URLhttps://api.guestly.ai/api/v1
Test environmenthttps://dev-api.guestly.ai/api/v1 (keys start with gst_test_)
Authenticationx-api-key: <key> header on every request
Format JSON (UTF-8). File uploads use multipart/form-data.

On the hotel's side:

  1. The hotel is onboarded on Guestly and has connected its WhatsApp Business number.
  2. 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.
  3. To send templates, the hotel has at least one template approved by WhatsApp (you can list them with the API, see section 4).
  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:

HTTP
GET /partner/me
x-api-key: gst_live_…
JSON
{
  "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:

JSON
{
  "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" }
  }
}
HTTPcodeMeaning
400VALIDATION_ERRORA field is missing or invalid (the message says which).
400INVALID_PHONE The phone number can't be parsed. Use +<country code><number>.
400MISSING_TEMPLATE_VARIABLE A template variable has no value (details.missing lists them).
401INVALID_API_KEYMissing, wrong or revoked key.
404CONTACT_NOT_FOUND / ROOM_NOT_FOUND / TEMPLATE_NOT_FOUNDNot found for this hotel.
409TEMPLATE_NOT_APPROVEDThe template exists but WhatsApp hasn't approved it.
409OUTSIDE_24H_WINDOW Free-form messages need the guest to have written in the last 24 hours. Send a template instead.
409WHATSAPP_NOT_CONNECTEDThe hotel has no WhatsApp number connected.
422NOT_ON_WHATSAPPThe number isn't a WhatsApp account.
429RATE_LIMITED Too many requests; retry after Retry-After seconds.
502WHATSAPP_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 pass name, 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.open and window.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

HTTP
GET /partner/templates?language=it

language (en, it, de) is optional. Only templates approved by WhatsApp are returned.

JSON
{
  "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 }
      ]
    }
  ]
}
  • variables are the body placeholders {{1}}, {{2}}, … in order.
  • auto_filled: true means 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.
  • example is the sample the hotel entered when creating the template. It is never sent in place of a missing value.
  • dynamic: true buttons need a value for the {{1}} in their URL (for example an offer id).

5. Send a message

HTTP
POST /partner/messages

Every message has a recipient to and a type: text, image, video, document or template.

Recipient

JSON
"to": { "contact_id": "65f1c0b8a2e3f4c5d6e7f8a9" }

or

JSON
"to": { "phone": "+393331234567", "name": "Mario", "surname": "Rossi", "email": "[email protected]", "language": "it" }

5.1 Template (works any time)

JSON
{
  "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"]
    }
  }
}
FieldRequiredDescription
template.name, template.languageyesAs returned by GET /partner/templates.
variables.bodyif 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_urlno Replaces the approved header image, video or document for this send.
variables.buttonsif 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)

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

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

Bash
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):

JSON
{
  "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 partLimit
Text messageup to 4,096 characters
Media captionup to 1,024 characters
ImageJPEG or PNG, up to 5 MB
VideoMP4 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 - or 1. , 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)

HTTP
GET /partner/rooms?status=open&updated_since=2026-09-27T00:00:00Z&page=1&limit=20
ParameterDescription
statusopen or closed.
contact_id / phoneOnly 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.
JSON
{
  "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

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

FieldDescription
idGuestly message id. Stable; use it to deduplicate.
message_id WhatsApp message id (wamid…), or null if it wasn't sent.
room_idThe conversation.
contact{ id, phone, name } of the guest.
directioninbound (guest → hotel) or outbound (hotel → guest).
sent_byguest, 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.
typetext, 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_toWhatsApp id of the message this one replies to.
statusreceived (inbound), sending, sent, delivered, read or failed.
error{ code, message } from WhatsApp when status is failed.
created_atISO-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:

HTTP
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…
JSON
{
  "id": "evt_qGlH_spKIgkHAapr",
  "event": "message.created",
  "created_at": "2026-09-28T10:14:03.512Z",
  "business_id": "66e42ceeb622697a6f788bb3",
  "data": { …message object (section 9)… }
}
eventdata
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.
pingTest 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:

JavaScript
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:

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 2xx within 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 message id).
  • Events can arrive out of order: sort by created_at, and treat message.status as "latest wins". If you ever miss events, catch up with GET /partner/rooms/{id}/messages?after=….

11. Quick reference

MethodPathPurpose
GET/partner/meCheck the key; hotel and WhatsApp number.
GET/partner/templatesApproved templates and the values they need.
POST/partner/messagesSend a template, text, image, video or document.
GET/partner/roomsConversations, newest activity first.
GET/partner/rooms/{id}/messagesMessages of one conversation.
POSTyour 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.

Language

© 2025 Guestly