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.

Login with Maison is available. API key authentication is the standard method. OIDC “Login with Maison” (OAuth 2.1 authorization code flow + PKCE) is now live for third-party relying parties. See the section below.

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.

The full key is displayed once, in the dialog that opens after creation, and cannot be recovered. Copy it there and store it securely before closing the dialog.

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:

PrefixEnvironment
mx_t_Test / Local / Dev
mx_s_Staging
mx_p_Production

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/*.

bash
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.

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

json
{
  "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.

json
{ "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, email and profile are not on the form; request them in the authorize URL.
For confidential clients the client secret is displayed once, in the dialog that opens after registration. Copy it there — it cannot be recovered. A verified badge appears when Maison has reviewed the app; the flow works before verification.

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.

javascript
// 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).

javascript
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.

bash
# 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:

  • iss equals https://maison-labs.com
  • aud includes your client_id
  • exp is in the future
  • nonce matches the value you sent at authorization
javascript
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 key

6. 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).

javascript
// 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
};
Do not call /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.

html
<!-- 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.

javascript
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.

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

ScopeClaims returned
openidsub
emailemail, email_verified
profilename, given_name, family_name, updated_at
email_verified is 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.

javascript
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.

11. Common errors

ErrorCauseFix
access_deniedUser declined consentShow an error and offer a retry
interaction_requiredUser not logged in to MaisonRestart the flow — do not loop silently
invalid_requestMissing or malformed parameterCheck redirect_uri exact match and PKCE params
invalid_grant (token endpoint)Code expired, already used, or verifier wrongRestart the flow from the authorize step
invalid_token (userinfo)Every Maison access token is issued for an API, and /as/userinfo refuses all of themRead identity from the id_token (step 6)