Identity integration guide
This page is the build. It assumes you’ve read the overview and hold a confidential client_id + client_secret with registered redirect URIs (see the onboarding checklist).
Base URL in the examples is Movmo’s e2e environment, where new integrations start:
const MOVMO_API = "https://e2e.api.movmo.io";Everything below is identical in production except the base URL and the chip’s isProd flag.
What you build
Section titled “What you build”Three small pieces: the one-click chip on your landing page, one callback page, and two backend calls (token exchange + resolve). There is nothing to install server-side beyond an HTTP client, no SDK requirement on the backend, and no changes to your PSS, auth stack, or loyalty core — verification of loyalty ownership stays entirely on your side, through whatever channel you already use. Loyalty linking is one more backend call when you want it.
Build it with an AI coding assistant
Section titled “Build it with an AI coding assistant”The fastest path: paste the prompt below into an AI coding assistant working inside your repo — Claude Code, Codex, Cursor, or similar. It encodes this guide’s contract, so the assistant scaffolds the right pieces against your stack and stops where your own systems (session issuance, loyalty verification) begin.
Integrate the Movmo one-click identity chip into this codebase. Movmo lets areturning traveler connect to an airline site in one click and, with explicitconsent, share their profile with it — docs athttps://docs.movmo.io/identity/integration-guide/ (read that page first; alsohttps://docs.movmo.io/identity/overview/ andhttps://docs.movmo.io/identity/operations-and-compliance/).
Facts you need:- Base URL: https://e2e.api.movmo.io (e2e environment; production is a base-URL switch later, plus isProd on the chip).- I have a confidential OAuth client: client_id = <CLIENT_ID>, and the client_secret is available to the backend as <ENV_VAR_NAME>. Registered redirect URI: <https://our-site.example/movmo/callback>. Register one redirect URI per origin the site is served on (www, apex, staging) and tell me each so it gets registered. Local dev can mount the chip on http://localhost:3000, but the connect flow itself only completes on a registered https origin; never register a tunnel hostname.- Frontend: if the site runs React, install @movmo/connect-button (2.0.0 or later) and render <ConnectMovmoButton clientId redirectUri scope traveler onSignedOut onRecognized onForgotten /> once, on the landing page only — never next to a checkout button. If it does not run React, load https://cdn.e2e.movmo.io/sdk/connect/v2/connect.js with one <script> tag and call MovmoConnect.mountButton({ clientId, redirectUri, scope, traveler, onSignedOut, onRecognized, onForgotten, onError }) — never build a React island for this. Mount it anywhere: the SDK renders the chip into its own fixed slot, so write no CSS, add no wrapper element and give it no target. chipPosition is optional ("bottom-left" default, "bottom-right", or "inline" for our own slot; inline needs a target element). scope is ["profile.read", "preferences.read"]. Build redirectUri from window.location.origin plus the callback path, never a hard-coded value. It is ONE chip that switches itself: pass the traveler prop (an object with firstName) from OUR OWN session (the stored guid resolved to claims) and it shows the recognized chip; pass null or nothing and it shows "Checkout faster with Movmo". Do not choose between two components and do not write conditional render logic. On a script-tag page, call the handle's update method when the session changes. "Not you?" and "Manage" are built into the chip; do not build our own sign-out control. traveler is only a first-paint hint: on a remembered device the chip recognizes the traveler itself and calls onRecognized({ code, codeVerifier }). POST both to a second endpoint of ours that exchanges the code at {base}/v1/oauth/token with code_verifier and client_secret_basic and NO redirect_uri, then links or refreshes our session. Recognition alone must never sign the traveler in to our account or unlock saved payments. In onForgotten, clear OUR stored Movmo link for this customer (same call as onSignedOut) and drop our traveler hint, so the greeting does not come back from our own session. onSignedOut fires once, in-page, when the traveler taps "Not you?" (the chip deletes this device's recognition record itself): in it, clear OUR session, greeting, pre-fill and guid cookie. It does not revoke consent and we must not call any Movmo endpoint for it. Return a promise that resolves once the session is cleared. The chip needs no other state wiring. There are no tokens, iframes or message listeners anywhere in this integration.- Callback page at the registered redirect URI. Read the query string and check `error` FIRST: any `error` (access_denied means the traveler declined or cancelled) is a neutral "not connected" state with a way back to the landing page — never call completeMovmoOAuth then, because a denial carries no code. Otherwise call completeMovmoOAuth(code, state) (React) or MovmoConnect.completeOAuth(code, state) (script tag) with `code ?? ""` and `state ?? ""`, and POST the returned code and codeVerifier to our backend as code and code_verifier. A malformed callback with no code throws an error whose code is "missing_code". On MissingVerifierError, StateMismatchError or missing_code, show a visible retry state telling the traveler to tap the Movmo chip again — never redirect as if it had succeeded. Treat only a 2xx from our link-complete as success, then redirect to the landing page.- Backend, two endpoints of ours to create (paths are ours; the docs use /api/movmo/… throughout): 1. POST /api/movmo/link-complete — accept only our own page's same-origin JSON POST. Exchange the authorization code (PKCE) at POST {base}/v1/oauth/token with client_secret_basic, sending the redirect_uri from OUR allowlist of registered origins, chosen by the request's Origin header (never one supplied by the browser); store the movmo_guid from the response against the traveler. If we keep customer accounts, store it on the account; if we don't, a server-sealed HttpOnly cookie is fine — encrypted and authenticated (e.g. Fernet/iron-session), key from our secret store, or derived from client_secret via HMAC if we have none (possession of the cookie is the gate). 2. On page load for a traveler with a stored movmo_guid — POST {base}/v1/identity/resolve with {"guid": ...} (client_secret_basic); use the returned profile/preferences claims to greet and pre-fill, and pass the claims as `traveler` to the chip.- THE one error rule: a 404 from resolve or loyalty-link means the stored guid is dead (the traveler revoked it, or it is not ours) → delete it, never retry that guid, and stop passing `traveler`. The traveler's next click mints a fresh guid, after one consent screen if they had revoked.- Scope for this session: the chip, callback and the two endpoints unless I say otherwise; loyalty linking is a separate step.- Security requirements (non-negotiable, from the docs): client_secret only on the backend, never in browser code; treat resolve claims as display data, not authentication for account changes; log no claim values, codes or guids.- Optional (loyalty): after WE verify a member owns their loyalty account through OUR OWN channel, call POST {base}/v1/identity/loyalty-link with {guid, program_code, loyalty_account_ref, member_number, airline_name, verification_method}; DELETE the same path to unlink. Movmo never verifies loyalty ownership — we do.
Work incrementally: the chip first, then the callback + token exchange, thenresolve on page load, then (if requested) loyalty linking. After each piece,deploy it (or run it locally where the docs say that works) and verify itagainst e2e before moving on: the chip renders and redirects to Movmo, thecallback returns 200 from our backend, a resolve returns claims.Fill in the three placeholders; the scope line already limits the session to the chip and callback — change it when you want loyalty linking. Everything the prompt asserts is specified on this page — treat the docs as the authority if the assistant improvises.
Where it can run
Section titled “Where it can run”Movmo trusts only the origins you register. Nothing to configure separately: the trust list is derived from your registration.
- Registered origins: CORS and the return URL. The origins of your registered redirect URIs (plus your airline domain and its
www.variant) can call Movmo’s API from the browser — the Express Checkout button’s offer save is one such call; see the Express Checkout guide — and are the only origins Movmo returns an Express Checkout traveler to. Matched exactly as the browser spells an origin (lowercase host, no path,:443implied). The one-click chip itself makes no cross-origin call: it redirects the whole tab. Register every host you will use — production and staging. Adding one later is a message to Movmo; it takes effect within a few minutes, with no deploy on either side. - A connect flow must return to the origin it started on. Register one
redirect_uriper origin your site is served on —www/ apex / staging aliases — build it at runtime fromwindow.location.originplus your callback path, and have your backend send the token exchange the same value, chosen from its own allowlist keyed by the request’s origin, never taken from the browser. PKCE state is per-origin storage; a callback on a different origin cannot complete the flow. - “Keyed by the request’s origin” means the
Originheader of the callback page’s same-origin POST to yourlink-completeendpoint — notHost, which a CDN or gateway can rewrite. If your backend sits behind a CDN or API gateway, confirm it forwardsOrigin(and cookies) to your origin server; CloudFront only does this with the right origin-request policy attached. - Redirect URIs must be
https://on a domain you control durably —localhostis not accepted for confidential clients, and neither are shared tunnel or hosting subdomains (ngrok,*.pages.dev, …): a subdomain you later release would keep your registered-origin trust (see Authentication). For local development, build the chip onhttp://localhost:3000and run the connect flow on your staging origin. - Stacks without React. Any stack can load the script-tag build and call
MovmoConnect.mountButton(...)in the layout andMovmoConnect.completeOAuth(...)on the callback page — no React, no bundler; see No React? One script tag. In Next.js, both pieces are client components (or use the script tag); a route-handler / SSR callback needs a customstorageadapter — see thestoragerow. The backend half is plain HTTP in any language. - Seeding a test traveler. A Movmo test account can grant your airline access from its own Connected apps page (
accounts.e2e.movmo.io/connected-apps) once your client is registered. From then on the chip returns that traveler to your callback with no Movmo screen at all, the fastest way to see the returning-traveler path on day one.
Frontend: one chip, a callback and sign-out, ~80 lines
Section titled “Frontend: one chip, a callback and sign-out, ~80 lines”1. Add the chip once, at landing
Section titled “1. Add the chip once, at landing”npm install @movmo/connect-buttonUse 3.0.0 or later (release notes): one chip that switches itself between “Checkout faster with Movmo” and the recognized state, and recognizes a remembered device on its own. Coming from 2.x or 1.x? See Migrating from 2.x and Migrating from 1.x.
import { ConnectMovmoButton } from "@movmo/connect-button";import "@movmo/connect-button/dist/index.css";
<ConnectMovmoButton clientId="your-airline-client-id" redirectUri="https://your-airline.com/movmo/callback" scope={["profile.read", "preferences.read"]} traveler={session.movmo} // from YOUR session; null or undefined when nobody is linked onSignedOut={forgetMovmo} // clear YOUR session; return the promise onRecognized={linkOnBackend} // remembered device: POST { code, codeVerifier } to your backend onForgotten={forgetMovmo} // "Forget this device": clear YOUR stored link and traveler hint // isProd // add at go-live onError={(err) => console.error("Movmo connect did not start:", err)}/>;The chip reads “Checkout faster with Movmo”. Mount it once on your landing page, never next to the Express Checkout button, and keep it mounted: it shows the recognized state when Movmo recognizes the device (see Remembered devices) or, for the first frame, when you pass traveler. The SDK renders it into its own fixed slot (bottom left by default), so there is no wrapper, target or CSS to write. A click starts the redirect (PKCE state is stored first) and the chip reads “Opening Movmo…” until the page leaves. The headless useMovmoOAuth(options) hook and the framework-free startMovmoOAuth(options) run the same flow from your own button.
Props reference
Section titled “Props reference”| Prop | Type | Default | Description |
|---|---|---|---|
clientId | string | required | Your OAuth client_id, issued by Movmo. |
redirectUri | string | required | Must exactly match a redirect_uri registered for your client_id. |
scope | string[] | all scopes registered for your client | Requested scopes. Pass them explicitly: a request that omits scope never re-asks a permission the traveler turned off. The traveler may grant a subset. |
isProd | boolean | false | auth.movmo.io when true, auth.e2e.movmo.io otherwise. |
label | string | "Checkout faster with" | Chip text; the Movmo badge follows it, so the default reads “Checkout faster with Movmo”. |
variant | 'primary' | 'secondary' | 'ghost' | 'primary' | Visual style. |
chipPosition | 'bottom-left' | 'bottom-right' | 'inline' | 'bottom-left' | Fixed positions render into a package-owned slot on document.body (position: fixed, 40px from the side, 28px from the bottom, z-index: 9999). 'inline' renders in place (React) or inside the target (script tag), for a page with its own slot. |
className | string | — | Extra classes on the <button> (anonymous) or the wrapper <div> (recognized). |
disabled | boolean | false | Disable independently of the loading state. |
ariaLabel | string | "<label> Movmo" | Accessible name override. |
onError | (err: OAuthError) => void | — | Called with a typed error when the start fails (for example StorageUnavailableError); nothing is redirected in that case. |
traveler | { firstName?: string } | null | — | Optional first-paint hint from YOUR session. Set shows the recognized chip (“Movmo · Welcome back · Not you? · Manage”) until Movmo’s own recognition result arrives and wins; null or undefined shows the anonymous chip. |
recognition | 'auto' | 'off' | 'auto' | 'auto' recognizes a remembered device on every load and asks the traveler to opt in at sign-in. 'off' makes no recognition call and no remember-me request (1.3.0 behavior). |
onRecognized | (info) => void | Promise<void> | — | { code, codeVerifier, firstName?, isAutoSelected, mechanism }; since 3.0 mechanism is always 'rt' and isAutoSelected always true. Exchange it on your backend (Remembered devices). Never fires for the traveler hint. |
onForgotten | () => void | — | Called after the traveler chose “Forget this device”. The Movmo session stays signed in, so onSignedOut does not fire. Clear your own stored Movmo link for this customer and your traveler hint here, the same way your onSignedOut does: Forget is device-scoped on Movmo’s side, and a greeting that comes back from your own session looks like Forget did nothing. |
onSignedOut | () => void | Promise<void> | — | Optional. Called once, in-page, when the traveler taps “Not you?”; clear your own session and return its promise. |
manageUrl | string | Connected apps for your clientId | Overrides the “Manage” target. |
postLogoutRedirectUri | string | current URL without movmo_* params | Deprecated. Used only by 2.x pages; 3.0 chips never navigate on “Not you?”. |
collapseAfterMs | number | 7000 | The recognized chip collapses to check + Movmo after this long (paused on hover or focus) and re-expands on click. |
storage | StorageAdapter | built-in sessionStorage + localStorage mirror (10-min TTL) | Where the PKCE code_verifier/state live between the redirect legs. Supply { getItem, setItem, removeItem } (sync or async) for SSR or a route-handler callback — e.g. a server-sealed cookie. completeMovmoOAuth/MovmoConnect.completeOAuth accept the same adapter in their options. If both built-in storages are unwritable the start reports StorageUnavailableError through onError and does not redirect. |
authorizeUrl | string | derived from isProd | Base URL of Movmo’s authorization UI; set it only for local testing. |
apiBaseUrl | string | derived from isProd | Movmo API origin; set it only for local testing. |
generateState, navigate | functions | — | Test hooks: a custom state generator and redirect function. |
The script-tag MovmoConnect.mountButton(options) takes the same option names. The headless useMovmoOAuth / startMovmoOAuth default recognition to 'off'; pass 'auto' to ask for remember-me from your own button.
What your users see
Section titled “What your users see”- A traveler with a Movmo session and a standing consent for your airline lands back on your callback with no Movmo screen: the chip click is the whole interaction.
- A traveler who isn’t signed in to Movmo signs in with an email code or a passkey on Movmo’s page, then approves one named, revocable consent for your airline.
- A traveler who denies, or cancels the sign-in, comes back to your callback with
?error=access_deniedand no code. Show a neutral “not connected, you can keep booking” state with the chip still available. - A remembered device (the traveler ticked “Keep me recognized on {Airline} on this device”) shows “Welcome back, {firstName}” on load with no click; see Remembered devices.
- “Not you?” is in-page and device-scoped: the chip forgets this device, calls your
onSignedOutand goes anonymous without leaving your page. The Movmo session stays signed in, so the traveler’s next click asks “Continue as {email}?” or “Use another account”. Clearing your own session is yours, and nothing here revokes consent. A traveler revokes your airline from Movmo’s Connected apps page, and the next resolve returns the404you already handle.
No React? One script tag
Section titled “No React? One script tag”The package ships a self-contained script-tag build (no React, framework-free, styled inline — no CSS step). Add one script tag and mount the chip anywhere on the landing page; there is no element to add:
<script src="https://cdn.e2e.movmo.io/sdk/connect/v2/connect.js"></script><script> var chip = MovmoConnect.mountButton({ clientId: "your-airline-client-id", redirectUri: window.location.origin + "/movmo/callback", scope: ["profile.read", "preferences.read"], traveler: window.__session && window.__session.movmo, // null or undefined when nobody is linked onSignedOut: forgetMovmo, // clear YOUR session; return the promise onRecognized: linkOnBackend, // remembered device: POST { code, codeVerifier } to your backend onForgotten: forgetMovmo, // "Forget this device": clear YOUR stored link and traveler hint onError: function (err) { console.error("Movmo connect did not start:", err); }, }); // chip.unmount();</script>mountButton(options) needs no target. mountButton(target, options) is still accepted: for the fixed positions the target (a selector or an Element) is only the dedupe key, and chipPosition: "inline" requires it and appends the chip there. It throws if a given selector matches nothing or clientId/redirectUri is missing, and returns { unmount(), update({ traveler }) }; call chip.update({ traveler: null }) or chip.update({ traveler: { firstName: "Ana" } }) when your session changes, without re-mounting. One mount per target, or per position without one: a second call warns and returns the live handle. Build redirectUri from window.location.origin plus your callback path at runtime — never a hard-coded value (see Where it can run).
window.MovmoConnect is { mountButton, consumeSignOut, startOAuth, completeOAuth, MovmoOAuthError, StateMismatchError, MissingVerifierError, CryptoUnavailableError, StorageUnavailableError, version }. On your callback page, MovmoConnect.completeOAuth(code, state) is completeMovmoOAuth as a plain function; see the callback page below for the full script-tag version.
Version pinning and SRI
Section titled “Version pinning and SRI”The v2 URL above is a mutable alias that tracks the latest 2.x release (v1 keeps tracking 1.x) — convenient for e2e and demo pages, with no SRI. For production, pin an exact version and add Subresource Integrity so a CDN compromise can’t alter the script; the published .sri sidecar (https://cdn.e2e.movmo.io/sdk/connect/{version}/connect.js.sri) holds the exact value:
<script src="https://cdn.e2e.movmo.io/sdk/connect/2.0.0/connect.js" integrity="sha384-Swot49okmplDwmZjps+AxjbbyNhAyuOlhl6rt6ymWyCnRtRtR+fIVGDPGuVPV0o7" crossorigin="anonymous"></script>An SRI-pinned tag does not auto-update — you take new releases by updating the URL and hash together.
If your page sets a Content-Security-Policy, script-src must allow https://cdn.e2e.movmo.io (the movmo.io equivalent in production). The chip needs no frame-src and no connect-src entry: it makes no request, only a top-level redirect.
2. Add one thin OAuth callback page
Section titled “2. Add one thin OAuth callback page”Mounted at the exact path you registered as your redirect_uri. @movmo/connect-button does the state/PKCE validation for you — your page checks error first, forwards the result to your own backend, and returns the traveler to your landing page.
// e.g. src/pages/MovmoCallback.tsximport { useEffect, useRef, useState } from "react";import { completeMovmoOAuth, MissingVerifierError, MovmoOAuthError, StateMismatchError,} from "@movmo/connect-button";
type Status = "connecting" | "declined" | "retry" | "error";
export default function MovmoCallback() { const [status, setStatus] = useState<Status>("connecting"); const didRun = useRef(false); // the code is single-use: guard a double-invoke in dev/StrictMode
useEffect(() => { if (didRun.current) return; didRun.current = true;
const params = new URLSearchParams(window.location.search); // Check `error` FIRST. A denial (?error=access_denied) carries no code, so it // must never reach completeMovmoOAuth. if (params.get("error")) { setStatus("declined"); return; }
completeMovmoOAuth(params.get("code") ?? "", params.get("state") ?? "") .then(({ code, codeVerifier }) => // your own backend endpoint — it exchanges these at Movmo's // /v1/oauth/token with your client_secret (Flow A below). The // backend sends YOUR registered redirect_uri; never trust one // supplied by the browser. fetch("/api/movmo/link-complete", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ code, code_verifier: codeVerifier }), }), ) .then((response) => { // success = your backend's link-complete returned 2xx — never // redirect on a failure response as if it had succeeded if (!response.ok) throw new Error(`link-complete ${response.status}`); window.location.replace("/"); }) .catch((err) => { if ( err instanceof MissingVerifierError || err instanceof StateMismatchError || (err instanceof MovmoOAuthError && err.code === "missing_code") ) { setStatus("retry"); // lost or replayed PKCE state, or a callback with no code return; } setStatus("error"); }); }, []);
if (status === "declined") return <p>No problem, nothing was shared. <a href="/">Back to the home page</a></p>; if (status === "retry") return <p>We couldn't finish connecting. Tap the Movmo chip on the home page to try again. <a href="/">Back</a></p>; if (status === "error") return <p>We couldn't connect your Movmo account. <a href="/">Try again</a></p>; return <p>Connecting your Movmo account…</p>;}Import the SDK eagerly on this page (not in a lazy chunk, not after an await), and load it before any third-party or session-replay tag: it removes the #movmo_rt= fragment from the URL when it loads, and completeMovmoOAuth stores the remember-me handle only if it ran on that same load.
The script-tag version (a server-rendered page, no bundler) is the same logic. completeOAuth throws MovmoOAuthError with code === "missing_code" when code is empty:
<script src="https://cdn.e2e.movmo.io/sdk/connect/v2/connect.js"></script><script> var params = new URLSearchParams(window.location.search);
function show(message) { document.body.innerHTML = '<p>' + message + ' <a href="/">Back to the home page</a></p>'; }
if (params.get("error")) { show("No problem, nothing was shared."); // declined: no code, never call completeOAuth } else { MovmoConnect.completeOAuth(params.get("code") || "", params.get("state") || "") .then(function (result) { // your own backend endpoint — same contract as the React callback above return fetch("/api/movmo/link-complete", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ code: result.code, code_verifier: result.codeVerifier }), }); }) .then(function (response) { if (!response.ok) throw new Error("link-complete " + response.status); window.location.replace("/"); }) .catch(function (err) { var lost = err instanceof MovmoConnect.MissingVerifierError || err instanceof MovmoConnect.StateMismatchError || (err instanceof MovmoConnect.MovmoOAuthError && err.code === "missing_code"); show(lost ? "We couldn't finish connecting. Tap the Movmo chip on the home page to try again." : "We couldn't connect your Movmo account."); }); }</script>Your server must return this page at the exact path you registered as your client_id’s redirect_uri. To send the traveler back to a page other than the landing page, save its path and call the headless startMovmoOAuth from your own button instead of the chip.
Phones and the one-time code
Section titled “Phones and the one-time code”Between the redirect out to Movmo and the redirect back, a mobile browser can evict the tab holding PKCE state — most often while the traveler switches to their mail app to read a one-time code, but also if the callback opens in a different tab or browser, or a stale callback link is reused. completeMovmoOAuth then throws MissingVerifierError or StateMismatchError.
The SDK mirrors PKCE state to localStorage with a 10-minute TTL alongside sessionStorage, so a lost tab self-heals with nothing for you to do. A custom storage adapter is available if you need one (SSR, strict storage partitioning). Either way, the visible retry state above — “tap the chip to try again,” never a silent redirect — is the contract; don’t rely on the mirror alone to make failures invisible.
MissingVerifierError and StateMismatchError have a second common cause, unrelated to eviction: the flow started on another origin of your site than the one this callback loaded on (see Where it can run for the per-origin redirect_uri rule). A mismatch here fails every time on that origin, not just occasionally.
Greeting missing after connecting? Check the callback first: a visible retry message means one of the typed errors above. If the page redirected home with no error, confirm your link-complete endpoint was actually called and returned 2xx — a silent redirect on a non-2xx response looks identical to success from the browser. If it works the second time but not the first, or on only one hostname, the flow started on an origin whose redirect_uri isn’t the one your backend’s allowlist returns for it.
That’s the entire frontend: the chip starts the flow, and the callback page forwards {code, code_verifier} to your backend’s token exchange. After that your backend resolves the stored GUID and renders the claim set in your UI however you like.
3. Handle “Not you?” and sign-out
Section titled “3. Handle “Not you?” and sign-out”The chip from step 1 is already the recognized chip once you pass traveler; there is no second component and no conditional render. MovmoRecognizedChip and MovmoConnect.mountRecognized (1.1.0) still work but are deprecated aliases; replace them with ConnectMovmoButton / mountButton.
travelercomes from your own session. Movmo’s session is invisible to your page by design, so pass the traveler you linked (stored GUID resolved to claims,firstNameonly); passnullor nothing when nobody is linked.onSignedOutis optional and clears your session. Return a promise: clear the linked traveler, greeting, pre-fill and GUID cookie. The chip stays anonymous after “Not you?” until that promise settles and a render orupdatepasses notraveler; it fires once per tap, even with several chips on the page.
| On “Not you?” the chip | Detail |
|---|---|
| Deletes this device’s recognition record at Movmo | Cookie-less forget call with the handle and a DPoP proof. |
| Removes the handle and key from your origin | Both are gone at once. |
Calls navigator.credentials.preventSilentAccess() | For your whole origin. |
Calls onSignedOut once, in-page, and goes anonymous | No navigation, no movmo_signed_out return. |
The Movmo session is not ended and there is no endpoint for you to call. The SDK stores a one-hour marker and the next chip click adds prompt=select_account, so Movmo shows “Continue as {email}? / Use another account” instead of auto-proceeding (the remember prompt follows Continue when asked).
On a shared device the Movmo session stays signed in until the traveler signs out at accounts.movmo.io; the chooser is the mitigation. Sign-out is not consent revocation: the GUID mapping stays, and revoking is Connected apps (“Manage” opens it). 2.x pages may still receive movmo_signed_out and use consumeMovmoSignOut / MovmoConnect.consumeSignOut; 3.x pages need neither.
4. Remembered devices (zero-click)
Section titled “4. Remembered devices (zero-click)”The traveler opts in once per device on Movmo’s screen: “Keep me recognized on {Airline} on this device”, unticked by default. Unticked behaves as before (a click per visit).
| Path | Browsers | How |
|---|---|---|
| Recognition token | All, Safari and iOS included | The SDK holds an opaque handle plus a non-extractable key and proves possession on every load; no cookies. |
The SDK never calls navigator.credentials.get: one consent model on every browser. The remember box is never pre-ticked; if the traveler chose it for your airline in another browser, a hint line says so. The key is deleted when a sign-in ends without a handle (declined, or an ?error= redirect). rt_request and dpop_jwk travel in the authorize URL fragment, never the query.
On a hit the chip shows “Welcome back, {firstName}” and calls onRecognized once per recognition. Your backend exchanges the code like Flow A, without redirect_uri; the code is single use and expires in about a minute.
<ConnectMovmoButton clientId="your-airline-client-id" redirectUri="https://your-airline.com/movmo/callback" onRecognized={({ code, codeVerifier }) => fetch("/api/movmo/link-complete", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ code, codeVerifier }), }) } onForgotten={forgetMovmo} // clear YOUR stored link and hint, or the greeting returns from your session/>POST https://e2e.api.movmo.io/v1/oauth/tokenAuthorization: Basic base64(client_id:client_secret)Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&code=…&code_verifier=…Recognition alone must never sign the traveler in to your account, unlock saved payments or open a booking (PNR). It proves a returning device to Movmo: greeting, pre-fill and account linking only; keep your own login for anything sensitive. (Movmo rule M10.)
The recognized chip carries “Not you?” and “Manage”, plus “Forget this device” while the device holds a handle (“Not you?” additionally clears your session through onSignedOut). “Forget this device” deletes the record, handle and key, calls navigator.credentials.preventSilentAccess() and onForgotten; the Movmo session stays signed in and your consent stays granted. It is device-scoped: clear your own stored link in onForgotten (see the table above), otherwise your session greets the traveler again on the next visit.
“Not you?” and “Forget this device” switch off silent sign-in on your origin. Both call navigator.credentials.preventSilentAccess(), which the browser applies to your whole origin: if your site uses the browser Credential Management API for its own password auto sign-in, that auto sign-in stays off until the traveler signs in to you again.
A silently minted code carries only the scopes the traveler already granted. If you later start requesting a new supported scope (for example loyalty.read), zero-click recognition keeps working with the narrower grant and nothing new is granted silently. Compare the scope in the token response with what you need and send the traveler through the redirect flow (a chip click) to decide the new scope.
The traveler’s first name is visible to scripts on your page. The recognized chip renders given_name (“Welcome back, {firstName}”) in your DOM, so your own third-party tags (analytics, session replay, chat) can read it. Treat the chip’s slot as personal data in your tag governance, or exclude it: the slot is the SDK-owned fixed element div[data-movmo-connect-slot] (the chip sits in a div[data-movmo-chip] inside it). With chipPosition: "inline" the chip renders in your own container instead, so the same attribute selectors apply there.
What the SDK stores on your origin, for your own storage notice (ePrivacy: name the items, the purpose — recognizing a returning Movmo traveler on your site — and the 30-day lifetime; Movmo’s privacy policy describes its side under “Remembered devices”):
| Where | What | Lifetime |
|---|---|---|
localStorage['movmo:rt:<clientId>'] | Opaque recognition handle | 30 days, fixed |
IndexedDB movmo-connect / keys / rt-key:<clientId> | Non-extractable ECDSA P-256 key pair | Until the handle is deleted; removed at once if you decline “Keep me recognized” |
sessionStorage['movmo:connect:just-linked:<clientId>'] | Skips the silent call right after a link | 60 seconds |
sessionStorage['movmo:connect:not-you:<clientId>'] | Makes the next sign-in ask “Continue as…“ | 1 hour |
sessionStorage + localStorage mirror: movmo:oauth:code_verifier, movmo:oauth:state, movmo:oauth:client_id | Pending sign-in only | 10 minutes (mirror), removed on completion |
“Manage” on the recognized chip deep-links the traveler to Movmo’s Connected apps with that airline’s recognition drawer open (…/connected-apps?client_id=<yours>&manage=recognition), where they can switch recognition off per airline or remove one device; a partner manageUrl replaces it.
- Recognition runs on every page load and every remount of the chip; Movmo currently allows 10 recognitions per token per minute (a provisional limit that may change), so a layout that remounts the chip in a loop is rate-limited and the chip stays anonymous for that load.
- Safari clears script-written storage after 7 days without a visit to your site; the traveler is then anonymous once and is asked again at the next sign-in.
recognition: "off"skips all of this, including the remember-me request.
Migrating from 2.x
Section titled “Migrating from 2.x”3.0.0 changes behavior, hence the major bump.
| Change | Action |
|---|---|
| ”Not you?” is in-page | No redirect and no movmo_signed_out; onSignedOut fires in-page. Drop the SSR movmo_signed_out check and any consumeMovmoSignOut call. |
| Movmo session stays signed in | The next click shows “Continue as {email}? / Use another account”; no code change. |
postLogoutRedirectUri | Deprecated, ignored by 3.0 chips; remove it. |
| FedCM client path removed | Delete fedcmConfigUrl; mechanism is always 'rt' and isAutoSelected always true. |
| Key and fragment | Key deleted when sign-in ends without a handle; rt_request/dpop_jwk move to the URL fragment. No code change. |
| Script tag | Switch connect/v2/connect.js to connect/v3/connect.js, or pin connect/3.0.0/connect.js with its new .sri hash. |
Migrating from 1.x
Section titled “Migrating from 1.x”2.0.0 changes behavior, hence the major bump; the props you pass today keep working.
| Change | Action |
|---|---|
traveler is a first-paint hint | Keep passing it; Movmo’s recognition result wins once it arrives. |
New onRecognized | Add the exchange endpoint (Remembered devices); the code is redeemed without redirect_uri. |
| Callback page | Import the SDK eagerly (not in a lazy chunk, not after an await). |
| Sign-out return | The chip always claims it. If you call consumeMovmoSignOut yourself, move the handler to the chip’s onSignedOut or call consume before mounting. |
Headless useMovmoOAuth / startMovmoOAuth | Request remember-me only with recognition: "auto". |
MovmoRecognizedChip, mountRecognized | Still deprecated; they force recognition: "off". |
| Script tag | Switch connect/v1/connect.js to connect/v2/connect.js, or pin connect/2.0.0/connect.js with its new .sri hash. |
| Opt out | recognition: "off" restores 1.3.0 behavior. |
Backend: the flows
Section titled “Backend: the flows”One code path serves two situations. Flow A is a traveler’s first connection and Flow C a returning traveler’s one click; both run the same chip, callback and token exchange, and differ only in whether Movmo shows a screen. Flow B is optional: resolving a GUID you stored.
Flow A — first connection
Section titled “Flow A — first connection”Standard OAuth 2.0 authorization-code + PKCE, once per traveler. The traveler clicks the chip, signs in to Movmo if needed, and approves a named, revocable consent on Movmo’s screen — what is shared, with which airline, and why. Movmo redirects to your registered redirect_uri; your callback page forwards the result; your backend exchanges it:
POST https://e2e.api.movmo.io/v1/oauth/tokenAuthorization: Basic base64(client_id:client_secret)Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&code=…&redirect_uri=…&code_verifier=…redirect_uri is the registered value your backend knows — send that, not the browser’s idea of it. The Basic credential is base64(client_id + ":" + client_secret) as issued; your client_id and secret contain no characters that need the RFC 6749 §2.3.1 form-encoding step, so encoding them is a harmless no-op.
The response includes a movmo_guid extension member alongside the standard token fields:
{ "access_token": "…", "token_type": "Bearer", "expires_in": 900, "scope": "profile.read preferences.read", "movmo_guid": "20e45f61-…"}The scope field is a single space-delimited string of the granted scopes (RFC 6749 §5.1) — not an array. Parse it with a split on spaces, and read it rather than assuming you received everything you requested: a traveler can untick individual permissions on the consent screen, and any scope outside the set registered for your client_id (by default profile.read and preferences.read, plus whatever Movmo enabled for you at onboarding, such as loyalty.read) is dropped before the consent screen is shown. The field reflects what was actually granted.
Store movmo_guid against the customer record if you run accounts, or in a server-sealed cookie if you don’t (Flow B): it is the only persisted state in the whole integration, and it is how your pages greet the traveler without another redirect. If you store nothing, the traveler simply clicks the chip again on the next visit (Flow C).
Flow B — returning customer, signed in to your site (optional)
Section titled “Flow B — returning customer, signed in to your site (optional)”Applies if you stored a GUID from Flow A or C. With customer accounts, the GUID lives on the account and your normal customer authentication gates the resolve. Without accounts, a server-sealed, HttpOnly, Secure cookie holding the GUID is an acceptable store — possession of that server-issued cookie is the gate, and the browser never reads the GUID. “Server-sealed” means encrypted and authenticated (Fernet, AES-GCM, iron-session), with the key held in your secret store, never derived from anything the browser sends. No key store? Deriving the key from client_secret (HMAC) is acceptable; rotating the secret then ends every stored session, and each traveler clicks the chip again.
from cryptography.fernet import Fernet # key from your secret storef = Fernet(SEALED_COOKIE_KEY)sealed = f.encrypt(guid.encode()) # set as the cookie valueguid = f.decrypt(sealed_cookie_value).decode() # on readconst { sealData, unsealData } = require("iron-session"); // key from your secret storeconst sealed = await sealData({ guid }, { password: SEALED_COOKIE_KEY });const { guid } = await unsealData(sealedCookieValue, { password: SEALED_COOKIE_KEY });- With no key store, deriving the key via HMAC from your
client_secretis acceptable — rotating the secret then drops every stored session (each traveler clicks the chip again).
Skipping the stored GUID (relying on Flow C for everyone) is also a complete, valid integration: the traveler clicks the chip, with no screen when a standing consent exists, and your callback exchanges the code again.
After authenticating the customer, look up their stored movmo_guid and resolve it server-to-server:
POST https://e2e.api.movmo.io/v1/identity/resolveAuthorization: Basic base64(client_id:client_secret)Content-Type: application/json
{"guid": "<the stored GUID>"}On 200, you receive the consented claim set (below) and render your own UI, personalized — first_name can be absent (a traveler who never set a name), so fall back to a neutral greeting. On 404 unknown_guid, the traveler has revoked consent or has asked to delete their Movmo account (or the GUID is otherwise no longer valid) — delete your stored GUID and treat them as not-connected. This is how revocation propagates: you honor it by clearing on 404; never retry that GUID. Show the chip again: the traveler’s next click (Flow C) mints a fresh GUID, after one consent screen if they had revoked.
Because this endpoint returns a specific customer’s personal data, gate it behind your normal customer authentication, like any endpoint that returns that customer’s data.
Flow C — returning traveler, one click
Section titled “Flow C — returning traveler, one click”A traveler who already has a Movmo session and a standing consent for your airline needs no screen. They click the chip, auth-ui checks the session and consent, and redirects straight back to your redirect_uri with a code:
- The chip click starts the PKCE redirect to Movmo.
- Movmo returns the code with no UI. Your callback page runs
completeMovmoOAuthand posts{code, code_verifier}to your backend. - Your backend exchanges the code (Flow A’s
POST /v1/oauth/token) and receivesmovmo_guid. - Your backend resolves it:
POST https://e2e.api.movmo.io/v1/identity/resolveAuthorization: Basic base64(client_id:client_secret)Content-Type: application/json
{"guid": "<the movmo_guid from the exchange>"}If the traveler has no Movmo session, or a requested permission has no decision yet, Movmo shows sign-in or the consent screen first. Deny, or cancelling the sign-in, returns ?error=access_denied to your callback. Read the scope field of the token response for what was granted.
Nothing is asked of a traveler without a click, and your page cannot tell whether a visitor has a Movmo account. After the exchange, your own session carries the GUID (Flow B) and the chip is no longer shown.
What you get back: the claim set
Section titled “What you get back: the claim set”/v1/identity/resolve returns only what the traveler consented to share with your airline, gated by scope:
{ "guid": "20e45f61-…", "status": "active", "granted_scopes": ["profile.read", "preferences.read"], "profile": { "first_name": "Alex", "middle_name": "K", "last_name": "Traveler", "suffix": "Jr", "email": "traveler@example.com", "phone_number": "+1…" }, "preferences": { "travel": { "seat_type": "aisle", "seating_zone": "front", "extra_legroom": true, "exit_row": false, "check_bag": true, "meal_types": ["gluten_free"], "dietary_notes": "no shellfish", "special_assistance": ["service_animal"] }, "locale": { "language_code": "en", "region_code": "GB", "currency_code": "USD" } }}| Group | Scope | Status | Contents |
|---|---|---|---|
profile | profile.read | Live | Name, email, phone |
preferences | preferences.read | Live | Travel preferences (seat/bag/meal/assistance) + locale |
documents | passengers.read | Planned | Date of birth, travel documents, known-traveler numbers |
loyalty | loyalty.read | Early access (available) | Membership across your linked programs — see Loyalty account link below |
payment | payments.read | Planned | Vaulted payment token (checkout-tier integrations only) |
The loyalty group shape
Section titled “The loyalty group shape”Present only when loyalty.read is granted and the traveler has at least one linked program with your airline (see Loyalty account link below):
"loyalty": { "programs": [ { "program_code": "skyhigh-rewards", "airline_name": "Example Air", "airline_iata_code": "XA", "member_number": "SH-4471029", "tier_status": "gold", "points": 84200, "verified": true } ]}programs is an array because you may operate more than one loyalty program (regional subsidiaries, codeshare-specific numbers). Every field except verified is exactly what you sent on POST /v1/identity/loyalty-link below — airline_name and airline_iata_code included; Movmo doesn’t derive them from anything else. airline_iata_code, tier_status, and points are omitted if you never supplied them. The array contains only the programs you linked for this traveler — memberships the traveler holds with other airlines are never returned, and unlinking (below) removes the program from your array entirely. verified is therefore always true for a returned program; it stays on the wire so the shape is stable if a non-verified source is ever introduced.
A group is present only if its scope was granted; a nested field is omitted when the traveler hasn’t set it. New groups slot into the same scope-gated contract without changing this shape — adding one is a scope request on your side, not a contract change. The response never carries a Movmo user ID or any cross-airline data.
Treat claims as per-request data, not a syncable copy. Resolve is cheap, and revocation is enforced at resolve time. Cache a response briefly to serve a page view or session (minutes, not days); don’t replicate claims into your customer database as a standing profile.
The carrier silo, guaranteed on the wire
Section titled “The carrier silo, guaranteed on the wire”If you present a GUID that is not yours — another airline’s, an unknown one, or a revoked one — you get a uniform 404 unknown_guid. You cannot distinguish “not mine” from “doesn’t exist,” so no integration can probe another airline’s travelers. Movmo shares only what each traveler consented to share with each specific airline — never one airline’s data with another, and never booking history.
Loyalty account link (early access)
Section titled “Loyalty account link (early access)”This backs the loyalty single login described in the overview. The principle: your loyalty stack stays the authority. Movmo never issues membership, never sets tier or points, and never decides who owns a member number — you verify that through your own channel (a one-time code to the member’s email, an already-authenticated loyalty session, or a fresh enrollment), and Movmo stores only the association plus whatever member data you choose to expose. Movmo never fabricates member data on your behalf.
POST /v1/identity/loyalty-link
Section titled “POST /v1/identity/loyalty-link”Same credentials as resolve — client_secret_basic, server-to-server:
POST https://e2e.api.movmo.io/v1/identity/loyalty-linkAuthorization: Basic base64(client_id:client_secret)Content-Type: application/json
{ "guid": "20e45f61-…", "program_code": "skyhigh-rewards", "loyalty_account_ref": "acct_9f21c7", "member_number": "SH-4471029", "airline_name": "Example Air", "airline_iata_code": "XA", "verification_method": "member_otp", "tier_status": "gold", "points": 84200}On success, 201:
{ "linked": true, "program_code": "skyhigh-rewards"}| Field | Type | Required | Description |
|---|---|---|---|
guid | string | required | The pairwise GUID for this traveler and your airline — the movmo_guid from the token exchange (Flow A or C). |
program_code | string | required | Your identifier for the loyalty program being linked. |
loyalty_account_ref | string | required | Your own reference for this member’s loyalty account. Stored encrypted; it is never returned by resolve or by this endpoint. |
member_number | string | required | The traveler’s member number in your program. |
airline_name | string | required | Your display name, as you want it reflected back through resolve’s loyalty claim. |
airline_iata_code | string | optional | Your IATA code, if you want it reflected back through resolve’s loyalty claim. |
verification_method | member_otp | loyalty_session | enrollment | required | How you established that this traveler owns this membership — see below. |
tier_status | string | optional | Current tier, if you want it reflected back through resolve. |
points | number | optional | Current point/mile balance, if you want it reflected back through resolve. |
verification_method records how you verified ownership, not how Movmo did — Movmo takes your word for it and stores what you send:
member_otp— you sent a one-time code to the email address on file for that member number and the traveler entered it back to you.loyalty_session— the traveler was already signed in to your loyalty program (an authenticated session on your own site) when they initiated the link.enrollment— the membership is brand-new, created as part of this link rather than matched to an existing one.
You supply all member data on this call — member_number, airline_name, airline_iata_code, tier_status, points — the API never fabricates or backfills it. If your loyalty stack’s tier or balance changes later, send an updated POST to refresh what resolve returns; there’s no separate sync endpoint yet (see the overview for what’s still being scoped).
DELETE /v1/identity/loyalty-link
Section titled “DELETE /v1/identity/loyalty-link”DELETE https://e2e.api.movmo.io/v1/identity/loyalty-linkAuthorization: Basic base64(client_id:client_secret)Content-Type: application/json
{ "guid": "20e45f61-…", "program_code": "skyhigh-rewards"}On success, 200:
{ "linked": false, "program_code": "skyhigh-rewards"}You get the same 200 {"linked": false, ...} whether or not a link existed for that GUID and program — safe to call more than once, and safe to call speculatively before a fresh link.
Uniform 404 discipline
Section titled “Uniform 404 discipline”Both endpoints share resolve’s anti-enumeration property (see The carrier silo, guaranteed on the wire above): a guid that’s unknown, revoked, or belongs to another airline is a byte-identical 404 {"error":"unknown_guid"}. You cannot distinguish “never existed” from “not yours” from either endpoint.
Handle it with the same one rule you already implement for resolve: a 404 means the GUID you stored is dead — delete it and never retry that GUID; the traveler’s next chip click (Flow C) hands you a fresh guid from the token exchange. That’s the entire stale-state story for the whole integration; there is no separate error handling to build for loyalty-link. (A traveler who disconnected and reconnected while you held the old GUID is the common way to hit this.)
- Testing this: there is no test GUID that returns a dead
404on demand. Revoke your own airline in Connected apps for a test traveler — that’s the test.
Re-association guarantee
Section titled “Re-association guarantee”The link is keyed to the consenting traveler, not to the GUID you happen to hold. If a traveler disconnects and later reconnects — minting a brand-new pairwise GUID, per Traveler control, built in — your loyalty association survives the swap automatically: store the fresh movmo_guid from the new token exchange, and your next resolve returns verified: true for the same program with no re-linking ceremony on your part. Never key your own durable state on the GUID alone — treat it as the traveler’s current handle, not a permanent one.
Linking after a connect
Section titled “Linking after a connect”The movmo_guid from the token exchange (Flow A or C) is all loyalty-link needs: store it, verify the member through your own channel, then call loyalty-link with it. It always takes a guid.
Consent ordering
Section titled “Consent ordering”Linking never requires the traveler to have granted your client loyalty.read — the two are independent. loyalty-link records an association; loyalty.read is what lets a later resolve hand it back to you as a claim. A traveler can be linked today and disclose nothing until they grant loyalty.read tomorrow, and revoking loyalty.read alone doesn’t drop the association it gates — see the loyalty revocation matrix for what does.
Requesting loyalty.read by name in your OAuth flow always gives the traveler a real choice: Movmo’s consent screen prompts for the missing permission — and if the traveler had previously turned loyalty sharing off, the same screen re-asks with an explicit “you previously turned this off” note, so your linking flow can turn sharing on in one pass. Declining keeps it off, and OAuth completes without the scope — handle absent loyalty claims gracefully (your member is still linked; disclosure is simply off until the traveler changes their mind, here or in their Movmo account). One anti-nagging rule to know: only scopes you request by name can re-ask; a request that omits scope never re-prompts a declined permission.