Skip to content

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

  • 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 your returnUrl, by default the page they clicked from.
  • An onBookingComplete handler that keys your record on Movmo’s bookingId and stores airlinePnr when present, plus an onExit handler that returns the traveler to the right step. Both are unauthenticated browser signals: confirm the booking server-side before you fulfil anything.

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-click
checkout for airline bookings. The traveler clicks our button, the whole tab
navigates to Movmo's checkout page, and Movmo sends the traveler back to our
page when they finish. There is no overlay, no iframe and no /movmo/checkout
route. Docs: https://docs.movmo.io/express-checkout/overview/,
https://docs.movmo.io/express-checkout/integration-guide/ and
https://docs.movmo.io/express-checkout/operations-and-compliance/ (read the
integration 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 to
the Movmo checkout page on e2e (flights.e2e.movmo.io) - a click that does
nothing means the offer save failed; check the network tab for the
createProviderOffer POST - then wire the return page, onBookingComplete,
onExit and onError.

Fill in the two placeholders. The docs are the authority if the assistant improvises.

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:3000 works against e2e out of the box.

What an unregistered origin looks like:

  • At the click, onError fires: the network tab shows the POST to /v1/flights/providers/{provider}/offers failing with a CORS error, surfaced as { stage: "save_offer", status: undefined, message: "Failed to fetch" }. The analytics beacon fails the same way, silently.
  • A returnUrl on an unregistered origin (or not https, 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.

Install, version pinning and SRI are on the package reference.

import "@movmo/express-checkout-button/dist/index.css"; // once, at app boot
import { 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}
/>;
<!-- 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-width of about 300 px (font-dependent) or a width, never a height: a narrower container wraps the label onto two lines.
  1. Saves the offer via createProviderOffer (POST /v1/flights/providers/{provider}/offers), skipped when you pass flightOfferID. On failure it stops and calls onError.
  2. Stores a one-time state nonce in sessionStorage on your origin (key movmo:checkout:pending).
  3. Navigates the whole tab to https://flights.movmo.io/booking?offerId=…&providerId=…&airlineRef=…&returnUrl=…&state=… (https://flights.e2e.movmo.io when isProd is false). Your page unloads.
<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).

StepWhat the SDK does
VerifyRuns 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.
CallbacksonBookingComplete (confirmed bookings only), then onExit({ reason }), then the nonce is cleared. A throwing callback is caught and logged.
ScrubEvery 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 onExit never waits for a promise from onBookingComplete. 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 at 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.
  • returnUrl is 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:3000 on e2e), on a registered origin, without userinfo and at most 2048 characters. Otherwise there is no return link.

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 / bundler
import { consumeMovmoCheckoutReturn } from "@movmo/express-checkout-button";
consumeMovmoCheckoutReturn({ isProd: false, onBookingComplete, onExit });
// Script tag
MovmoExpressCheckout.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; isProd is only a fallback. It returns true when callbacks fired.
  • With two buttons on one page, pass iataCode, providerID or offerId to claim only the return that matches.
<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.
StageCauseAction
missing_offerNeither flightOffer nor flightOfferID was passed; fires synchronously, no statusFix the mount.
save_offer, no statusNetwork failure, usually an unregistered origin (Where it can run)Register the origin, or save the offer server-side.
save_offer, status: 400The sandbox offer shape was rejectedFix the field the message names (the backend’s own error text).
  • No /movmo/checkout route is needed: Movmo’s checkout lives on its own page.
  • The return lands on returnUrl as 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 returnUrl on the route that mounts the button or calls consumeReturn; Movmo adds movmo_pnr inside your existing hash.
  • Single-page apps: mount (or consume) on the return route’s first render; the movmo_* params are in location, not in router state.

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.

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:

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

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.

ProviderWhat you pass
darwinflightOffer = the availability-response object from /perform-availability, verbatim.
dohopflightOffer = 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.
duffelflightOfferID = 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.

{
"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_* and tax_* are all required.
  • passengers[].id must 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 with provider_passenger_id and reference it from segments[].passengers[].passenger_id.
  • Durations: "N hr M min" ("1 hr 25 min") or ISO 8601 PTnHmM ("PT1H25M"), on the slice and each segment. They always read back as "N hr M min"; "1h25m", bare minutes and hh:mm are rejected.
  • A non-matching offer is rejected at save time with 400 and a message naming the field; nothing is stored.
  • Times are local, without a zone ("2026-11-02T07:15:00"); the zone comes from origin.time_zone / destination.time_zone.
  • owner, marketing_carrier and operating_carrier are the same { id, name, iata_code } object. The checkout does not display owner: its airline name, logo and policies come from the customer record behind your iataCode, and each flight row shows marketing_carrier.name and the flight number.
  • cabin_class is one of economy, premium_economy, business, first.
  • The top-level id is yours (any string); Movmo assigns its own offer_id on save.
  • A saved offer expires 30 minutes after the save (expires_at). Past that the checkout ends with offer_unavailable; re-save a flightOfferID you 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[].id on the stored offer carries that passenger’s Movmo id. Never copy it back into a new offer.
onBookingComplete fieldMeaningWhat to do
bookingIdMovmo’s booking idKey your record on it.
airlinePnrThe record locator in your own reservation system; absent when the provider returned noneStore it when present.
airlineRefThe iataCode you passed to the button; not the PNROptional.
providerIdThe providerID you passed to the buttonOptional.

Show your own confirmation page or leave the traveler on Movmo’s; either way Movmo emails the traveler its confirmation.

onExit reasonMeaningYour next step
booking_confirmedThe traveler clicked “Return to {airline}” after bookingShow your confirmation or trip page.
offer_unavailableThe offer expired or the traveler switched accountsReturn to search results so they can re-select.
search_againThe traveler left through the airline logo in the checkout header, or after a booking failure the checkout cannot retryReturn 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.
StateWhat the traveler sees
Button”Express Checkout” with the Movmo badge, on your selected-flight step.
After the clickThe 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 pageThe 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 paymentSaved passengers and cards from the traveler’s Movmo account, or new ones.
ConfirmationThe 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_unavailable right away: the saved offer expired, or a different Movmo account opened it. See Offer lifecycle.
  • Traveler is not sent back: the returnUrl origin is not registered; see returnUrl.
  • A production API key from Movmo.
  • Production origins registered with Movmo.
  • Your live provider id in place of the sandbox one.
  • isProd: true on the button.
  • returnUrl origins registered for production, and the return page deployed.
  • The script-tag version pinned to an exact version with SRI (not the v2 alias); see Version pinning and SRI.