Authentication
The Maison public API accepts two credentials: an API key, and an OAuth access token that an app receives when a person connects it. Console session cookies and session tokens are accepted only by Maison's browser-facing apps and are not part of the public API surface.
Connecting Claude, ChatGPT or another MCP client, registering an app for your hotel, and approving or revoking connections are covered in Connect with OAuth.
Creating an API key
API keys are managed in the admin console. Sign in, open the API keys panel on your profile, give the key a name, and choose Create key. Keys are managed on your profile, not from the account control at the bottom of the sidebar.
Key creation requires an active console session — you must be signed in; an API key cannot mint another key. A key inherits the creating user's full permission set. Admins with the api-keys write permission can create a key for someone else from Users → the user → API keys; reading another user's keys needs the maison-users permission.
Without the console, a signed-in session can call the same operation directly: PUT /api/user/apikey with a JSON body { "description" } returns the key once in its key field. It accepts session authentication only, never an API key.
Key format
Keys follow the pattern mx_{env}_{base64url_payload}. The middle segment identifies the environment:
Your key for this environment starts with mx_p_. Keys are HMAC-SHA256 signed; only the signature hash is stored — the full key cannot be recovered after creation.
Sending requests
Include the key in the x-api-key request header on every call. No other credential is accepted on /api/v1/*.
curl https://maison-labs.com/api/v1/me \
-H "x-api-key: mx_p_YOUR_KEY"The key only reaches the public tool API at /api/v1/*, /mcp, and /a2a. It is silently ignored on all other routes.
Error semantics
401 Unauthorized
No valid API key was provided, or the key's owning user is disabled. Check that the x-api-key header is present and the value has not been truncated.
{ "error": "Unauthorized", "code": "unauthorized" }403 Forbidden
The key is valid but lacks one or more grants the tool requires. The response body names the missing grants:
{
"error": "Forbidden",
"code": "forbidden",
"details": {
"missing": [
{ "feature": "clients", "level": "WRITE", "scope": "global" }
]
}
}Authorization is all-or-nothing: if any one declared grant is missing, the whole call is refused. Composite tools name their atomic parts — a caller refused the composite may run the parts they are allowed individually.
403 not_entitled
The tool requires a feature that the target client's plan does not include. Callers with a global grant on the feature are exempt.
{ "error": "Client is not entitled to chat-logs", "code": "not_entitled", "details": { "feature": "chat-logs", "clientId": "11111111-…" } }Revoking a key
Revoke a key from the same panel: admin console → Profile → API keys (or Users → the user → API keys for someone else's key), choose Revoke and confirm. Revocation is immediate — the key becomes unusable on the next request. The equivalent call is DELETE /api/user/apikey/{id} from a signed-in session (never an API key); revoking another user's key requires the api-keys write permission.
Login with Maison
Third-party applications can use Maison as an OIDC identity provider. The flow is OAuth 2.1 authorization code + PKCE (S256 required), returning an id_token and an access token that works on the Maison public API. Discovery lives at https://maison-labs.com/.well-known/openid-configuration.
1. Register the app
Open Connect › Developer apps in the hotel's workspace in the admin console (the hotel needs the Developer apps feature, and you need the oauth-apps:WRITE permission on that hotel — see Register an app for your hotel). Fill in:
- Client type — Public (no secret) for a single-page, mobile or desktop app; Confidential for a server-side app, which authenticates at the token endpoint with
client_secret_post(secret in the form body). - Redirect URIs — exact URIs only; wildcards are not accepted. The URI used at authorization must match one registered here exactly.
- Scopes — the API permissions your app needs. The identity scopes
openid,emailandprofileare not on the form; request them in the authorize URL.
2. Build the authorize URL with PKCE
Generate a random state (CSRF token) and a random PKCE code_verifier, compute its SHA-256 as a base64url code_challenge, and include a nonce for id_token replay protection. code_challenge_method must be S256 — plain is rejected.
// 1. PKCE verifier and challenge (Web Crypto API)
async function pkce() {
const verifier = Array.from(crypto.getRandomValues(new Uint8Array(48)))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
const digest = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(verifier),
);
const challenge = btoa(String.fromCharCode(...new Uint8Array(digest)))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
return { verifier, challenge };
}
// 2. Store verifier and state for the callback, then redirect
const { verifier, challenge } = await pkce();
const state = crypto.randomUUID();
const nonce = crypto.randomUUID();
sessionStorage.setItem("oauth_verifier", verifier);
sessionStorage.setItem("oauth_state", state);
const params = new URLSearchParams({
response_type: "code",
client_id: "YOUR_CLIENT_ID",
redirect_uri: "https://yourapp.example.com/callback",
scope: "openid email profile",
state,
nonce,
code_challenge: challenge,
code_challenge_method: "S256",
});
window.location.assign("https://maison-labs.com/as/authorize?" + params);3. Handle the callback
After the user approves, Maison redirects to your redirect_uri. Always check for an error parameter first, then verify state matches what you stored, then check iss in the id_token (see step 5).
const url = new URL(window.location.href);
const error = url.searchParams.get("error");
if(error) {
// Handle: "access_denied", "interaction_required", etc.
throw new Error(url.searchParams.get("error_description") ?? error);
}
const code = url.searchParams.get("code");
const returnedState = url.searchParams.get("state");
const savedState = sessionStorage.getItem("oauth_state");
if(returnedState !== savedState) throw new Error("state mismatch — possible CSRF");4. Exchange the code at /as/token
Send the code and the stored verifier. Confidential Developer apps authenticate with client_secret_post: the client id and secret go in the form body. HTTP Basic is refused for them with invalid_client.
# Public client — send code_verifier, no client secret
curl -X POST https://maison-labs.com/as/token \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=authorization_code" \
--data-urlencode "code=AUTH_CODE_FROM_CALLBACK" \
--data-urlencode "redirect_uri=https://yourapp.example.com/callback" \
--data-urlencode "client_id=YOUR_CLIENT_ID" \
--data-urlencode "code_verifier=YOUR_VERIFIER"
# Confidential client — secret in the body (client_secret_post)
curl -X POST https://maison-labs.com/as/token \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "client_id=CLIENT_ID" \
--data-urlencode "client_secret=CLIENT_SECRET" \
--data-urlencode "grant_type=authorization_code" \
--data-urlencode "code=AUTH_CODE_FROM_CALLBACK" \
--data-urlencode "redirect_uri=https://yourapp.example.com/callback" \
--data-urlencode "code_verifier=YOUR_VERIFIER"The response includes access_token, id_token, token_type: "Bearer", and expires_in. A refresh_token is issued when the app is registered with refresh_token in grant_types.
5. Verify the id_token
Fetch the key set from https://maison-labs.com/as/jwks and verify the JWT signature (RS256). Then check:
issequalshttps://maison-labs.comaudincludes yourclient_idexpis in the futurenoncematches the value you sent at authorization
import * as jose from "jose";
const JWKS = jose.createRemoteJWKSet(
new URL("https://maison-labs.com/as/jwks"),
);
const { payload } = await jose.jwtVerify(idToken, JWKS, {
issuer: "https://maison-labs.com",
audience: "YOUR_CLIENT_ID",
});
if(payload.nonce !== savedNonce) throw new Error("nonce mismatch");
// payload.sub is the stable Maison user UUID — use this as your user key6. Read identity from the ID token
The verified id_token is where the person's identity lives. Every Maison access token is issued for one of the public APIs, so the id_token carries the claims of each identity scope you were granted: sub for openid; name, given_name, family_name and updated_at for profile; email and email_verified for email (see step 9).
// payload is the verified id_token from step 5
const user = {
id: payload.sub, // stable Maison user id
name: payload.name, // profile
givenName: payload.given_name, // profile
familyName: payload.family_name, // profile
email: payload.email, // email
emailVerified: payload.email_verified === true, // email
};/as/userinfo with a Maison access token: today it answers invalid_token for every one of them, because each is issued for an API. To refresh the claims later, exchange the refresh token; the response carries a new id_token.7. Embed the “Continue with Maison” button
The <maison-login-button> custom element handles PKCE, builds the authorize URL, and dispatches a cancelable maison-login event before navigating. Copy the file from the @maison-labs/ui-theme npm package and serve it from your own origin (node_modules/@maison-labs/ui-theme/src/maison-login-button.js). A Maison-hosted copy is not available.
<!-- Self-host the file from @maison-labs/ui-theme/src/maison-login-button.js -->
<script type="module" src="/static/maison-login-button.js"></script>
<maison-login-button
client-id="YOUR_CLIENT_ID"
redirect-uri="https://yourapp.example.com/callback"
state="CSRF_TOKEN"
code-challenge="CODE_CHALLENGE"
scope="openid email profile"></maison-login-button>Generate state and code-challenge in JavaScript before rendering the element (see step 2). Listen for the maison-login event to intercept navigation if needed — calling event.preventDefault() stops the redirect.
const btn = document.querySelector("maison-login-button");
btn.addEventListener("maison-login", (event) => {
// event.detail.url is the full authorize URL
// call event.preventDefault() to handle navigation yourself
console.log("navigating to", event.detail.url);
});8. Plain HTML/CSS variant
If you cannot use the web component, use this copy-paste starter. Replace the startLogin() body with your PKCE helper.
<style>
/* Token values from @maison-labs/ui-theme LOGIN_BUTTON_TOKENS */
.maison-login-btn {
display: inline-flex;
align-items: center;
gap: 0.75rem;
padding: 0 1.5rem;
min-height: 44px;
background: #6366f1;
color: #ffffff;
font-family: "Suisse Intl", "Suisse Int'l", system-ui, sans-serif;
font-size: 0.875rem;
font-weight: 600;
border: none;
border-radius: 0.375rem;
cursor: pointer;
transition: filter 100ms ease;
}
.maison-login-btn:hover { filter: brightness(1.12); }
@media (prefers-color-scheme: dark) {
.maison-login-btn { color: #ffffff; }
}
</style>
<button class="maison-login-btn" onclick="startLogin()">
Continue with Maison
</button>
<script>
async function startLogin() {
/* generate PKCE verifier/challenge and state (see step 2), then: */
const params = new URLSearchParams({
response_type: "code",
client_id: "YOUR_CLIENT_ID",
redirect_uri: "https://yourapp.example.com/callback",
scope: "openid email profile",
state: "STORED_STATE",
nonce: "STORED_NONCE",
code_challenge: "COMPUTED_CHALLENGE",
code_challenge_method: "S256",
});
window.location.assign("https://maison-labs.com/as/authorize?" + params);
}
</script>9. Scopes and claims
Standard OIDC scopes and the claims they put in the id_token:
true once the user has accepted a Maison invitation emailed to that address. Users provisioned before the invitation flow, or who have not yet accepted an invitation, report false. A later removal from a hotel does not un-verify the address.10. Sign out
RP-initiated logout redirects the user to the Maison end-session endpoint. Supply an id_token_hint and a registered post_logout_redirect_uri. After logout Maison issues a 303 to your URI with the optional state parameter.
const params = new URLSearchParams({
id_token_hint: "ID_TOKEN",
post_logout_redirect_uri: "https://yourapp.example.com/logged-out",
state: "LOGOUT_STATE",
});
window.location.assign("https://maison-labs.com/as/endsession?" + params);post_logout_redirect_uri is only honoured when it is listed in the app's registered post_logout_redirect_uris and a valid id_token_hint identifies the client. Unregistered URIs are silently cleared. The Developer apps form does not collect these URIs yet, so a Developer app's sign-out ends on Maison's page.