@m2c/checkout
v0.2.0
Published
Headless browser checkout SDK for M2C: run an auction, redirect to the winning vendor's hosted checkout, and reflect conversion status.
Downloads
788
Maintainers
Readme
@m2c/checkout
Headless browser checkout SDK for M2C. It runs an M2C auction, redirects the customer to the winning vendor's hosted checkout, and reflects the conversion status back to your UI when they return.
It is headless: no components, no styles. You render every pixel (including the trigger button); the SDK gives you state, events, and a typed terminal result.
It holds no secrets and does no cryptography. The signed, retried merchant
webhook to your backend remains the source of truth for fulfillment - a return
to success_url proves the customer came back, not that they paid. Treat this
SDK's status as advisory UX.
npm install @m2c/checkoutESM, zero runtime dependencies, ships .d.ts types. Targets evergreen browsers
(ES2020). Importing the module is SSR-safe; the lifecycle methods need a DOM.
Two modes
Backend-initiated (recommended)
Your backend creates the checkout with its secret key (the existing
POST /api/v1/auction call is the create step) and forwards
checkout_url, request_id, and ttl to the browser. No key ships in the
client.
import { createClient } from '@m2c/checkout';
const client = createClient({
// Your backend already receives the M2C webhook, so it is the authoritative
// status source. Point the SDK at your own endpoint.
statusSource: { kind: 'url', template: 'https://shop.example/checkout-status?ref={request_id}' },
});
// session = { checkoutUrl, requestId, ttl } from your backend
await client.startFromSession(session);Client-initiated (no-backend shortcut)
The browser calls the auction directly with a publishable key. For merchants without a backend. Reliable only for low-stakes, instant, in-session digital delivery: a customer who never returns is never confirmed client-side.
Live publishable keys require HTTPS origins. Test publishable keys
(pub_test_...) may also allow loopback HTTP origins such as
http://127.0.0.1:5173 for local sandbox testing.
const client = createClient({
publishableKey: 'pub_...',
// default statusSource is { kind: 'm2c' } - poll the M2C advisory read endpoint
});
await client.start({
transactionValue: 49.99, // major units, e.g. dollars
currency: 'USD',
description: 'Pro plan',
successUrl: 'https://shop.example/checkout/return',
cancelUrl: 'https://shop.example/checkout/canceled',
});The SDK automatically attaches checkout-context metadata to this auction:
platform: "web" and a coarse device_type (mobile / desktop) detected from the
browser. Both are metadata only - never auth or fulfillment - with no caller override.
The redirect / resume model
The default launch is a same-tab full-page redirect (location.assign), for
maximum compatibility with vendor 3-D Secure / bank redirect flows that break
inside popups and iframes.
Because the redirect tears down the page, start / startFromSession navigate
away and do not resolve in place. You get the outcome on your return page by
calling resume():
// On your success_url and cancel_url pages:
const client = createClient({ publishableKey: 'pub_...' });
const result = await client.resume({ outcome: 'success' }); // or 'cancel'
// result is null if no checkout was in progress, otherwise one of:
// { status: 'completed', requestId }
// { status: 'failed', requestId }
// { status: 'canceled', requestId }
// { status: 'pending_timeout', requestId } <- poll window elapsed; ask your webhookThe SDK cannot tell success_url from cancel_url on its own, so pass
outcome. A cancel return resolves canceled immediately; a success return
polls the status source to a terminal result.
start* writes a small resume record to sessionStorage (namespaced by
storageKey, default m2c.checkout) before navigating. It is read and cleared
on the return page, so a refresh does not re-poll a stale checkout.
If the customer instead presses Back from the vendor checkout, the
originating page is restored (from the back-forward cache) and the start /
startFromSession promise resolves { status: 'window_closed', requestId }.
This is an ambiguous browser outcome, not a payment status; keep using the
webhook/status source as the truth. The resume record remains in storage, so if
the customer goes Forward and completes the hosted checkout, the return page can
still call resume() and resolve the terminal payment result.
Popup / new-tab launch
For apps that must keep the original page alive, such as web games, opt into a separate checkout window:
const client = createClient({
publishableKey: 'pub_...',
launchMode: 'popup', // or 'new_tab'
returnTimeoutMs: 60000, // optional fallback for abandoned popup/new-tab flows
});Call start() directly from a user click/tap. The SDK pre-opens a blank window
before the auction request, clears opener, then navigates that window after
the checkout URL is known. If the browser blocks the window, start() rejects
with InvalidRequest before creating the auction.
The default storage for popup/new-tab launch writes the resume record to
localStorage as well as sessionStorage, so the return page in the checkout
window can call resume(). If you inject custom storage, make sure it is
visible to both the opener page and the return page. Use the same client
factory on the opener and return page, or at least pass the same launchMode
and storage configuration:
const client = createClient({
publishableKey: 'pub_...',
launchMode: 'popup', // same value used before start()
});
// On your success_url / cancel_url page in the checkout window:
await client.resume({ outcome: 'success' });When the return page calls resume(), the SDK also broadcasts the terminal
result back to the original same-origin page. In popup/new-tab mode, the
start() / startFromSession() promise in the original page resolves with the
same result.
This handoff does not make successUrl authoritative. completed and failed
still come from the configured status source, and fulfillment should still be
driven server-side from webhook state.
If the checkout window observably closes before a return page publishes a
result, the original page resolves start() / startFromSession() with
{ status: 'window_closed', requestId }. This is not a payment status and does
not mean the purchase failed or was canceled. Some hosted checkout pages sever
opener observability while the window is still alive; in that case the SDK keeps
waiting for the return handoff, then treats opener focus as an abandonment
signal if no return result appears.
Set returnTimeoutMs if you want popup/new-tab mode to stop waiting after a
fixed window when no return handoff arrives. This is an advisory UX fallback;
choose a value long enough for a normal customer checkout, and keep server-side
webhook state as the fulfillment source of truth.
To reconcile an ambiguous outcome after the fact - a window_closed or
pending_timeout that may have completed server-side - re-read the status for
the requestId with checkStatus, a one-shot read against the configured
status source:
const status = await client.checkStatus(requestId);
// 'processing' | 'completed' | 'failed' | 'canceled'checkStatus does not change the client's lifecycle state, and (like the poll)
it is advisory - your webhook remains the source of truth.
For example:
const result = await client.start({
transactionValue: 49.99,
successUrl: 'https://shop.example/checkout/return',
cancelUrl: 'https://shop.example/checkout/canceled',
});The checkout window still lands on your return page because that is where the
vendor redirects the customer. The original page receives the result through a
same-origin BroadcastChannel / localStorage handoff; the SDK does not rely
on window.opener.
Progress events
const unsubscribe = client.onStateChange((state, ctx) => {
// state: idle | creating | ready | launching | awaiting_return | returned
// | polling | completed | failed | canceled | pending_timeout
// | window_closed | error
// ctx: { requestId?, error? } (error is set only on the 'error' state)
render(state);
});Status sources
Configure where status is read from after the return:
{ kind: 'm2c' }- poll the M2C advisory read endpoint with the publishable key (client-initiated only). The default.{ kind: 'url', template }- poll your endpoint;{request_id}is substituted. Answer from your webhook-recorded state. Recommended for backend-initiated mode.{ kind: 'callback', checkStatus }- call your async function on the backoff schedule. A function cannot survive a full-page redirect, so re-supply it on the return page viacreateClient({ statusSource })orresume({ statusSource }).
Status readers may return the client statuses (processing, completed,
failed, canceled) or webhook-native statuses from your own store (pending,
abandoned, refunded, chargedback). The SDK maps them to the client result
enum before resolving.
The poll uses bounded exponential backoff (immediate, then 1s, 2s, 4s, 8s,
capped at 8s, ~90s total window; override with poll). Correlation is always
request_id, which you get from the auction response and your webhook records -
no pre-coordination needed.
A push (
subscribe) adapter is reserved in the type surface but not implemented yet; usem2c,url, orcallback.
Error handling
Rejections are M2CCheckoutError with a typed code:
| code | meaning |
|---|---|
| InvalidRequest | bad input / unsupported currency / bad URL (400, or client-side) |
| OriginNotAllowed | the page origin is not on the key's allowlist (403) |
| AccountSuspended | the merchant account is suspended (403) |
| NoVendorsAvailable | no linked/eligible vendor produced a bid (404) |
| RateLimited | slow down; see retryAfterSeconds (429) |
| ServiceUnavailable | transient backend failure (5xx) |
| CheckoutExpired | the TTL lapsed before launch (backend mode cannot re-mint) |
| Network | no HTTP response (fetch failure) |
| Unknown | an unexpected response with no specific mapping |
pending_timeout is not an error - it resolves as a terminal result. During
polling, transient read failures (rate limit, 5xx, network, a not-yet-visible
status row) are swallowed and retried; a developer-actionable failure (bad
origin, invalid request) rejects so you see it.
Framework recipes
Vanilla:
button.onclick = () => client.start({ /* ... */ }).catch(showError);
client.onStateChange((s) => (statusEl.textContent = s));React (drive a state variable from the event; resume on the return route):
useEffect(() => client.onStateChange(setState), []);
const onBuy = () => client.start({ /* ... */ }).catch(setError);
// On the return route:
useEffect(() => {
client.resume({ outcome: 'success' }).then(setResult).catch(setError);
}, []);Test mode
Use test keys (pub_test_...) and forward a backend session created with a
sec_test_... key. The auction runs against the in-server sandbox vendors and
returns a synthetic M2C-hosted checkout_url; the status read is scoped to the
test flag derived from the key. No special code path - the same flow drives the
sandbox.
Server-side / non-browser usage
All DOM access is guarded, so importing the module on a server never throws.
start / startFromSession / resume throw InvalidRequest if no
sessionStorage / navigation is available; inject storage, navigate, and
fetch to drive the SDK in a non-browser environment (this is how the test
suite runs).
What this SDK does not do
- Capture card data or render the vendor's checkout page (M2C is a router; the vendor owns PCI scope).
- Decide fulfillment. Grant goods server-side off the M2C webhook. See the fulfillment webhook receiver examples that ship alongside the SDKs.
- Ship UI. Headless by design.
Packaging note
This package currently publishes ESM only, matching the rest of the M2C SDK
workspace and its plain-tsc, zero-dependency build. Every modern bundler and
Node 18+ consume it directly; a CJS build can be added without an API change if
a consumer needs it.
