Express Checkout integration guide
This page is the build. It assumes you’ve read the overview and hold an API key, your iataCode and a provider id (see the onboarding checklist).
What you build
Section titled “What you build”- The button on the step where a traveler has selected a flight, given the offer you already have.
- A return page that mounts the button (or calls
consumeReturn) on a fresh page load: Movmo sends the traveler back to yourreturnUrl, by default the page they clicked from. - An
onBookingCompletehandler that keys your record on Movmo’sbookingIdand storesairlinePnrwhen present, plus anonExithandler that returns the traveler to the right step. Both are unauthenticated browser signals: confirm the booking server-side before you fulfil anything.
Build it with an AI coding assistant
Section titled “Build it with an AI coding assistant”Paste the prompt below into an AI coding assistant working inside your repo (Claude Code, Codex, Cursor, or similar). It stops where your own PSS record begins.
Add Movmo Express Checkout (SDK 2.x) to this codebase. Movmo is a one-clickcheckout for airline bookings. The traveler clicks our button, the whole tabnavigates to Movmo's checkout page, and Movmo sends the traveler back to ourpage when they finish. There is no overlay, no iframe and no /movmo/checkoutroute. Docs: https://docs.movmo.io/express-checkout/overview/,https://docs.movmo.io/express-checkout/integration-guide/ andhttps://docs.movmo.io/express-checkout/operations-and-compliance/ (read theintegration guide first, especially "Where it can run", "The return" and"The offer object").
Facts you need:- I have a Movmo API key available to the frontend build as <ENV_VAR_NAME> (server-rendered stack: render it into the page from server config; the key is public and ships in the page either way) and my airline IATA code is <IATA_CODE>. Environment: e2e (isProd: false; production is a flag flip later). Our page's origin is registered with Movmo. It has to be: the button's offer save is a cross-origin call from our page, and Movmo only returns the traveler to a registered origin.- Mount the button where a traveler has selected a flight. React apps: npm i @movmo/express-checkout-button@^2.0.0, then <MovmoCheckoutButton /> (import the package stylesheet once at app boot). Every other stack: the script tag https://cdn.e2e.movmo.io/sdk/express-checkout/v2/express-checkout.js and MovmoExpressCheckout.mount(target, options). Production pins an exact version with SRI instead of v2.- Required props/options: iataCode, apiKey, providerID (the provider our selected offer came from: darwin, duffel or dohop, or the sandbox provider id Movmo gave us for e2e if we have no live provider yet), and exactly one of flightOffer (the raw offer object) or flightOfferID (a Movmo offer id our backend saved earlier with createProviderOffer). Optional: isProd, className, returnUrl, onBookingComplete, onExit, onError.- The offer object: for darwin and dohop it is our PSS's own response, passed as-is (Movmo's adapter unwraps it; do not transform it); for duffel it is the Duffel offer id passed as flightOfferID (no save call). For the sandbox provider it is Movmo's normalized Offer shape exactly as "The offer object" documents it: decimal-string amounts, an owner, passengers with an EMPTY id plus a provider_passenger_id, slices with segments whose passengers reference that provider_passenger_id, and every duration written like "1 hr 25 min". If our data layer doesn't already produce that shape, build it there, never inside the button props.- Whenever the offer object is not already in the browser (typical for script-tag and server-rendered stacks), save it server-side with createProviderOffer (the curl under "Pre-saved offer ID") and pass the returned offer id as flightOfferID. Save when the traveler selects the flight, never at search: the 30-minute expiry starts at the save.- Click flow: the button saves the offer (raw-offer mode only) and then navigates the whole tab to Movmo. Our page unloads. Do not call preventDefault, open a popup or wrap the button in a form.- The return: Movmo navigates back to returnUrl (default: the page URL at click) and the SDK fires onBookingComplete, then onExit, on whichever page mounts the button or calls the consume function on that load. The return is a FRESH page load: SPA router state and in-memory state are gone, so the return page must render from the URL, our session or storage. Mount the button on the return page, or call consumeMovmoCheckoutReturn(options) (ESM) / MovmoExpressCheckout.consumeReturn(options) (script tag) as early as possible on it. Hash router: keep returnUrl on the route that mounts or consumes.- Pass onBookingComplete({ bookingId, airlinePnr, airlineRef, providerId }): airlinePnr is the record locator in our own reservation system (may be absent), airlineRef is the iataCode we passed, not the PNR. Key our record on bookingId and store airlinePnr when present, then stop: that is where our PSS record begins; do not build booking reads, changes or refunds against Movmo.- Both callbacks are unauthenticated browser signals: the traveler can edit the return URL. Never fulfil an order, award loyalty or mark anything paid from them; use them to route the traveler, and confirm the booking server-side before acting.- Wire onExit({ reason }) to return the traveler to the right step of our flow ("search_again" / "offer_unavailable" / "booking_confirmed"), and onError to fall back to our own checkout when the offer save fails.- Do not listen for window "message" events, do not add a /movmo/checkout route and do not add a frame-src CSP entry: none of them exist in 2.x. CSP needs only the CDN script-src and connect-src for the offer save.- Do not restyle the button beyond size/width: the "Express Checkout" label and Movmo badge are part of the trust contract.
Work incrementally: mount the button first and show me a click navigates tothe Movmo checkout page on e2e (flights.e2e.movmo.io) - a click that doesnothing means the offer save failed; check the network tab for thecreateProviderOffer POST - then wire the return page, onBookingComplete,onExit and onError.Fill in the two placeholders. The docs are the authority if the assistant improvises.
Where it can run
Section titled “Where it can run”The button makes two cross-origin calls from your page: the offer save (createProviderOffer, on click) and an analytics beacon. Movmo answers them with CORS headers only for origins it knows, and it returns the traveler only to a registered origin. So every origin that hosts the button or your returnUrl must be registered with Movmo, production and staging alike.
- Identity partners: nothing extra to do. The origins of your registered redirect URIs, plus your airline domain and its
www.variant, are registered within minutes, with no deploy on either side. See Where it can run on the identity guide. - Express-Checkout-only partners: tell Movmo your origins at onboarding, and again whenever one changes.
- Local development:
http://localhost:3000works against e2e out of the box.
What an unregistered origin looks like:
- At the click,
onErrorfires: the network tab shows thePOSTto/v1/flights/providers/{provider}/offersfailing with a CORS error, surfaced as{ stage: "save_offer", status: undefined, message: "Failed to fetch" }. The analytics beacon fails the same way, silently. - A
returnUrlon an unregistered origin (or nothttps, with userinfo, or over 2048 characters) is dropped: the traveler is not sent back and uses the browser back button. - To get a failure you can handle in code without relying on CORS, save the offer from your backend and pass
flightOfferID; see Pre-saved offer ID.
Frontend
Section titled “Frontend”Install, version pinning and SRI are on the package reference.
import "@movmo/express-checkout-button/dist/index.css"; // once, at app bootimport { MovmoCheckoutButton } from "@movmo/express-checkout-button";
<MovmoCheckoutButton iataCode="PR" apiKey={process.env.REACT_APP_MOVMO_API_KEY} // Vite: import.meta.env.VITE_MOVMO_API_KEY providerID="darwin" flightOffer={selectedOffer} isProd={false}/>;Script tag (any stack)
Section titled “Script tag (any stack)”<!-- No fixed height: the button renders at its natural size, about 303×68 px. --><div id="movmo-checkout-button"></div>
<script src="https://cdn.e2e.movmo.io/sdk/express-checkout/v2/express-checkout.js"></script><script> MovmoExpressCheckout.mount("#movmo-checkout-button", { iataCode: "PR", apiKey: "YOUR_MOVMO_API_KEY", providerID: "darwin", flightOffer: selectedOffer, });</script>mount()takes the same options as the React props, callbacks included, and also consumes a pending return.- To size it, set a
min-widthof about 300 px (font-dependent) or a width, never a height: a narrower container wraps the label onto two lines.
On click
Section titled “On click”- Saves the offer via
createProviderOffer(POST /v1/flights/providers/{provider}/offers), skipped when you passflightOfferID. On failure it stops and callsonError. - Stores a one-time
statenonce insessionStorageon your origin (keymovmo:checkout:pending). - Navigates the whole tab to
https://flights.movmo.io/booking?offerId=…&providerId=…&airlineRef=…&returnUrl=…&state=…(https://flights.e2e.movmo.iowhenisProdisfalse). Your page unloads.
The return
Section titled “The return”<MovmoCheckoutButton iataCode="PR" apiKey={process.env.REACT_APP_MOVMO_API_KEY} providerID="darwin" flightOffer={selectedOffer} onBookingComplete={({ bookingId, airlinePnr }) => { saveMovmoBooking({ movmoBookingId: bookingId, pnr: airlinePnr ?? null }); }} onExit={({ reason }) => { if (reason === "booking_confirmed") showTripPage(); else returnToSearchResults(); }}/>Movmo navigates the tab to your returnUrl with movmo_exit, movmo_state and, after a booking, movmo_booking_id in the query. The airline PNR travels only in the URL fragment (#movmo_pnr=…, or inside your hash route).
| Step | What the SDK does |
|---|---|
| Verify | Runs on the page that mounts the button, or at consumeReturn. Callbacks fire only when movmo_state matches the nonce stored at click, once, within 24 hours. A mismatch or missing nonce logs a warning and calls nothing. |
| Callbacks | onBookingComplete (confirmed bookings only), then onExit({ reason }), then the nonce is cleared. A throwing callback is caught and logged. |
| Scrub | Every movmo_* param is removed from the address bar with history.replaceState, your own query and hash untouched. Call it before analytics or session-replay scripts read the URL. |
- The callbacks are unauthenticated browser signals. The traveler can edit the return URL, so neither proves a booking: never fulfil, award loyalty or mark anything paid from them; confirm the booking server-side first.
- The return is a fresh page load. SPA router state and in-memory state are gone, so render the return page from the URL, your session or storage.
- Callbacks are plain synchronous calls, and
onExitnever waits for a promise fromonBookingComplete. Before a navigation, show an interim “Finishing your booking…” state, and chain them yourself:
let saved = Promise.resolve();const callbacks = { // spread into mount() options or the React props onBookingComplete: (info) => { showFinishing(); saved = saveMovmoBooking(info); }, onExit: async ({ reason }) => { await saved; if (reason === "booking_confirmed") location.assign("/trip"); },};- Reasons and next steps: After the click. Without
onExit, the traveler lands on the page that loaded atreturnUrl.
returnUrl
Section titled “returnUrl”<MovmoCheckoutButton {...props} returnUrl="https://www.example-air.com/trip/checkout-return" />- Default: the page URL at click with every
movmo_*query param removed and the rest of the query kept. The fragment is kept only for router routes (#/…,#!/…), never anything else. returnUrlis sent to Movmo and appears in its request logs: set an explicit one when your page URL carries tokens or personal data.- It must be
https(http://localhost:3000on e2e), on a registered origin, without userinfo and at most 2048 characters. Otherwise there is no return link.
Pages without the button
Section titled “Pages without the button”The traveler returns to returnUrl, which can be a different page from the one with the button. On that page call the consume function as early as possible:
// React / bundlerimport { consumeMovmoCheckoutReturn } from "@movmo/express-checkout-button";consumeMovmoCheckoutReturn({ isProd: false, onBookingComplete, onExit });
// Script tagMovmoExpressCheckout.consumeReturn({ isProd: false, onBookingComplete, onExit });- A page-level call claims whatever return is pending and takes airline, provider and environment from the record written at click;
isProdis only a fallback. It returnstruewhen callbacks fired. - With two buttons on one page, pass
iataCode,providerIDorofferIdto claim only the return that matches.
onError
Section titled “onError”<MovmoCheckoutButton iataCode="PR" apiKey={process.env.REACT_APP_MOVMO_API_KEY} providerID="darwin" flightOffer={selectedOffer} onError={({ stage, status, message }) => { // stage is "save_offer" (the createProviderOffer call failed) or // "missing_offer" (neither flightOffer nor flightOfferID was passed). showToast(`Checkout is unavailable right now (${message}).`); // or fall back to your own checkout flow entirely. }}/>- The offer save never throws; on failure the button also logs
console.warn('[movmo] express checkout: offer save failed', { status, message }), and the click does not navigate.
onError stages
Section titled “onError stages”| Stage | Cause | Action |
|---|---|---|
missing_offer | Neither flightOffer nor flightOfferID was passed; fires synchronously, no status | Fix the mount. |
save_offer, no status | Network failure, usually an unregistered origin (Where it can run) | Register the origin, or save the offer server-side. |
save_offer, status: 400 | The sandbox offer shape was rejected | Fix the field the message names (the backend’s own error text). |
Routing
Section titled “Routing”- No
/movmo/checkoutroute is needed: Movmo’s checkout lives on its own page. - The return lands on
returnUrlas a fresh load. Make sure that page renders without in-memory state, and keep the traveler’s selection in your server session or storage if the URL cannot rebuild it. - Hash routers: keep
returnUrlon the route that mounts the button or callsconsumeReturn; Movmo addsmovmo_pnrinside your existing hash. - Single-page apps: mount (or consume) on the return route’s first render; the
movmo_*params are inlocation, not in router state.
The offer in your data layer
Section titled “The offer in your data layer”Raw offer (most common)
Section titled “Raw offer (most common)”The partner page already has the user’s selected offer (e.g. from your PSS availability response). Pass the raw object — the component persists it on click:
<MovmoCheckoutButton iataCode="PR" apiKey={process.env.REACT_APP_MOVMO_API_KEY} providerID="darwin" flightOffer={selectedOffer}/>The component sends the object as-is to createProviderOffer; Movmo’s provider adapter does the unwrapping.
Pre-saved offer ID
Section titled “Pre-saved offer ID”The partner already holds a Movmo-known offer id — a Duffel offer id, or the offer_id returned by a server-side createProviderOffer — and only needs to launch checkout:
<MovmoCheckoutButton iataCode="PR" apiKey={process.env.REACT_APP_MOVMO_API_KEY} providerID="darwin" flightOfferID="33333333-3333-3333-3333-333333333333"/>The save is one server-to-server call with the offer object as the body:
curl -X POST "https://e2e.api.movmo.io/v1/flights/providers/darwin/offers" \ -H "x-api-key: $MOVMO_API_KEY" \ -H "movmo-customer-id: PR" \ -H "Content-Type: application/json" \ --data @offer.json# 201 → {"offer_id": "33333333-3333-3333-3333-333333333333"}# 400 → {"error": {"message": "offer is not a valid Movmo offer: slices[0].duration: …", "status_code": 400, "is_retryable": false, …}}- Save server-side whenever the offer object is not already in the browser (typical for script-tag and server-rendered stacks).
- Save at flight selection, never at search: the 30-minute expiry starts at the save (Offer lifecycle).
The offer object
Section titled “The offer object”Hand the button the full offer JSON your PSS returned, or the provider’s offer id where the provider supports retrieval by id. Movmo’s adapter converts it; there is no Movmo-side shape to produce for a real provider.
| Provider | What you pass |
|---|---|
darwin | flightOffer = the availability-response object from /perform-availability, verbatim. |
dohop | flightOffer = the offer object from Dohop’s search response, verbatim. Movmo uses the Dohop offer_id as the Movmo offer id and keeps the offer’s secret on its side. |
duffel | flightOfferID = the Duffel offer id (off_…). No save call; flightOffer is not supported. |
| Sandbox (e2e only) | flightOffer = Movmo’s normalized Offer shape (what getProviderOffer returns, minus Movmo-assigned fields). Runs the full checkout against a Movmo-hosted test airline. |
Real-provider offers are stored as sent. Sandbox offers are validated at save time, per the rules below.
Sandbox provider: a complete example
Section titled “Sandbox provider: a complete example”{ "id": "SA101-2026-11-02-1ADT", "total_amount": "89.00", "total_currency": "USD", "base_amount": "89.00", "base_currency": "USD", "tax_amount": "0.00", "tax_currency": "USD", "owner": { "id": "SA", "name": "Sample Airline", "iata_code": "SA" }, "passengers": [ { "id": "", "provider_passenger_id": "1", "type": "adult" } ], "slices": [ { "id": "sli_SA101", "origin": { "iata_code": "LAX", "name": "Los Angeles International", "city_name": "Los Angeles", "type": "airport", "time_zone": "America/Los_Angeles" }, "destination": { "iata_code": "SFO", "name": "San Francisco International", "city_name": "San Francisco", "type": "airport", "time_zone": "America/Los_Angeles" }, "duration": "1 hr 25 min", "fare_brand_name": "Saver", "segments": [ { "id": "seg_SA101", "origin": { "iata_code": "LAX", "name": "Los Angeles International", "city_name": "Los Angeles", "type": "airport", "time_zone": "America/Los_Angeles" }, "destination": { "iata_code": "SFO", "name": "San Francisco International", "city_name": "San Francisco", "type": "airport", "time_zone": "America/Los_Angeles" }, "departing_at": "2026-11-02T07:15:00", "arriving_at": "2026-11-02T08:40:00", "duration": "1 hr 25 min", "marketing_carrier": { "id": "SA", "name": "Sample Airline", "iata_code": "SA" }, "marketing_carrier_flight_number": "101", "operating_carrier": { "id": "SA", "name": "Sample Airline", "iata_code": "SA" }, "operating_carrier_flight_number": "101", "aircraft": { "id": "320", "name": "Airbus A320", "iata_code": "320" }, "passengers": [{ "passenger_id": "1", "cabin_class": "economy" }] } ] } ]}- Amounts are decimal strings (
"89.00"), never numbers.total_*,base_*andtax_*are all required. passengers[].idmust be the empty string. The checkout fills it with the traveler’s saved-passenger id; anything else fails the passenger update (400/404, “Error updating passenger”). Identify each slot withprovider_passenger_idand reference it fromsegments[].passengers[].passenger_id.- Durations:
"N hr M min"("1 hr 25 min") or ISO 8601PTnHmM("PT1H25M"), on the slice and each segment. They always read back as"N hr M min";"1h25m", bare minutes andhh:mmare rejected. - A non-matching offer is rejected at save time with
400and a message naming the field; nothing is stored. - Times are local, without a zone (
"2026-11-02T07:15:00"); the zone comes fromorigin.time_zone/destination.time_zone. owner,marketing_carrierandoperating_carrierare the same{ id, name, iata_code }object. The checkout does not displayowner: its airline name, logo and policies come from the customer record behind youriataCode, and each flight row showsmarketing_carrier.nameand the flight number.cabin_classis one ofeconomy,premium_economy,business,first.- The top-level
idis yours (any string); Movmo assigns its ownoffer_idon save.
Offer lifecycle
Section titled “Offer lifecycle”- A saved offer expires 30 minutes after the save (
expires_at). Past that the checkout ends withoffer_unavailable; re-save aflightOfferIDyou are holding before it expires. - A saved offer is bound to the first Movmo account that opens it. Signing in as someone else mid-checkout ends with
offer_unavailable; re-select the flight to mint a fresh offer. - Once a saved passenger is attached,
passengers[].idon the stored offer carries that passenger’s Movmo id. Never copy it back into a new offer.
After the click
Section titled “After the click”onBookingComplete field | Meaning | What to do |
|---|---|---|
bookingId | Movmo’s booking id | Key your record on it. |
airlinePnr | The record locator in your own reservation system; absent when the provider returned none | Store it when present. |
airlineRef | The iataCode you passed to the button; not the PNR | Optional. |
providerId | The providerID you passed to the button | Optional. |
Show your own confirmation page or leave the traveler on Movmo’s; either way Movmo emails the traveler its confirmation.
onExit reason | Meaning | Your next step |
|---|---|---|
booking_confirmed | The traveler clicked “Return to {airline}” after booking | Show your confirmation or trip page. |
offer_unavailable | The offer expired or the traveler switched accounts | Return to search results so they can re-select. |
search_again | The traveler left through the airline logo in the checkout header, or after a booking failure the checkout cannot retry | Return to search results, or to your selected-flight step. |
- Browser back calls nothing. A traveler who presses back, or closes the tab, never returns through Movmo, so neither callback fires. Back from Movmo’s page simply returns to your page.
- Servicing happens in your PSS. Changes, refunds and cancellations run on your side; there is no partner API for booking reads and no webhook today.
What your users see
Section titled “What your users see”| State | What the traveler sees |
|---|---|
| Button | ”Express Checkout” with the Movmo badge, on your selected-flight step. |
| After the click | The button is disabled (aria-busy) and shows a spinner with “Opening Movmo…” until Movmo’s page replaces yours. It resets if the offer save fails, and when the page is restored from the back/forward cache. |
| Movmo checkout page | The tab navigates to Movmo’s checkout on flights.movmo.io, with a “Return to {airline}” link in the header. The traveler signs in there with their email and a code, or with a passkey; there are no pop-ups or storage prompts. |
| Passengers and payment | Saved passengers and cards from the traveler’s Movmo account, or new ones. |
| Confirmation | The booking, with Return to {airline}, which sends the tab back to your returnUrl and fires onBookingComplete then onExit({ reason: "booking_confirmed" }). |
| Offer expired | ”This offer is no longer available” after 30 minutes or an account switch; its Return to {airline} fires onExit({ reason: "offer_unavailable" }). |
- Dead click: see Where it can run.
offer_unavailableright away: the saved offer expired, or a different Movmo account opened it. See Offer lifecycle.- Traveler is not sent back: the
returnUrlorigin is not registered; seereturnUrl.
Going to production
Section titled “Going to production”- A production API key from Movmo.
- Production origins registered with Movmo.
- Your live provider id in place of the sandbox one.
isProd: trueon the button.returnUrlorigins registered for production, and the return page deployed.- The script-tag version pinned to an exact version with SRI (not the
v2alias); see Version pinning and SRI.