ChatBedsDevelopers
Apps and OAuth

OAuth 2.0

Connect your app to a hotel with the authorization code flow and PKCE, swap the code for tokens, refresh them safely and revoke them.

A hotel connects your app with the OAuth 2.0 authorization code flow (RFC 6749). PKCE (RFC 7636) with S256 is supported, and you should use it.

EndpointAddress
Consent (in the hotel's browser)https://app.chatbeds.app/oauth/authorize
Token (from your server)https://api.chatbeds.app/oauth/token
Revoke (from your server)https://api.chatbeds.app/oauth/revoke

The flow at a glance

  1. The hotel presses Connect in its Apps directory, which opens your install address.
  2. Your server makes a state and a PKCE code_verifier, keeps them, and redirects the browser to the consent page.
  3. The hotel owner or admin signs in to ChatBeds, picks a property and presses Allow.
  4. ChatBeds sends the browser back to your redirect_uri with a code and your state.
  5. Your server checks the state, then swaps the code for an access token and a refresh token at /oauth/token.
  6. You call /partner/v1 with Authorization: Bearer cbat_... and refresh the token before it expires.
Authorize URL
https://app.chatbeds.app/oauth/authorize
  ?response_type=code
  &client_id=cba_WID1hm0lWAQFdXATrQ47Qpd5
  &redirect_uri=https%3A%2F%2Fbooker.example.com%2Fchatbeds%2Fcallback
  &scope=property%3Aread%20rooms%3Aread%20availability%3Aread%20reservations%3Aread
  &state=af0ifjsldkj
  &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
  &code_challenge_method=S256
ParameterRequiredValue
response_typeYesAlways code
client_idYesYour app's client ID, cba_...
redirect_uriYesOne of your registered redirect addresses, exactly as registered
scopeNoScopes separated by spaces, a subset of the app's scopes. Leave it out to ask for all of them
stateRecommendedA random value you keep and check when the hotel comes back. Up to 500 characters. Returned unchanged
code_challengeRecommendedThe PKCE challenge: base64url (no padding) of the SHA-256 of your code_verifier, 43 to 128 characters
code_challenge_methodWith code_challengeS256. Other methods are refused

URL-encode every value. In particular, the spaces in scope become %20 (or +).

2. What the hotel sees

The hotel must be signed in to ChatBeds as an owner or admin. The consent screen shows:

  • Connect followed by your app's name, your tagline and category, and links to your website, support email and privacy policy;
  • for an app that is not yet listed, the note "This app is not reviewed by ChatBeds yet. Only your own account can connect it, so you can test it.";
  • "Booker will be able to:" with one line per scope you asked for, in plain words (see Scopes);
  • a Property picker. Each connection is for one property; one already connected is marked (already connected), and allowing again updates what your app may do there;
  • Cancel and Allow.

If something is wrong with your request, the hotel sees an error on ChatBeds and is not sent back to you:

The hotel seesCause
This link is not completeclient_id or redirect_uri is missing
We could not find this appUnknown client_id
This link cannot be usedredirect_uri is not registered, or scope has a scope your app may not ask for, or code_challenge is not a valid S256 challenge
This app is not availableThe app is suspended, or it isn't listed yet and belongs to another account
Ask an owner or adminThe signed-in user is not an owner or admin
An error with See plansThe hotel's plan doesn't include the partner API (your own apps on your own properties are exempt)

3. Handle the redirect back

If the hotel presses Allow:

https://booker.example.com/chatbeds/callback?code=Q1xN…code&state=af0ifjsldkj

If the hotel presses Cancel:

https://booker.example.com/chatbeds/callback?error=access_denied&error_description=The+hotel+did+not+allow+it.&state=af0ifjsldkj

If your redirect address already has a query string, these parameters are added to it with &.

Always check state

Reject the callback if state doesn't match the one you stored for this browser. Then forget it, so it can't be used twice.

4. Swap the code for tokens

From your server, POST to the token endpoint, form-encoded. Authenticate with client_id and client_secret in the body (client_secret_post) or with HTTP Basic (Authorization: Basic base64(client_id:client_secret)). A JSON body is also accepted.

POSThttps://api.chatbeds.app/oauth/token
Operation grant_type=authorization_code
grant_type stringrequired
authorization_code
code stringrequired
The code from the redirect.
redirect_uri stringrequired
The same redirect_uri you sent to the consent page.
code_verifier string
The PKCE verifier. Required if you sent a code_challenge.
client_id stringrequired
Your client ID, unless you use HTTP Basic.
client_secret stringrequired
Your client secret, unless you use HTTP Basic.
Request
curl -X POST https://api.chatbeds.app/oauth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d grant_type=authorization_code \
  -d code=86CYBF_H7… \
  -d redirect_uri=https://booker.example.com/chatbeds/callback \
  -d code_verifier=DFNCU5K8p6aydqui4_E2eiQdTdnctPpgA-oxd3PW6FDb63jYeQB2s1LY_zW_A31V \
  -d client_id=cba_WID1hm0lWAQFdXATrQ47Qpd5 \
  -d client_secret=cbs_••••
200 OK
{
  "access_token": "cbat_DqJc…",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "cbrt_pXXS…",
  "scope": "property:read rooms:read availability:read reservations:read",
  "property_id": "19306340-c7cf-465a-93d3-0b7873a7e85d",
  "installation_id": "c2205d78-c0e7-4d92-94be-9b91e1ab42aa"
}
FieldMeaning
access_tokencbat_.... Send it as Authorization: Bearer. Lasts 1 hour (expires_in is in seconds)
refresh_tokencbrt_.... Lasts 90 days. Works once
scopeWhat the hotel allowed, separated by spaces
property_idThe property this connection acts for
installation_idThe connection. Store your tokens against it: one hotel can connect your app to several properties, each with its own connection

The code lasts 10 minutes and works once. Token responses carry Cache-Control: no-store.

5. Call the API

curl "https://api.chatbeds.app/partner/v1/properties/19306340-c7cf-465a-93d3-0b7873a7e85d/offers?arrival=2026-10-31&departure=2026-11-02&adults=2" \
  -H "Authorization: Bearer cbat_DqJc…"

App tokens call the same operations as API keys, limited to the scopes the hotel allowed. A missing scope answers 403. See Scopes.

6. Refresh tokens

Before the access token expires (or when a call answers 401), swap the refresh token for a new pair.

Request
curl -X POST https://api.chatbeds.app/oauth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d grant_type=refresh_token \
  -d refresh_token=cbrt_pXXS… \
  -d client_id=cba_WID1hm0lWAQFdXATrQ47Qpd5 \
  -d client_secret=cbs_••••
200 OK
{
  "access_token": "cbat_w6zd…",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "cbrt_q93o…",
  "scope": "property:read rooms:read availability:read reservations:read",
  "property_id": "19306340-c7cf-465a-93d3-0b7873a7e85d",
  "installation_id": "c2205d78-c0e7-4d92-94be-9b91e1ab42aa"
}

Refreshing rotates both tokens: the old refresh token is used up, and you must store the new one. Each refresh starts a new 90-day refresh token, so a connection you use regularly never needs the hotel again.

Reusing a refresh token stops the connection

If a refresh token is used a second time, ChatBeds assumes someone else has a copy and stops every token of that connection:

400 Bad Request
{
  "error": "invalid_grant",
  "error_description": "This refresh token was already used. All tokens for this connection were stopped."
}

The hotel then has to connect your app again.

Refresh from one place at a time

If two of your workers refresh the same token at the same moment, the second one counts as a reuse and ends the connection. Refresh behind a lock per installation_id, and save the new refresh token before you use the new access token.

7. Revoke a token

When a hotel disconnects from your side, or you no longer need a token, revoke it (RFC 7009).

Request
curl -X POST https://api.chatbeds.app/oauth/revoke \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d token=cbat_w6zd… \
  -d client_id=cba_WID1hm0lWAQFdXATrQ47Qpd5 \
  -d client_secret=cbs_••••

It answers 200 with an empty body, even for a token that is unknown or already revoked. Revoking either token of a pair stops both the access token and its refresh token. Don't use a revoked refresh token afterwards: that counts as a reuse and stops the whole connection.

Revoking doesn't remove the connection from the hotel's Apps page. Only the hotel can Disconnect it. To get new tokens after revoking, send the hotel through the consent page again.

When tokens stop working

What happenedWhat you see
The access token expired (after 1 hour)401 from /partner/v1. Refresh
The hotel pressed Disconnect on its Apps page401 from /partner/v1, then invalid_grant when you refresh
ChatBeds suspended your app401 from /partner/v1, and invalid_client from /oauth/token
The refresh token expired (90 days unused)invalid_grant. The hotel connects again
A refresh token was used twiceinvalid_grant, and every token of the connection stops

ChatBeds doesn't send an "uninstalled" webhook.

Errors

The OAuth endpoints answer errors as RFC 6749 describes, not with detail:

401 Unauthorized
{
  "error": "invalid_client",
  "error_description": "Unknown client, or wrong client secret."
}
errorStatusWhen
invalid_client401Unknown client ID, wrong client secret, or the app is suspended
invalid_grant400The code is unknown, used or expired; redirect_uri differs from the one sent to the consent page; code_verifier doesn't match the challenge; the hotel disconnected; the refresh token is unknown, expired or already used
unsupported_grant_type400grant_type is not authorization_code or refresh_token
slow_down429More than 60 calls a minute to the OAuth endpoints from your address. Wait for the Retry-After seconds

Read error_description for the exact reason; it is written for developers.

Complete example

These samples make the PKCE pair and state, build the authorize URL, and swap the code. They keep pending sign-ins in memory for brevity; in production keep them in your session store.

oauth.mjs
import crypto from "node:crypto";
import express from "express";

const CLIENT_ID = process.env.CHATBEDS_CLIENT_ID;         // cba_...
const CLIENT_SECRET = process.env.CHATBEDS_CLIENT_SECRET; // cbs_...
const REDIRECT_URI = "https://booker.example.com/chatbeds/callback";
const SCOPES = ["property:read", "rooms:read", "availability:read", "reservations:read"];

const pending = new Map(); // state -> code_verifier
const app = express();

// Your install address: the directory's Connect button opens this.
app.get("/chatbeds/connect", (req, res) => {
  const verifier = crypto.randomBytes(48).toString("base64url"); // 64 characters
  const challenge = crypto.createHash("sha256").update(verifier).digest("base64url");
  const state = crypto.randomBytes(16).toString("base64url");
  pending.set(state, verifier);

  const url = new URL("https://app.chatbeds.app/oauth/authorize");
  url.search = new URLSearchParams({
    response_type: "code",
    client_id: CLIENT_ID,
    redirect_uri: REDIRECT_URI,
    scope: SCOPES.join(" "),
    state,
    code_challenge: challenge,
    code_challenge_method: "S256",
  }).toString();
  res.redirect(url.toString());
});

app.get("/chatbeds/callback", async (req, res) => {
  const { code, state, error } = req.query;
  const verifier = pending.get(state);
  pending.delete(state);
  if (!verifier) return res.status(400).send("Unknown or used state.");
  if (error) return res.send("The hotel did not connect the app.");

  const tokenRes = await fetch("https://api.chatbeds.app/oauth/token", {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "authorization_code",
      code,
      redirect_uri: REDIRECT_URI,
      code_verifier: verifier,
      client_id: CLIENT_ID,
      client_secret: CLIENT_SECRET,
    }),
  });
  const tokens = await tokenRes.json();
  if (!tokenRes.ok) return res.status(400).send(tokens.error_description);

  // Save access_token, refresh_token and the expiry against tokens.installation_id
  // and tokens.property_id, encrypted, before doing anything else.
  res.send(`Connected to property ${tokens.property_id}.`);
});

app.listen(3000);

Refreshing

refresh.mjs
export async function refresh(refreshToken) {
  const res = await fetch("https://api.chatbeds.app/oauth/token", {
    method: "POST",
    headers: {
      "Content-Type": "application/x-www-form-urlencoded",
      Authorization: "Basic " + Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64"),
    },
    body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken }),
  });
  const body = await res.json();
  if (!res.ok) throw new Error(`${body.error}: ${body.error_description}`);
  return body; // store BOTH new tokens; the old refresh token is used up
}

Building something?

On this page