Skip to content

Discovery and device sessions

How a native client, such as the GuestCraft mobile app or a branded build of it, finds an instance and signs a person in (ADR 0041). The flow is OAuth 2.0 for native apps (RFC 8252): the person signs in on the instance’s own page in the system browser, and the app exchanges a one-time code with PKCE S256 (RFC 7636) for a device token of its own. API revision 3.4.0; since 4.6.0 a signed-in browser can also pair a phone with a QR code.

GET /.well-known/guestcraft

Public, no credentials, Cache-Control: public, max-age=300. The same document is GET /api/v1/instance (instance.get).

{
"name": "Hotel Aurora",
"api_base_url": "https://pms.example.com/api/v1",
"server_version": "0.1.0",
"api_revision": "3.4.0",
"min_app_version": "0.1.0",
"capabilities": ["front_desk", "housekeeping", "mobile_sessions", "…"],
"auth": {
"methods": ["password"],
"mobile": {
"authorization_url": "https://pms.example.com/mobile/authorize",
"token_url": "https://pms.example.com/api/v1/device_sessions",
"redirect_schemes": ["dev.guestcraft.app"]
}
}
}

A client should refuse an instance whose URLs are on another origin, that lacks the mobile_sessions capability, that does not list its redirect scheme, or whose min_app_version is above its own version; and ignore members it does not know. The SDK’s discoverInstance(baseUrl) reads and validates the document. An operator sets the name with GUESTCRAFT_INSTANCE_NAME and the allowed schemes with GUESTCRAFT_MOBILE_APP_SCHEMES (default dev.guestcraft.app; add exp to develop with Expo Go).

  1. Create a verifier of 43 to 128 unreserved characters, its challenge BASE64URL(SHA256(verifier)) without padding, and a state of 8 to 256 unreserved characters.

  2. Open authorization_url in the system browser (ASWebAuthenticationSession, Custom Tabs), never in an embedded web view:

    https://pms.example.com/mobile/authorize?redirect_uri=dev.guestcraft.app://auth
    &code_challenge=…&code_challenge_method=S256&state=…&device_name=Pixel%209

    The page refuses a redirect URI with another scheme, a missing or malformed challenge, another method than S256, and a device name that is empty, longer than 100 characters or holds control characters; it then shows an error and redirects nowhere. Otherwise the person signs in if needed, sees the device name, their organization and the instance’s address, and chooses:

    • Allow redirects to redirect_uri?code=gcc_…&state=…. The code is valid for two minutes and bound to the person, their active organization, the challenge, the redirect URI and the device name. The page issues it with POST /api/v1/device_authorizations, which only a browser session from the instance’s origin may call.
    • Cancel redirects to redirect_uri?error=access_denied&state=….
  3. Check that state is the one you sent, then exchange the code. The request needs no cookie, Origin or other credential:

    POST /api/v1/device_sessions
    Content-Type: application/json
    { "code": "gcc_…", "code_verifier": "…", "redirect_uri": "dev.guestcraft.app://auth" }
    {
    "token": "gcd_…",
    "device_session": {
    "id": "dvs_…", "name": "Pixel 9", "created_at": "…", "last_used_at": "…",
    "current": true, "etag": "\"1761724800000\""
    },
    "user": { "id": "usr_…", "name": "Ada Rossi", "email": "ada@example.com" }
    }

    Each code serves one attempt. An unknown, expired or already used code, another redirect URI and a wrong verifier all answer the same 400 device_session.invalid_grant; the code cannot be tried again, and a second use of a code also revokes the session the first one created. Like a pairing code below, the exchange is admitted first and can answer 429 auth.rate_limited with Retry-After, without using the code.

Keep the token in the platform keystore. It is shown once; the instance stores only its SHA-256.

A person signed in on the web pairs a phone without a second sign-in: the browser shows a QR code with a one-time pairing code, and the app that scans it gets its own device session, as that person. Discovery lists the mobile_pairing capability when the instance offers it.

  1. The browser issues a pairing. It needs its session cookie and the instance’s Origin; it sends no body:

    POST /api/v1/device_pairings
    {
    "id": "dvp_…", "status": "pending", "expires_at": "2026-10-31T09:02:00Z",
    "device_session": null, "etag": "\"1793437200000\"",
    "code": "gcp_…", "expires_in": 120,
    "pairing_url": "https://pms.example.com/mobile/pair#gcp_…"
    }

    The QR code carries pairing_url: the instance’s own address, with the code in the fragment, which a browser that opens the address never sends to a server. The code is shown in this answer only, is valid for two minutes, and is exchanged once, only while this browser session exists. expires_in is the whole seconds the code has left when the answer is sent, by the instance’s clock (RFC 8628): count down from it instead of comparing expires_at with the computer’s clock. An issuance that outlasted the code answers 0 and status: expired.

  2. The app scans the code, takes the origin of pairing_url, reads discovery there, refuses an instance without mobile_pairing, shows the instance, and sends the code with a name of its own to discovery’s token_url only, without cookies or Origin:

    POST /api/v1/device_sessions
    Content-Type: application/json
    { "pairing_code": "gcp_…", "device_name": "Pixel 9" }

    The answer is the same 201 as for a sign-in code: the token, the device session and the person. An unknown, expired, used or withdrawn code, a sign-in code, a code whose browser has signed out, and a code issued while the instance had another base URL all answer 400 device_session.invalid_grant, and a second use also revokes the session the first one created. A name that is empty, longer than 100 characters or holds control or invisible characters answers 400 device_session.invalid_request with field: device_name, and the code stays usable.

    The code is bound to the instance’s origin: it is exchanged only at the origin of pairing_url. Sent to another address that reaches the same instance, it answers the same 400 device_session.invalid_grant before it is looked up, and stays usable at the right one. The instance reads the address the request reached, never an Origin header, so the app sends none.

    Every exchange that would look a code up, of either grant, is first admitted in the instance’s budgets of unauthenticated lookups, those of API keys and device tokens: one attempt at a time per client address, 30 failed attempts a minute per address and 300 per IPv6 /48, and at most half the auth pool for all guesses of the instance. Otherwise it answers 429 auth.rate_limited with Retry-After at once, whatever the code, without using it; retry after Retry-After. A code that exchanges gives its attempt back. Two phones that scan one QR code at once are not refused for racing: their attempts run one after the other, so the second still revokes the session of the first.

  3. Meanwhile the browser polls GET /api/v1/device_pairings/{id} every two seconds or so. The answer has no code; status is pending until a device uses the code or it expires, then paired, with device_session (id, name, created_at), while that session exists, refused once a device used the code without getting a session (a refused exchange, or a second use that revoked it), or expired.

  4. When the browser stops showing a code nobody used, it withdraws it with DELETE /api/v1/device_pairings/{id} and If-Match: <etag>; the code then answers invalid_grant. The etag identifies the issuance, not the status: it stays the same when the status changes.

Only the browser session that issued a pairing reads or withdraws it. “Sign out other sessions” and a password change that signs out the others delete pending pairing codes, like sign-in codes. The paired device appears in the device list and is revoked like any other.

What each pairing operation can answer, besides 500 server.internal and 503 server.unavailable:

Operation Answers
POST /device_pairings 201; 400 request.invalid for a malformed Content-Length; 401 auth.unauthenticated without a session cookie, or with a device token or an API key, even next to one; 403 auth.forbidden without an active organization, for a person no longer in it, or with another Authorization credential next to the cookie; 403 request.untrusted_origin without the instance’s Origin; 413 request.too_large and 415 request.unsupported_media_type for a body too large or not JSON (a JSON body is ignored)
GET /device_pairings/{id} 200; 400 request.invalid for a malformed identifier (id.invalid) or Content-Length; 401 auth.unauthenticated; 404 device_pairing.not_found for another browser session’s or person’s pairing, a withdrawn one, or one removed an hour after its expiry; 415 for a body without a JSON Content-Type
DELETE /device_pairings/{id} 204; 400 request.invalid without If-Match, or for a malformed one, identifier or Content-Length; 401; 403 request.untrusted_origin; 404 device_pairing.not_found; 409 device_pairing.used once a device used the code, paired or refused (sign that device out instead); 412 device_pairing.version_mismatch for an If-Match other than the pairing’s etag; 413; 415
POST /device_sessions with a pairing code 201; 400 device_session.invalid_grant or device_session.invalid_request as above; 400 request.invalid for a body that is neither grant or for a malformed Content-Length; 400 request.malformed for a body that is not valid JSON; 413; 415; 429 auth.rate_limited, with Retry-After, when the exchange is not admitted (the sign-in grant too)

The three pairing operations never answer 429: a device token never reaches a lookup on them.

Send Authorization: Bearer gcd_… to every /api/v1 operation that lists the deviceSession security scheme, with GuestCraft-Workspace-Id where the operation needs a workspace. The token acts with the live role, permissions and property scope of the person in the organization that was active in the browser: a role change or a removal applies on the next request. A request with a device token is never authenticated by a cookie, and needs no Origin header.

GET /api/v1/workspaces lists the workspaces the person reaches, with role, permissions and property_ids (null for all properties), for a workspace and property picker.

Each instance admits device-token requests by the budgets of API keys (API revision 3.9.1, see API keys). A token it remembers gets 600 requests a minute. Any other token, such as on its first request to an instance after the instance starts, is a guess, looked up once: its other requests sent meanwhile wait for that lookup and get its answer, so the app’s parallel first reads all succeed. Guesses of other tokens from the same client address are looked up one at a time; they, and requests the budgets refuse, get 429 auth.rate_limited at once with Retry-After, including GET and DELETE /api/v1/device_sessions, so retry after Retry-After. Unknown tokens from one address, or one IPv6 /48, use up its failure budget, never a remembered token’s.

Device tokens cannot issue codes, manage the team, administer Channex, or call the identity routes under /api/auth. Channex administration (the connection and its credential, the account mode, upstream property, room-type and rate-plan discovery, links, mappings, webhooks, import drains and retries, ARI publication changes, and the Messages installation and synchronization) is browser-only since API 3.7.0. Every browser-only operation (the team, the issuance of codes and Channex administration) answers 401 auth.unauthenticated to a request that carries a device token, even next to a valid session cookie (ADR 0041). Channex state and health reads, conversations, replies and templates accept the token.

Operation Credentials Result
GET /api/v1/device_sessions browser session or device token the person’s live sessions, most recently used first; current marks the caller’s own
DELETE /api/v1/device_sessions/{id} browser session (with Origin) or device token, If-Match: <etag> 204; the token answers 401 from its next request

DELETE answers 404 device_session.not_found for someone else’s or an ended session, and 412 device_session.version_mismatch when the etag is another issuance’s. The app signs out by revoking its own session. Account settings in the web list the same sessions with a sign-out per device.

A session also ends 30 days after its last use, 180 days after its creation, when the person signs out their other sessions, and when they change their password with “sign out other sessions”. The last two also void the person’s codes that no device has exchanged yet. A code is exchanged only while the browser session that allowed it exists: once that browser signs out, or is signed out by another, the exchange answers 400 device_session.invalid_grant. Creation, revocation and a reused code are audited as device_session.created, device_session.revoked and device_session.code_reused in each workspace whose team lists the person, without the device name, code or token.

Push notifications, native passkeys, refresh tokens, and a device session for more than one organization at a time.