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.
| Endpoint | Address |
|---|---|
| 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
- The hotel presses Connect in its Apps directory, which opens your install address.
- Your server makes a
stateand a PKCEcode_verifier, keeps them, and redirects the browser to the consent page. - The hotel owner or admin signs in to ChatBeds, picks a property and presses Allow.
- ChatBeds sends the browser back to your
redirect_uriwith acodeand yourstate. - Your server checks the
state, then swaps thecodefor an access token and a refresh token at/oauth/token. - You call
/partner/v1withAuthorization: Bearer cbat_...and refresh the token before it expires.
1. Send the hotel to the consent page
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| Parameter | Required | Value |
|---|---|---|
response_type | Yes | Always code |
client_id | Yes | Your app's client ID, cba_... |
redirect_uri | Yes | One of your registered redirect addresses, exactly as registered |
scope | No | Scopes separated by spaces, a subset of the app's scopes. Leave it out to ask for all of them |
state | Recommended | A random value you keep and check when the hotel comes back. Up to 500 characters. Returned unchanged |
code_challenge | Recommended | The PKCE challenge: base64url (no padding) of the SHA-256 of your code_verifier, 43 to 128 characters |
code_challenge_method | With code_challenge | S256. 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 sees | Cause |
|---|---|
| This link is not complete | client_id or redirect_uri is missing |
| We could not find this app | Unknown client_id |
| This link cannot be used | redirect_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 available | The app is suspended, or it isn't listed yet and belongs to another account |
| Ask an owner or admin | The signed-in user is not an owner or admin |
| An error with See plans | The 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=af0ifjsldkjIf the hotel presses Cancel:
https://booker.example.com/chatbeds/callback?error=access_denied&error_description=The+hotel+did+not+allow+it.&state=af0ifjsldkjIf 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.
https://api.chatbeds.app/oauth/tokengrant_type=authorization_codeauthorization_codecode from the redirect.redirect_uri you sent to the consent page.code_challenge.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_••••{
"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"
}| Field | Meaning |
|---|---|
access_token | cbat_.... Send it as Authorization: Bearer. Lasts 1 hour (expires_in is in seconds) |
refresh_token | cbrt_.... Lasts 90 days. Works once |
scope | What the hotel allowed, separated by spaces |
property_id | The property this connection acts for |
installation_id | The 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.
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_••••{
"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:
{
"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).
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 happened | What you see |
|---|---|
| The access token expired (after 1 hour) | 401 from /partner/v1. Refresh |
| The hotel pressed Disconnect on its Apps page | 401 from /partner/v1, then invalid_grant when you refresh |
| ChatBeds suspended your app | 401 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 twice | invalid_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:
{
"error": "invalid_client",
"error_description": "Unknown client, or wrong client secret."
}error | Status | When |
|---|---|---|
invalid_client | 401 | Unknown client ID, wrong client secret, or the app is suspended |
invalid_grant | 400 | The 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_type | 400 | grant_type is not authorization_code or refresh_token |
slow_down | 429 | More 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.
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
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?