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-receiver

v0.2.0

Published

Fulfillment webhook receiver for M2C: verify conversion webhooks and serve checkout status to the @m2c/checkout browser SDK.

Readme

@m2c/checkout-receiver

A drop-in fulfillment webhook receiver for M2C: it verifies the signed conversion webhook M2C delivers, records a coarse checkout status keyed by requestId, and serves that status back to the @m2c/checkout browser SDK's url status source. Built for a small dedicated service or a serverless function (AWS Lambda, Cloud Run, Cloudflare Workers, Deno, Bun).

It does not decide fulfillment for you. The verified webhook is the source of truth; this package keeps the browser's post-checkout status read honest while your own onEvent grants the goods.

The signature crypto is reused wholesale from @m2c/server - this package adds the status projection, the durable-status contract, and a delivery-dedupe helper.

Requires Node 18+ or a Node-compatible runtime (it uses node:crypto via @m2c/core).

Install

npm install @m2c/checkout-receiver

What you build with it

Two endpoints sharing one store:

  1. POST /webhook - M2C delivers the signed conversion webhook here (server-to-server, HMAC-verified). You write the recorded status.
  2. GET /status/:requestId - the browser polls here; the checkout SDK's { kind: 'url', template } source reads { status }.
import {
  handleReceiverWebhook,
  readStatus,
  InMemoryStatusStore,
} from '@m2c/checkout-receiver';

// In production, replace this with a durable, SHARED store (see "Storage").
const store = new InMemoryStatusStore();

// POST /webhook  (M2C -> you)
const { status, body } = await handleReceiverWebhook({
  secret: process.env.M2C_WEBHOOK_SECRET!,
  rawBody, // the RAW bytes/string, NOT a parsed object
  headers,
  store,
  onEvent: (event) => {
    // Your fulfillment. Runs only for recorded (non-test) events. Fulfill
    // completed payments only, and keep goods-granting idempotent - see
    // "Idempotent fulfillment".
    if (event.status === 'completed') {
      grantGoods(event.reference ?? event.requestId, event.value);
    }
  },
});
// write `status` (204 ok / 400 bad signature) and `body`

// GET /status/:requestId  (browser -> you)
const result = await readStatus(store, requestId); // { status: 'processing' | 'completed' | 'failed' | 'canceled' }
// respond 200 application/json with `result`

Point the checkout SDK at the read endpoint:

import { createClient } from '@m2c/checkout';

const client = createClient({
  baseUrl: 'https://api.m2cmarkets.com',
  statusSource: { kind: 'url', template: 'https://shop.example/status/{request_id}' },
});

How the status maps

recordConversion projects the webhook ConversionStatus to a coarse, browser-safe ReceiverStatus, kept in lockstep with @m2c/checkout's own mapping:

| Webhook status | Served status | Why | |---|---|---| | completed | completed | Payment cleared. | | refunded / chargedback | completed | The payment did clear; reversals arrive long after the checkout poll window and do not change "did they pay" at return time. | | failed | failed | Vendor reported a failure. | | abandoned | canceled | Customer did not complete. | | (no webhook yet) | processing | No row exists, so the SDK keeps polling within its window. |

The served payload is { status } only - never the vendor, value, reference, or transaction id from the event - so the browser-reachable read endpoint discloses nothing beyond pass / fail / processing.

Storage

StatusStore is two async methods keyed by requestId:

interface StatusStore {
  get(requestId: string): Promise<StatusRecord | undefined>;
  put(requestId: string, record: StatusRecord): Promise<void>;
}

The bundled InMemoryStatusStore is for local dev and tests only. In production back it with a durable, shared store (DynamoDB, Redis, Postgres, a KV namespace): the webhook write and the browser read run as separate invocations - on serverless, separate instances - so an in-process map will not see its own writes back.

class DynamoStatusStore implements StatusStore {
  async get(requestId: string) {
    const row = await ddb.get({ TableName: 'checkout_status', Key: { requestId } });
    return row.Item as StatusRecord | undefined;
  }
  async put(requestId: string, record: StatusRecord) {
    await ddb.put({ TableName: 'checkout_status', Item: { requestId, ...record } });
  }
}

recordConversion resolves event ordering (a delayed or retried older delivery never regresses a newer status) before calling put, so a plain overwrite is correct. A store may additionally make put conditional if it wants strict ordering under truly concurrent same-requestId deliveries.

Idempotent fulfillment

The status cache is an idempotent upsert and needs no dedupe. Your fulfillment side effect does: M2C retries deliveries, so granting goods or sending mail must be guarded. Use runOnce, keyed on the retry-stable deliveryId: it reserves the delivery before running your callback, marks it handled only after success, and releases the claim if your callback throws so M2C's retry can try again.

import { runOnce, InMemoryDeliveryStore } from '@m2c/checkout-receiver';

const deliveries = new InMemoryDeliveryStore(); // back with an atomic insert-if-absent in prod

await handleReceiverWebhook({
  /* ... */
  onEvent: (event) => runOnce(deliveries, event.deliveryId, () => {
    if (event.status === 'completed') grantGoods(event.reference ?? event.requestId, event.value);
  }),
});

A production DeliveryStore should model the same claim lifecycle in a shared datastore:

interface DeliveryStore {
  claim(deliveryId: string): Promise<'claimed' | 'already_handled' | 'in_progress'>;
  markHandled(deliveryId: string): Promise<void>;
  release(deliveryId: string): Promise<void>;
}

Make claim atomic, and make in-progress claims leased or otherwise recoverable: if a process dies after claiming but before marking handled, a later M2C retry must not be blocked forever. If runOnce sees in_progress, it throws a DeliveryInProgressError; let that become a 5xx so M2C retries after the active attempt succeeds or releases.

Adapters (copy-paste for your runtime)

The receiver is runtime-agnostic; the only per-runtime work is capturing the raw body and writing the response. The signature covers the exact bytes, so never let a JSON parser rewrite the body before verification.

Node http

import { createServer } from 'node:http';
import { handleReceiverWebhook, readStatus, InMemoryStatusStore } from '@m2c/checkout-receiver';

const store = new InMemoryStatusStore();
const SECRET = process.env.M2C_WEBHOOK_SECRET!;

createServer(async (req, res) => {
  const url = new URL(req.url ?? '/', 'http://localhost');

  if (req.method === 'POST' && url.pathname === '/webhook') {
    const chunks: Buffer[] = [];
    for await (const c of req) chunks.push(c as Buffer);
    const { status, body } = await handleReceiverWebhook({
      secret: SECRET,
      rawBody: Buffer.concat(chunks),
      headers: req.headers,
      store,
      onEvent: (e) => { if (e.status === 'completed') grantGoods(e.reference ?? e.requestId, e.value); },
    });
    res.writeHead(status).end(body);
    return;
  }

  const m = url.pathname.match(/^\/status\/([^/]+)$/);
  if (req.method === 'GET' && m) {
    const result = await readStatus(store, decodeURIComponent(m[1]));
    res.writeHead(200, {
      'content-type': 'application/json',
      'access-control-allow-origin': 'https://shop.example', // scope to your shop origin
    });
    res.end(JSON.stringify(result));
    return;
  }

  res.writeHead(404).end();
}).listen(8093);

Express

import express from 'express';
import { handleReceiverWebhook, readStatus, InMemoryStatusStore } from '@m2c/checkout-receiver';

const store = new InMemoryStatusStore();
const app = express();

// RAW body on the webhook route ONLY - before any express.json().
app.post('/webhook', express.raw({ type: '*/*', limit: '64kb' }), async (req, res, next) => {
  try {
    const { status, body } = await handleReceiverWebhook({
      secret: process.env.M2C_WEBHOOK_SECRET!,
      rawBody: req.body, // Buffer
      headers: req.headers,
      store,
      onEvent: (e) => { if (e.status === 'completed') grantGoods(e.reference ?? e.requestId, e.value); },
    });
    res.status(status).send(body);
  } catch (err) {
    next(err); // a throw -> 5xx -> M2C retries
  }
});

app.get('/status/:requestId', async (req, res) => {
  res.set('access-control-allow-origin', 'https://shop.example');
  res.json(await readStatus(store, req.params.requestId));
});

AWS Lambda (API Gateway / Function URL)

import { handleReceiverWebhook, readStatus, InMemoryStatusStore } from '@m2c/checkout-receiver';

const store = new InMemoryStatusStore(); // use a DynamoStatusStore in prod

export async function handler(event: any) {
  const path = event.rawPath ?? event.path;
  const method = event.requestContext?.http?.method ?? event.httpMethod;

  if (method === 'POST' && path.endsWith('/webhook')) {
    // API Gateway may base64-encode the body; decode to the RAW bytes.
    const raw = event.isBase64Encoded
      ? Buffer.from(event.body ?? '', 'base64')
      : (event.body ?? '');
    const { status, body } = await handleReceiverWebhook({
      secret: process.env.M2C_WEBHOOK_SECRET!,
      rawBody: raw,
      headers: event.headers,
      store,
      onEvent: (e) => { if (e.status === 'completed') grantGoods(e.reference ?? e.requestId, e.value); },
    });
    return { statusCode: status, body: body ?? '' };
  }

  const m = path.match(/\/status\/([^/]+)$/);
  if (method === 'GET' && m) {
    const result = await readStatus(store, decodeURIComponent(m[1]));
    return {
      statusCode: 200,
      headers: { 'content-type': 'application/json', 'access-control-allow-origin': 'https://shop.example' },
      body: JSON.stringify(result),
    };
  }

  return { statusCode: 404, body: '' };
}

For Cloudflare Workers / Deno / Bun, pass await request.text() (or new Uint8Array(await request.arrayBuffer())) as rawBody and request.headers as headers, then build a Response from the returned { status, body }. A ready-to-deploy Worker (with KV-backed storage) lives in examples/cloudflare-worker/ in the repo.

Security notes

  • Serve coarse status only. The read endpoint is browser-reachable; { status } is all the SDK needs and all it should ever see. request_id is the only correlation, so keep the response minimal and CORS-scope it to your shop origin.
  • Verify the raw bytes. Hand the original request body to handleReceiverWebhook; a re-serialized object fails verification.
  • Branch on test. Sandbox conversions arrive at the same URL with the signed test flag set. The receiver skips recording them by default; do not fulfill real goods for a test event.
  • The webhook is the truth; this cache is advisory UX. Drive fulfillment from onEvent, idempotently, never from the browser read.

Error handling

handleReceiverWebhook returns 400 (and records nothing, fires no onEvent) on a bad or missing signature, and 204 after recording. An empty secret is local misconfiguration and throws. A throw from your onEvent, or an authentic but off-contract payload, propagates - return that as a 5xx so M2C retries with backoff and then dead-letters. Errors thrown by this package extend the exported M2CError base class; your own onEvent may throw whatever your code throws.

Development

npm install
npm run typecheck
npm test       # hermetic; no network or DB
npm run build

Status

Draft (0.1.0), tracking the API in this monorepo. See @m2c/checkout for the browser SDK this feeds and ../DESIGN.md for the suite status.