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 component’s isProd flag.

The whole integration is three small pieces: an embedded component in your site layout, one callback page, and two backend calls (token exchange + resolve). Teams working from this page typically have recognition running against e2e in a day, with loyalty linking on day two. 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.

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 Movmo identity recognition into this codebase. Movmo is an identity
layer that recognizes consenting travelers on airline sites — 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).
- 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>.
- Frontend: mount Movmo's recognition component (@movmo/connect-button,
<MovmoRecognition />) once in the site layout, per the docs. Add the thin
OAuth callback page at the redirect URI.
- Backend, three endpoints of ours to create:
1. POST <our>/movmo/link-complete — exchange the authorization code
(PKCE) at POST {base}/v1/oauth/token with client_secret_basic; store
the movmo_guid from the response against the traveler.
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.
3. POST {base}/v1/identity/resolve with {"recognition_token": ...} for
the anonymous-recognition path; the response includes guid — store it.
- THE one error rule: any 404 from resolve or loyalty-link means the stored
guid is dead → delete it, re-recognize, retry once. Never retry a 404
without deleting first.
- 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.
- 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: frontend mount first, then the callback + token
exchange, then resolve on page load, then (if requested) loyalty linking.
After each piece, show me how to verify it against e2e before moving on.

Fill in the three placeholders, and tell the assistant which pieces you want (recognition only is a fine day-one scope; add loyalty linking when you’re ready). Everything the prompt asserts is specified on this page — treat the docs as the authority if the assistant improvises.

1. Mount the recognition component once, in your site layout

Section titled “1. Mount the recognition component once, in your site layout”
npm install @movmo/connect-button

(MovmoRecognition shipped in 0.2.0; use 0.3.0 or later — that’s where the package’s default environment became e2e — or just take the latest (release notes).)

import { MovmoRecognition } from "@movmo/connect-button";
import "@movmo/connect-button/dist/index.css";
<MovmoRecognition
clientId="your-airline-client-id"
redirectUri="https://your-airline.com/movmo/callback"
airlineName="Your Airline"
chipPosition="bottom-left"
transparency="full"
onToken={(token) => forwardTokenToYourBackend(token)}
onState={(state) => {
/* optional: 'recognized' | 'anonymous' | 'none' */
}}
/>;

That’s the whole embed. The iframe it renders owns all recognition UI (the banner/chip lifecycle), the browser storage mechanics, and postMessage origin security. Place the component once in your layout — its wrapper is fixed-position, so it doesn’t matter which page or where in the DOM.

PropTypeDefaultDescription
clientIdstringrequiredYour OAuth client_id, issued by Movmo.
redirectUristringrequiredMust exactly match a redirect_uri registered for your client_id.
scopestring[]['profile.read', 'preferences.read']Scopes requested if the traveler connects from the recognition surface.
isProdbooleanfalseSelects the production vs. e2e Movmo origin the iframe points at.
chipPosition'bottom-left' | 'bottom-right' | 'left' | 'right''bottom-left'Corner/edge the fixed-position wrapper anchors to.
transparency'full' | 'minimal''full'Visibility tier for the recognized state. 'full' keeps a persistent chip after the first-visit banner; 'minimal' shows the first-visit banner only. Never affects the connect chip.
airlineNamestringYour display name, forwarded into the iframe for personalized copy (“Welcome back”, etc.).
onToken(token: string) => voidFires with a short-lived, single-use recognition token whenever the iframe has one. Forward it to your backend — it’s opaque to the browser.
onState(state: 'recognized' | 'anonymous' | 'none') => voidFires whenever the iframe’s recognition state changes. Carries no identity data.
onError(error: MovmoRecognitionError) => voidFires when the recognition surface fails — e.g. the iframe never completes its boot handshake (source: 'frame_timeout': network failure, outage, or a blocked embed). Without it the surface fails silent, so wire this to your monitoring in production.
readyTimeoutMsnumber10000How long to wait for the iframe’s boot handshake before reporting frame_timeout via onError. Pass 0 to disable the timeout.
  • First-ever recognition: a slim banner (”✓ Movmo — Welcome back, {first name}”) that auto-collapses into a small persistent ”✓ Movmo” chip.
  • “Not you?” signs the traveler out of Movmo (not out of your site) and returns the surface to its anonymous state.
  • “Manage” deep-links, in a new tab, to the traveler’s Connected-apps page on Movmo’s own site, scoped to your client_id — travelers review or revoke your access there, not on your page.
  • The anonymous chip (“Checkout faster with Movmo”) is dismissible; the recognized surface deliberately is not — “Not you?” is the only way off it, because a dismiss would visually read as a revoke.

This is the fallback path for browsers that can’t silently recognize a returning traveler, and the entry point for a first-time connection. @movmo/connect-button does the state/PKCE validation for you — your page just forwards the result to your own backend and navigates home:

// e.g. src/pages/MovmoCallback.tsx — mounted at the EXACT path you registered
// as your client_id's redirect_uri.
import { useEffect, useRef, useState } from "react";
import {
completeMovmoOAuth,
MissingVerifierError,
StateMismatchError,
} from "@movmo/connect-button";
export default function MovmoCallback() {
const [error, setError] = useState<string | null>(null);
const didRun = useRef(false); // guard against a double-invoke in dev/StrictMode
useEffect(() => {
if (didRun.current) return;
didRun.current = true;
const params = new URLSearchParams(window.location.search);
const code = params.get("code");
const state = params.get("state");
if (params.get("error") || !code || !state) {
window.location.replace("/"); // declined consent, or a malformed callback
return;
}
completeMovmoOAuth(code, state)
.then(({ code, codeVerifier }) =>
// your own backend endpoint — it exchanges these at Movmo's
// /v1/oauth/token with your client_secret (Flow A below)
fetch("/api/movmo/link/complete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
code,
code_verifier: codeVerifier,
redirect_uri: window.location.origin + "/movmo/callback",
}),
}),
)
.then(() => window.location.replace("/"))
.catch((err) => {
if (
err instanceof StateMismatchError ||
err instanceof MissingVerifierError
) {
window.location.replace("/"); // stale/replayed callback — fail silent, not loud
return;
}
setError("We couldn't connect your Movmo account. Please try again.");
});
}, []);
return <p>{error ?? "Connecting your Movmo account…"}</p>;
}

That’s the entire frontend: the component forwards recognition tokens to your backend (Flow C), and the callback page forwards {code, code_verifier} to your backend’s token exchange (Flow A). Both backend calls return the same claim set — render it in your UI however you like.

Standard OAuth 2.0 authorization-code + PKCE, once per traveler. The traveler clicks the connect surface, 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=…

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 the field reflects what they actually granted.

If you run customer accounts and want the direct returning-customer path (Flow B), store movmo_guid against the customer record — that is the only persisted state in the whole integration, and it is optional. If you don’t keep accounts, store nothing: the same traveler is recognized on later visits through 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 only if you keep customer accounts and stored a GUID in Flow A. It skips the browser round-trip for a customer you’ve already authenticated — skipping this flow entirely (relying on Flow C for everyone) is a complete, valid integration.

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. On 404 unknown_guid, the traveler has revoked consent (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, with no retries and no grace period.

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 — traveler anonymous to you, known to Movmo (the primary case)

Section titled “Flow C — traveler anonymous to you, known to Movmo (the primary case)”

The embedded surface recognizes the traveler’s Movmo session and — only if they hold a standing consent for your airline — hands your page a short-lived, single-use recognition token via onToken. Your page forwards it to your backend, which redeems it on the same resolve endpoint:

POST https://e2e.api.movmo.io/v1/identity/resolve
Authorization: Basic base64(client_id:client_secret)
Content-Type: application/json
{"recognition_token": "<token from the browser surface>"}

guid and recognition_token are mutually exclusive — send exactly one. Both return the identical claim set for the same traveler; the token path just resolves to the same pairwise GUID server-side.

Recognition tokens are short-lived (about two minutes) and strictly single-use — a replay returns a uniform 404 unknown_token. Treat them as opaque, never log them, and never redeem a token you didn’t receive from the Movmo surface for this session.

/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. verified is per-carrier: it reads true only while your active, verified link for that program exists — another airline’s link for the same traveler has no bearing on it, and unlinking (below) removes the program from your array entirely rather than leaving a verified: false entry behind.

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 or token that is not yours — another airline’s, an unknown one, or a revoked one — you get a uniform 404 unknown_guid / unknown_token. 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 — from Flow A’s token response, or from resolving a Flow C recognition token first (below).
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, re-run recognition (Flow C hands you a fresh guid), and retry the call once. 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.)

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 GUID from the new Flow A or Flow C 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.

A traveler arriving through Flow C (anonymous, no stored GUID) can still link loyalty: resolve the recognition token first — the response includes guid — store that GUID, then call loyalty-link with it. There’s no separate token-based path for loyalty-link itself; 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.