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

Current receiver release: 0.11.0.

Install

npm install @m2c/[email protected]

What you build with it

Session purchase webhooks are ordinary conversion events with optional event.sessionId and event.purchaseId fields. They pass through unchanged; existing checkout events omit both fields.

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,
  type ConversionEvent,
} from '@m2c/checkout-receiver';

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

async function applyFulfillmentInOrder(event: ConversionEvent): Promise<void> {
  if (event.eventSequence === undefined) {
    // Never apply an unsequenced event directly to live business state. Persist
    // it idempotently for manual reconciliation, then return successfully so a
    // legacy delivery cannot retry forever or block a sequenced successor.
    await db.transaction((tx) => tx.enqueueLegacyConversionReview({
      key: event.deliveryId
        ?? (event.reversalId
          ? `${event.requestId}:${event.reversalId}`
          : `${event.requestId}:${event.status}:${event.timestamp}`),
      event,
    }));
    return;
  }
  await db.transaction(async (tx) => {
    // Lock or create one business-state row per requestId.
    const state = await tx.lockFulfillmentState(event.requestId);
    if (state.lastEventSequence >= event.eventSequence) return;

    if (event.status === 'completed') {
      await tx.grantGoods(event.reference ?? event.requestId, event.value);
    } else if (event.status === 'refunded' || event.status === 'chargedback') {
      await tx.applyReversal(
        event.reference ?? event.requestId,
        event.cumulativeReversalValue,
      );
    }

    // This compare-and-advance and the business mutation commit together. If
    // sequence 2 arrives first, it records the reversal state and sequence 1
    // cannot later grant goods.
    await tx.setFulfillmentSequence(
      event.requestId,
      event.eventSequence,
      event.status,
    );
  });
}

// 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: applyFulfillmentInOrder,
});
// 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({
  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>;
  putIfNewer(requestId: string, record: StatusRecord): Promise<{
    record: StatusRecord;
    applied: boolean;
  }>;
}

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

INSERT INTO checkout_status (request_id, status, event_sequence, event_timestamp)
VALUES ($1, $2, $3, $4)
ON CONFLICT (request_id) DO UPDATE
SET status = EXCLUDED.status,
    event_sequence = EXCLUDED.event_sequence,
    event_timestamp = EXCLUDED.event_timestamp
WHERE
    (
        EXCLUDED.event_sequence IS NOT NULL
        AND (
            checkout_status.event_sequence IS NULL
            OR checkout_status.event_sequence <= EXCLUDED.event_sequence
        )
    )
    OR (
        EXCLUDED.event_sequence IS NULL
        AND checkout_status.event_sequence IS NULL
        AND checkout_status.event_timestamp <= EXCLUDED.event_timestamp
    )
RETURNING status, event_sequence, event_timestamp;

putIfNewer is the atomic ordering boundary for the browser-facing status cache. Implement it with a conditional write, transaction, or compare-and-set; a separate get followed by an unconditional write is not sufficient. If a conditional upsert returns no row because a newer row already exists, read that row and return it with applied: false. Return an accepted candidate with applied: true. handleReceiverWebhook uses this flag to avoid running an obviously stale onEvent. This is only a prefilter. Two accepted callbacks can overlap, so fulfillment must independently compare and advance eventSequence in the same transaction as its business mutation. Eventually consistent Workers KV alone cannot provide either guarantee.

Current live deliveries are sequenced, but a rolling deployment or a dashboard redelivery can surface an older live payload without eventSequence. Do not apply that payload directly to fulfillment or reversal state. The example above records it in an idempotent manual-review queue and returns 2xx, preventing a retry storm while preserving the event for reconciliation. Once the status store has accepted any sequenced event for a request, a later unsequenced redelivery is deliberately acknowledged without invoking onEvent. Reconcile such a legacy DLQ entry from your retained webhook records or with M2C support before using the dashboard retry action; a successful retry response alone does not prove that legacy business mutation ran.

Duplicate suppression and 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 idempotent. runWithDeliveryClaim, keyed on the retry-stable deliveryId, suppresses completed and concurrent duplicate deliveries. It reserves the delivery before running your callback, marks it handled after success, and releases the claim if your callback throws so M2C's retry can try again.

The claim helper cannot make an arbitrary side effect exactly-once. A process can grant goods and crash before markHandled, so a retry may run the callback again. For database fulfillment, write the business mutation and a unique deliveryId marker in the same transaction. For external APIs, use a transactional outbox and pass deliveryId as the provider's idempotency key. Different conversion transitions have different delivery IDs, so this helper also does not serialize a completion against a later refund or chargeback. The per-request eventSequence transaction shown above is the ordering authority.

deliveryId is the primary dedupe key for every delivery. If you ever handle a reversal event that lacks one (for example, replaying from your own logs), fall back to (request_id, reversal_id) - never (request_id, status), which would silently drop every partial refund after the first.

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

const deliveries = new InMemoryDeliveryStore(); // back with an atomic insert-if-absent in prod
const fulfillmentKey = (event: ConversionEvent) => event.deliveryId
  ?? (event.reversalId ? `${event.requestId}:${event.reversalId}` : event.requestId);

await handleReceiverWebhook({
  /* ... */
  onEvent: (event) => runWithDeliveryClaim(
    deliveries,
    fulfillmentKey(event),
    () => applyFulfillmentInOrder(event),
  ),
});

A production DeliveryStore should model the same claim lifecycle in a shared datastore. The durable applyFulfillmentInOrder transaction remains required because a delivery claim alone cannot close the crash window:

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 runWithDeliveryClaim 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) => {
  try {
    const url = new URL(req.url ?? '/', 'http://localhost');

    if (req.method === 'POST' && url.pathname === '/webhook') {
      const chunks: Buffer[] = [];
      let size = 0;
      // Keep the stream alive on early return long enough to send the 413.
      for await (const c of req.iterator({ destroyOnReturn: false })) {
        const chunk = Buffer.isBuffer(c) ? c : Buffer.from(c);
        size += chunk.length;
        if (size > 64 * 1024) {
          res.writeHead(413, { connection: 'close' }).end();
          return;
        }
        chunks.push(chunk);
      }
      const { status, body } = await handleReceiverWebhook({
        secret: SECRET,
        rawBody: Buffer.concat(chunks),
        headers: req.headers,
        store,
        onEvent: applyFulfillmentInOrder,
      });
      res.writeHead(status).end(body);
      return;
    }

    const m = url.pathname.match(/^\/status\/([^/]+)$/);
    if (req.method === 'GET' && m) {
      let requestId: string;
      try {
        requestId = decodeURIComponent(m[1]);
      } catch {
        res.writeHead(400).end();
        return;
      }
      const result = await readStatus(store, requestId);
      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();
  } catch (err) {
    // Node's HTTP server does not catch rejected async request listeners.
    if (res.destroyed) return;
    if (res.headersSent) {
      res.destroy();
      return;
    }
    res.writeHead(500, { connection: 'close' }).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: applyFulfillmentInOrder,
    });
    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: applyFulfillmentInOrder,
    });
    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 }.

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.