@movmo_app/payments
@movmo_app/payments is a React SDK that lets partners embed Movmo card capture and saved-payment management directly in their own UI surface, instead of redirecting users to the Movmo accounts UI. It wraps Spreedly Hosted Fields tokenization, the createPaymentMethodFromToken exchange, and the read / update / delete operations on saved methods into drop-in components.
The full PAN never reaches Movmo — tokenization happens client-side in Spreedly iframes against the PCI vault, exactly as it does in the Movmo accounts UI. Partners stay in PCI DSS SAQ A scope.
| Current version | 0.5.0 |
| Peer dependencies | React 18, React DOM 18, Tailwind CSS 3.4+ |
| License | MIT |
| npm | @movmo_app/payments |
Install
Section titled “Install”npm install @movmo_app/paymentsImport the package stylesheet once at app boot. The CSS file ships the Tailwind utilities the components depend on plus Spreedly iframe sizing:
import "@movmo_app/payments/dist/style.css";Configure the SDK once before any component mounts. baseUrl is the Movmo API root (https://e2e.api.movmo.io in pre-prod, https://api.movmo.io in prod):
import { setPaymentsConfig } from "@movmo_app/payments";
setPaymentsConfig({ baseUrl: process.env.REACT_APP_MOVMO_API_URL,});PaymentsConfig also accepts an optional iconCdnBaseUrl (override for the CDN that serves card-brand SVGs) and fetch (described below). Defaults to the Movmo e2e icon CDN; override at boot if you ship a prod-tier CDN.
Movmo’s payment endpoints require session-cookie auth plus a CSRF token, and respond with 401 if the session is stale. createMovmoAuthFetch returns a fetch wrapper that handles both — passing it into setPaymentsConfig({ fetch }) is the recommended path for any partner that needs CSRF + session-refresh logic:
import { setPaymentsConfig, createMovmoAuthFetch } from "@movmo_app/payments";
const authFetch = createMovmoAuthFetch({ refreshSession: () => fetch("/auth/refresh", { credentials: "include" }).then((r) => { if (!r.ok) throw new Error("session refresh failed"); }), onRefreshFailed: () => { // Sign out and send the user back to your login page. window.location.href = "/login"; }, extraHeaders: () => ({ "x-api-key": process.env.REACT_APP_MOVMO_API_KEY, }),});
setPaymentsConfig({ baseUrl: process.env.REACT_APP_MOVMO_API_URL, fetch: authFetch,});The package never holds your client_secret or refresh token — refreshSession is your hook to call your own backend, which performs the OAuth refresh server-side.
CreateMovmoAuthFetchOptions also exposes advanced overrides for less common environments:
csrfCookieName— override the cookie name carrying the CSRF token (defaults tomovmo_csrf_token).authPathFragment— substring used to detect requests that must skip the refresh-on-401 retry loop (defaults to/v1/auth/). Override if your auth proxy lives at a different path and you want to avoid infinite refresh recursion.baseFetch— underlyingfetchimplementation; override only for tests or non-browser runtimes.readCookie— cookie reader; defaults todocument.cookie, override only in non-browser test environments.
Capture a card
Section titled “Capture a card”<MovmoCardForm> renders a complete card-capture form: card number, expiry, CVV, ZIP, cardholder name, and a Save button. Card number and CVV are Spreedly iframes; everything else is rendered in your DOM and styled with the package CSS.
import { MovmoCardForm } from "@movmo_app/payments";
<MovmoCardForm userId={user.id} isDefault={true} defaultCardholderName={`${user.firstName} ${user.lastName}`} onSuccess={(method) => { // method: PaymentMethodSummary — { id, last4, brand, expMonth, expYear, isDefault } console.log("Saved card", method.id); }} onError={(message) => { console.error("Card save failed:", message); }}/>;On submit, the form tokenizes the card against Spreedly client-side and exchanges the resulting token for a Movmo payment method UUID via createPaymentMethodFromToken. onSuccess fires with the saved method; onError fires with a human-readable message.
MovmoCardForm props
Section titled “MovmoCardForm props”| Prop | Type | Required | Description |
|---|---|---|---|
userId | string | yes | Movmo user UUID — the card is saved to this user. |
onSuccess | (method: PaymentMethodSummary) => void | yes | Called once the card is saved. |
onError | (message: string) => void | yes | Called on tokenization or save failure. |
isDefault | boolean | no | Save as the user’s default card. Defaults to false. |
defaultCardholderName | string | no | Pre-fill the cardholder name field. |
autoSave | boolean | no | Auto-submit when the form is valid (1200ms debounce). |
hideInternalSaveButton | boolean | no | Omit the built-in Save button — pair with formId and your own submit control. |
onCanSubmitChange | (canSubmit: boolean) => void | no | Fires when “ready to submit” toggles — useful for driving an external submit button. |
onSavingChange | (isSaving: boolean) => void | no | Fires when the saving state changes. |
onErrorDetail | (error: PaymentManagerError) => void | no | Structured-error sink fired alongside onError, carrying the originating source (save-from-token, card-tokenize, tokenization-session, spreedly-script). See Error handling. |
formId | string | no | HTML id for the <form> element. Defaults to movmo-card-form. |
autoFocus | "number" | "cvv" | no | Focus the card-number or CVV iframe on mount. |
className | string | no | Wrapper className appended to the form container. |
Manage saved cards
Section titled “Manage saved cards”<PaymentMethodsManager> is a higher-level component that renders the user’s saved cards, the add-card form when there are none (or when the user clicks “Add card”), and per-row actions for set-default, edit, and delete. (Edit captures the cardholder name and billing ZIP and is offered only on Spreedly-vaulted cards — the only vault where those fields are mutable.) It is what the Movmo accounts UI uses internally, and the same component drops into a checkout flow with selection-mode props.
Account-management variant:
import { PaymentMethodsManager } from "@movmo_app/payments";
<PaymentMethodsManager userId={user.id} defaultCardholderName={`${user.firstName} ${user.lastName}`}/>;Checkout selection variant — the user picks which saved card to charge, and selecting a card promotes it to default:
<PaymentMethodsManager userId={user.id} selectedId={selected?.id} onSelect={(method) => setSelected(method)} selectionSetsDefault={true} collapsible={true}/>PaymentMethodsManager props
Section titled “PaymentMethodsManager props”| Prop | Type | Required | Description |
|---|---|---|---|
userId | string | yes | Movmo user UUID. |
selectedId | string | no | Controlled selected payment method id. |
onSelect | (method: PaymentMethodSummary) => void | no | Fired when the user selects a row. |
onChange | (items: PaymentMethodSummary[]) => void | no | Fired when the list changes (add / delete / set-default). |
onError | (error: PaymentManagerError) => void | no | Fired on any user-facing failure, with a discriminated source. Additive — errors still render in-SDK. See Error handling. |
selectionSetsDefault | boolean | no | Selecting a row also sets it as default. Defaults to false. |
showDefaultBadge | boolean | no | Render a “Default” badge on the default card. Defaults to true. |
collapsible | boolean | no | Collapse the list to a single preview row with a toggle. Defaults to false. |
paymentTypeSelector | boolean | no | Show a Credit / PayPal / Google Pay radio above the form. Defaults to true. Only Credit is implemented today. |
autoSaveFirstCard | boolean | no | Auto-submit the add-card form for users with zero saved cards. Defaults to true. |
cardLabelFormat | "masked" | "branded" | no | •••• 4242 vs Visa 4242. Defaults to "branded". |
defaultCardholderName | string | no | Pre-fill the embedded <MovmoCardForm> cardholder name. |
className | string | no | Wrapper className. |
Delete and set-default are applied optimistically and roll back on API failure.
Error handling
Section titled “Error handling”Every failure the SDK surfaces to the user is also rendered in-component (a red banner, or a Retry control for the list). For observability — logging to Sentry, muting a Book button, or any other side effect — pass onError to <PaymentMethodsManager>. It fires with a PaymentManagerError whose source discriminates which operation failed:
<PaymentMethodsManager userId={user.id} onError={(error) => { // error: { source, message, cause? } Sentry.captureException(error.cause ?? new Error(error.message), { tags: { paymentSource: error.source }, }); if (error.source === "save-from-token" || error.source === "tokenization-session") { disableCheckout(); // can't take a new card right now } }}/>interface PaymentManagerError { source: PaymentErrorSource; message: string; // the same copy shown in the in-SDK banner cause?: unknown; // original Error / rejection, where available}
type PaymentErrorSource = | "tokenization-session" // GET /v1/payments/tokenization-session failed | "spreedly-script" // the Spreedly Hosted Fields iframe script failed to load or init | "card-tokenize" // Spreedly rejected the card while tokenizing | "save-from-token" // POST .../payment-methods/from-token failed | "list" // GET .../payment-methods failed | "set-default" // the set-default PUT failed | "update" // the edit-card (cardholder / ZIP) PUT failed | "delete"; // the delete request failedonError is additive and never swallows the in-SDK banner. The four add-card sources also surface on <MovmoCardForm> directly via its onErrorDetail prop, for partners composing their own management view around the bare card form.
Compact preview
Section titled “Compact preview”<PaymentMethodPreview> is the smallest building block — a one-line “brand icon + label” preview of a single saved card. Useful for a collapsed checkout drawer, an order summary row, or any spot where you want to show the user’s selected card without rendering the full <PaymentMethodsManager>.
import { PaymentMethodPreview, useUserPaymentMethods,} from "@movmo_app/payments";
const { items } = useUserPaymentMethods(user.id);const defaultMethod = items.find((m) => m.isDefault);
{ defaultMethod && ( <PaymentMethodPreview method={defaultMethod} cardLabelFormat="masked" onClick={() => openDrawer()} /> );}| Prop | Type | Required | Description |
|---|---|---|---|
method | PaymentMethodSummary | yes | The card to render — usually pulled from useUserPaymentMethods. |
trailing | React.ReactNode | no | Slot rendered after the label. Typical use: an expand-chevron button. |
onClick | () => void | no | Row click handler. Adds role="button" and Enter/Space keyboard support when present. |
cardLabelFormat | "masked" | "branded" | no | •••• 4242 vs Visa 4242. Defaults to "branded". |
className | string | no | Wrapper className. |
For partners building their own UI on top of the SDK’s data layer.
useUserPaymentMethods(userId)
Section titled “useUserPaymentMethods(userId)”Fetches the user’s saved cards. Refetches when userId changes; cancels in-flight requests on unmount.
const { items, status, error, refetch } = useUserPaymentMethods(user.id);Returns { items: PaymentMethodSummary[]; status: "idle" | "loading" | "ready" | "error"; error: string | null; refetch: () => void }.
Calls listPaymentMethods.
useDeletePaymentMethod(userId)
Section titled “useDeletePaymentMethod(userId)”Returns { deletePaymentMethod, status, error } with status: "idle" | "pending" | "error". deletePaymentMethod(methodId) returns a promise — await it for rollback-style UX:
const { deletePaymentMethod } = useDeletePaymentMethod(user.id);await deletePaymentMethod(methodId);Calls deletePaymentMethod.
useSetDefaultPaymentMethod(userId)
Section titled “useSetDefaultPaymentMethod(userId)”Same shape as useDeletePaymentMethod (status: "idle" | "pending" | "error"):
const { setDefault } = useSetDefaultPaymentMethod(user.id);await setDefault(methodId);Calls updatePaymentMethod with { isDefault: true }. The server clears the previous default atomically.
useMovmoCardFields(options?)
Section titled “useMovmoCardFields(options?)”Low-level hook for partners who want to compose their own card-form layout. Manages the Spreedly Hosted Fields lifecycle, exposes validity / brand / focus state, and returns a tokenize function plus a programmatic focus helper:
const { status, // 'idle' | 'loading' | 'ready' | 'error' error, // string | null errorSource, // 'tokenization-session' | 'spreedly-script' | null (boot failure only) errorCause, // unknown — original Error/rejection behind a boot failure (for Sentry); null otherwise validity, // { number: boolean; cvv: boolean } brand, // string | null — 'visa' | 'master' | 'american_express' | … focused, // { number: boolean; cvv: boolean } — live iframe focus tokenize, // (data: CardholderTokenizeData) => void focusField, // (field: 'number' | 'cvv') => void} = useMovmoCardFields({ onCardTokenized: (token) => saveToYourBackend(token), onFieldErrors: (errors) => setFieldErrors(errors), onValidityChange: (v) => setCanSubmit(v.number && v.cvv), onBrandChange: (b) => setDetectedBrand(b),});
<div id="movmo-card-number" /><div id="movmo-card-cvv" /><button disabled={!validity.number || !validity.cvv} onClick={() => tokenize({ firstName, lastName, month, year, zip }) }> Save card</button>focused exists because the browser’s :focus-within CSS doesn’t propagate across iframe boundaries — read it to render an “active” border on the iframe shell. focusField('cvv') programmatically moves keyboard focus into a hosted iframe, useful for auto-advancing from an outer expiry input.
Options
Section titled “Options”| Option | Type | Description |
|---|---|---|
numberEl | string | DOM id where the card-number field mounts. Defaults to movmo-card-number. |
cvvEl | string | DOM id where the CVV field mounts. Defaults to movmo-card-cvv. |
onCardTokenized | (token: string) => void | Fires with the opaque vault token after a successful tokenize() call. |
onFieldErrors | (errors: CardFieldError[]) => void | Fires with validation or vault-side errors. |
onValidityChange | (validity: CardFieldValidity) => void | Fires when per-field validity flips. Same value also available on the returned validity. |
onBrandChange | (brand: string | null) => void | Fires when the detected card brand changes. Same value also available on the returned brand. |
Use this when <MovmoCardForm> does not fit your layout. It does not call the Movmo API on your behalf — you receive the vault token in onCardTokenized and decide what to do with it.
All public types are exported and can be imported directly:
import type { PaymentMethodSummary, CardFieldError, CardFormStatus, PaymentErrorSource, PaymentManagerError, MovmoCardFormProps, PaymentMethodsManagerProps, PaymentMethodPreviewProps, PaymentsConfig, CreateMovmoAuthFetchOptions, // useMovmoCardFields UseMovmoCardFieldsOptions, UseMovmoCardFieldsResult, CardholderTokenizeData, // Hook results / status enums UseUserPaymentMethodsResult, UseUserPaymentMethodsStatus, UseDeletePaymentMethodResult, UseDeletePaymentMethodStatus, UseSetDefaultPaymentMethodResult, UseSetDefaultPaymentMethodStatus,} from "@movmo_app/payments";PaymentMethodSummary is a UI-focused subset of the partner-visible shape returned by listPaymentMethods:
interface PaymentMethodSummary { id: string; last4: string; brand: string; expMonth: number; expYear: number; isDefault: boolean;}The REST response also carries userId, methodType, createdAt, and updatedAt; the SDK strips these because nothing in the included components needs them. Read them from the REST response directly via listPaymentMethods if you need them in your own UI.
Underlying REST operations
Section titled “Underlying REST operations”The SDK is a thin wrapper over these operations in the OpenAPI spec:
| Operation | Path |
|---|---|
listPaymentMethods | GET /v1/users/{userid}/payment-methods |
getPaymentMethod | GET /v1/users/{userid}/payment-methods/{methodid} |
createPaymentMethodFromToken | POST /v1/users/{userid}/payment-methods/from-token |
updatePaymentMethod | PUT /v1/users/{userid}/payment-methods/{methodid} |
deletePaymentMethod | DELETE /v1/users/{userid}/payment-methods/{methodid} |
The SDK also calls an internal GET /v1/payments/tokenization-session endpoint to obtain the signed Spreedly iframe parameters. This endpoint is implementation detail — partners never call it directly, and it is not part of the partner integration surface.