npm.io
0.2.2 • Published yesterday

@m2c/checkout-receiver

Licence
MIT
Version
0.2.2
Deps
1
Size
33 kB
Vulns
0
Weekly
157

@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 }.

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.

Keywords