npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

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/checkout

ESM, 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 webhook

The 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 via createClient({ statusSource }) or resume({ 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; use m2c, url, or callback.

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.