@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.
Maintainers
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-receiverWhat you build with it
Two endpoints sharing one store:
POST /webhook- M2C delivers the signed conversion webhook here (server-to-server, HMAC-verified). You write the recorded status.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_idis 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 signedtestflag 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 buildStatus
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.
