@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.
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).
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:
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,
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.
