Skip to content

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.

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.

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 a
returning traveler connect to an airline site in one click and, with explicit
consent, share their profile with it — docs at
https://docs.movmo.io/identity/integration-guide/ (read that page first; also
https://docs.movmo.io/identity/overview/ and
https://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, then
resolve 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 it
against e2e before moving on: the chip renders and redirects to Movmo, the
callback 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.

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, :443 implied). 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_uri per origin your site is served on — www / apex / staging aliases — build it at runtime from window.location.origin plus 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 Origin header of the callback page’s same-origin POST to your link-complete endpoint — not Host, which a CDN or gateway can rewrite. If your backend sits behind a CDN or API gateway, confirm it forwards Origin (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 — localhost is 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 on http://localhost:3000 and 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 and MovmoConnect.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 custom storage adapter — see the storage row. 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”
npm install @movmo/connect-button

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

PropTypeDefaultDescription
clientIdstringrequiredYour OAuth client_id, issued by Movmo.
redirectUristringrequiredMust exactly match a redirect_uri registered for your client_id.
scopestring[]all scopes registered for your clientRequested scopes. Pass them explicitly: a request that omits scope never re-asks a permission the traveler turned off. The traveler may grant a subset.
isProdbooleanfalseauth.movmo.io when true, auth.e2e.movmo.io otherwise.
labelstring"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.
classNamestring—Extra classes on the <button> (anonymous) or the wrapper <div> (recognized).
disabledbooleanfalseDisable independently of the loading state.
ariaLabelstring"<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.
manageUrlstringConnected apps for your clientIdOverrides the “Manage” target.
postLogoutRedirectUristringcurrent URL without movmo_* paramsDeprecated. Used only by 2.x pages; 3.0 chips never navigate on “Not you?”.
collapseAfterMsnumber7000The recognized chip collapses to check + Movmo after this long (paused on hover or focus) and re-expands on click.
storageStorageAdapterbuilt-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.
authorizeUrlstringderived from isProdBase URL of Movmo’s authorization UI; set it only for local testing.
apiBaseUrlstringderived from isProdMovmo API origin; set it only for local testing.
generateState, navigatefunctions—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.

  • 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_denied and 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 onSignedOut and 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 the 404 you already handle.

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.

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.

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.tsx
import { 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.

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.

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.

  • traveler comes 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, firstName only); pass null or nothing when nobody is linked.
  • onSignedOut is 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 or update passes no traveler; it fires once per tap, even with several chips on the page.
On “Not you?” the chipDetail
Deletes this device’s recognition record at MovmoCookie-less forget call with the handle and a DPoP proof.
Removes the handle and key from your originBoth are gone at once.
Calls navigator.credentials.preventSilentAccess()For your whole origin.
Calls onSignedOut once, in-page, and goes anonymousNo 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.

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

PathBrowsersHow
Recognition tokenAll, Safari and iOS includedThe 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/token
Authorization: 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”):

WhereWhatLifetime
localStorage['movmo:rt:<clientId>']Opaque recognition handle30 days, fixed
IndexedDB movmo-connect / keys / rt-key:<clientId>Non-extractable ECDSA P-256 key pairUntil 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 link60 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_idPending sign-in only10 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.

3.0.0 changes behavior, hence the major bump.

ChangeAction
”Not you?” is in-pageNo 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 inThe next click shows “Continue as {email}? / Use another account”; no code change.
postLogoutRedirectUriDeprecated, ignored by 3.0 chips; remove it.
FedCM client path removedDelete fedcmConfigUrl; mechanism is always 'rt' and isAutoSelected always true.
Key and fragmentKey deleted when sign-in ends without a handle; rt_request/dpop_jwk move to the URL fragment. No code change.
Script tagSwitch connect/v2/connect.js to connect/v3/connect.js, or pin connect/3.0.0/connect.js with its new .sri hash.

2.0.0 changes behavior, hence the major bump; the props you pass today keep working.

ChangeAction
traveler is a first-paint hintKeep passing it; Movmo’s recognition result wins once it arrives.
New onRecognizedAdd the exchange endpoint (Remembered devices); the code is redeemed without redirect_uri.
Callback pageImport the SDK eagerly (not in a lazy chunk, not after an await).
Sign-out returnThe chip always claims it. If you call consumeMovmoSignOut yourself, move the handler to the chip’s onSignedOut or call consume before mounting.
Headless useMovmoOAuth / startMovmoOAuthRequest remember-me only with recognition: "auto".
MovmoRecognizedChip, mountRecognizedStill deprecated; they force recognition: "off".
Script tagSwitch connect/v1/connect.js to connect/v2/connect.js, or pin connect/2.0.0/connect.js with its new .sri hash.
Opt outrecognition: "off" restores 1.3.0 behavior.

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.

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/token
Authorization: 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 store
f = Fernet(SEALED_COOKIE_KEY)
sealed = f.encrypt(guid.encode()) # set as the cookie value
guid = f.decrypt(sealed_cookie_value).decode() # on read
const { sealData, unsealData } = require("iron-session"); // key from your secret store
const 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_secret is 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/resolve
Authorization: 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.

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:

  1. The chip click starts the PKCE redirect to Movmo.
  2. Movmo returns the code with no UI. Your callback page runs completeMovmoOAuth and posts {code, code_verifier} to your backend.
  3. Your backend exchanges the code (Flow A’s POST /v1/oauth/token) and receives movmo_guid.
  4. Your backend resolves it:
POST https://e2e.api.movmo.io/v1/identity/resolve
Authorization: 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.

/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"
}
}
}
GroupScopeStatusContents
profileprofile.readLiveName, email, phone
preferencespreferences.readLiveTravel preferences (seat/bag/meal/assistance) + locale
documentspassengers.readPlannedDate of birth, travel documents, known-traveler numbers
loyaltyloyalty.readEarly access (available)Membership across your linked programs — see Loyalty account link below
paymentpayments.readPlannedVaulted payment token (checkout-tier integrations only)

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.

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.

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.

Same credentials as resolve — client_secret_basic, server-to-server:

POST https://e2e.api.movmo.io/v1/identity/loyalty-link
Authorization: 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"
}
FieldTypeRequiredDescription
guidstringrequiredThe pairwise GUID for this traveler and your airline — the movmo_guid from the token exchange (Flow A or C).
program_codestringrequiredYour identifier for the loyalty program being linked.
loyalty_account_refstringrequiredYour own reference for this member’s loyalty account. Stored encrypted; it is never returned by resolve or by this endpoint.
member_numberstringrequiredThe traveler’s member number in your program.
airline_namestringrequiredYour display name, as you want it reflected back through resolve’s loyalty claim.
airline_iata_codestringoptionalYour IATA code, if you want it reflected back through resolve’s loyalty claim.
verification_methodmember_otp | loyalty_session | enrollmentrequiredHow you established that this traveler owns this membership — see below.
tier_statusstringoptionalCurrent tier, if you want it reflected back through resolve.
pointsnumberoptionalCurrent 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 https://e2e.api.movmo.io/v1/identity/loyalty-link
Authorization: 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.

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 404 on demand. Revoke your own airline in Connected apps for a test traveler — that’s the test.

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.

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.

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.

Next: the operations checklist and your compliance duties.