@m2c/vendor
v0.11.0
Published
Vendor-side Node/TypeScript SDK for the M2C payment-vendor auction: verify bid requests, sign bid responses, report conversions.
Maintainers
Readme
@m2c/vendor
Vendor-side Node / TypeScript SDK for participating in the M2C payment-vendor auction. It owns the protocol crypto so you can't get it subtly wrong; it does NOT host your bid endpoint, store your nonces, or price your bids - those stay yours (see What this SDK does not do).
What it gives you:
handleBidRequest(...)- the whole bid endpoint in one call: verify the signature, run your pricing callback, and return the right signed/unsigned response. Wraps the two primitives below; reach past it only for finer control.verifyBidRequest(...)- verify M2C's signature on an inbound bid request and parse it into a typed object.buildSignedBidResponse(...)/buildSignedInvalidMerchant(...)- construct and sign your bid (or merchant-account rejection) response.M2CVendorClient.reportConversion(...)- sign and POST a conversion (or reversal) report to M2C, with typed errors and retry.reportStoredConversion(...)- load the nonce from yourNonceStore, report the conversion, and clean up terminal nonces.handleRefundRequest(...)/verifyRefundRequest(...)- handle an M2C-initiated refund: verify the signature, get a typedRefundRequest, execute it at your PSP, then report the result as arefundedconversion.
Requires Node 18+ (built-in fetch and node:crypto).
Install
npm install @m2c/[email protected]Your two secrets
Initial conversion reports must arrive within seven days of auction creation. Missing reports are overdue after two hours, but that does not mean payment failed. A conflicting initial report returns 409 (conversion_conflict); an expired reporting window returns 422 (unprocessable). Both are permanent errors requiring reconciliation. Keep the payment evidence and nonce, contact M2C support, and do not create a new auction to report the same payment. Equivalent accepted-report retries remain successful after the deadline or a reversal. The 180-day reversal window starts when completion is accepted.
| Secret | Role |
|---|---|
| outbound key | The symmetric HMAC secret. Verifies M2C's inbound bid-request signature, and signs your bid responses and conversion reports. |
| inbound key | The X-API-Key bearer that authenticates you when you POST a conversion report. |
The bid endpoint (M2C calls you)
M2C signs each bid request with your outbound key. handleBidRequest runs the
whole endpoint in one call - verify the signature, run your pricing, build the
right response - and hands you back the status, headers, and body to write:
import { handleBidRequest } from '@m2c/vendor';
// Express: capture RAW bytes - verification is over the exact signed bytes.
// app.post('/m2c/bid', express.raw({ type: '*/*' }), handler)
async function handler(req, res) {
const { status, headers, body } = await handleBidRequest({
outboundKey: OUTBOUND_KEY,
vendorId: MY_VENDOR_ID, // serialized as vendor_id; M2C uses your
// authenticated registered id as authoritative
rawBody: req.body, // the RAW bytes, not a parsed object
headers: req.headers,
price: async (bid) => {
const fee = priceThisAuction(bid); // your pricing logic
if (fee === null) return 'decline';
// Persist the nonce keyed by requestId BEFORE you bid - you echo it on the
// conversion report (see "Storing the nonce"); a durable store is required.
await db.saveNonce(bid.requestId, bid.conversionNonce);
return { bidAmount: fee, checkoutUrl: await createCheckoutSession(bid), ttl: 300 };
},
});
res.set(headers ?? {}).status(status).send(body); // send the signed bytes unchanged
}Your price callback returns the bid fields to bid, 'decline' to pass on the
auction, or 'invalid-merchant' to reject the merchant account.
handleBidRequest maps those to the right response and to a 401 for a bad or
missing signature, so the status-code rules can't drift. An empty outboundKey
is local configuration failure and still throws.
The bid passed to price carries the auction context you can price on:
transactionValue, currency, country, language, deviceType, and platform
(the checkout surface: web | webgl | ios | android | desktop), alongside
requestId and conversionNonce. platform and deviceType are metadata hints,
not attestations of the real device, so price on them but don't trust them.
checkoutUrl must be HTTPS in normal environments and at most 4096 bytes - M2C
silently drops bids with longer URLs, so the SDK rejects them up front.
Loopback HTTP URLs (localhost, 127.0.0.0/8, or [::1]) are rejected unless
you pass responseOptions: { allowLoopbackHttp: true }, so a dev checkout URL
cannot accidentally ship inside a production bid.
Three responses, and only one of them is unsigned:
- Bid: HTTP
200with the signed body. M2C rejects an unsigned or wrongly-signed200, so always send the bytes the helper returns unchanged. - Decline: HTTP
204, no body, no signature. This is the only unsigned response in the protocol. - Reject the merchant account: HTTP
401with the signedinvalid_merchant_accountbody. Verified by M2C; suppresses future bids for that merchant/vendor pair.
Capturing the raw body
The signature covers the exact bytes M2C sent, so hand the handler the RAW body - a parsed-then-re-serialized object will not reproduce the signed bytes and always fails verification. How you keep the raw bytes depends on your framework:
Express: mount the raw parser on this route before any JSON parser:
app.post('/m2c/bid', express.raw({ type: '*/*', limit: '64kb' }), async (req, res, next) => { try { const result = await handleBidRequest({ rawBody: req.body, headers: req.headers, /* ... */ }); res.set(result.headers ?? {}).status(result.status).send(result.body); } catch (err) { next(err); } });Fastify: register a buffer parser for the bid route's content type:
fastify.addContentTypeParser( 'application/json', { parseAs: 'buffer' }, (_req, body, done) => done(null, body), ); fastify.post('/m2c/bid', async (req, reply) => { const result = await handleBidRequest({ rawBody: req.body as Buffer, headers: req.headers, /* ... */ }); return reply.headers(result.headers ?? {}).code(result.status).send(result.body); });Next.js route handler: read
await req.text()orBuffer.from(await req.arrayBuffer()); do not callawait req.json()first.Node
http: concatenate the request stream into aBufferyourself and pass that.
rawBody accepts a string or Buffer; headers accepts Node's
IncomingHttpHeaders or a WHATWG Headers.
Storing the nonce
You echo the bid's conversionNonce on the conversion report; without a match
M2C rejects the claim (401), so it must be persisted durably and survive a
restart. The recipe above does this directly in price - persist
bid.conversionNonce keyed by bid.requestId before you bid - which is all most
integrations need.
If you'd rather the handler save it for you, pass a nonceStore and it stores the
nonce whenever you bid. Implement the three-method NonceStore interface over your
own durable store; TTL lives in the store, not the interface, so expire entries
after your conversion window:
import { handleBidRequest, type NonceStore } from '@m2c/vendor';
const redisNonces: NonceStore = {
// Allow 7 days for initial reporting + 180 days after completion + a safety margin.
// Increase this if M2C configures a longer reversal window for your integration.
async save(requestId, nonce) { await redis.set(`m2c:nonce:${requestId}`, nonce, 'EX', 60 * 60 * 24 * 190); },
async load(requestId) { return (await redis.get(`m2c:nonce:${requestId}`)) ?? undefined; },
async delete(requestId) { await redis.del(`m2c:nonce:${requestId}`); },
};
await handleBidRequest({ /* ...as above... */ nonceStore: redisNonces });A bundled InMemoryNonceStore implements the same interface for tests and local
runs only - it's process-local and lost on restart, so never reach for it in
production.
The same store can drive conversion reporting with reportStoredConversion:
await reportStoredConversion(m2c, redisNonces, {
requestId,
status: 'completed',
value: 49.99,
transactionId: 'your_txn_id',
});By default it keeps the nonce after completed and refunded so you can report
later partial refunds or a chargeback, and deletes after failed, abandoned,
or chargedback. Pass deleteNonce: 'always' or false to override that
cleanup policy.
Lower-level primitives
When you need finer control - custom status codes, a non-HTTP transport, your own nonce timing - reach past the handler for the two primitives it wraps:
import { verifyBidRequest, buildSignedBidResponse } from '@m2c/vendor';
let bid;
try {
bid = verifyBidRequest(OUTBOUND_KEY, req.body, req.headers); // RAW bytes
} catch {
return res.status(401).end(); // bad/missing signature
}
await db.saveNonce(bid.requestId, bid.conversionNonce); // REQUIRED, before you bid
const { body, headers } = buildSignedBidResponse(OUTBOUND_KEY, {
requestId: bid.requestId, // must echo the incoming id
vendorId: MY_VENDOR_ID,
bidAmount: 2.9, // fee percentage in (0, 15]; above 15 is dropped
checkoutUrl: await createCheckoutSession(bid),
ttl: 300, // seconds; values below 60 are rejected
});
res.set(headers).status(200).send(body);Shop sessions
Use handleSessionBidRequest at the separately registered session endpoint.
It verifies M2C before parsing, then returns a signed shop bid, an unsigned 204
decline, or a signed invalid-merchant 401 keyed by session_id.
const result = await handleSessionBidRequest({
outboundKey: process.env.M2C_OUTBOUND_KEY!,
vendorId: process.env.M2C_VENDOR_ID!,
rawBody,
headers: req.headers,
price: async (session) => ({ bidAmount: 2.9, shopUrl: makeShop(session), ttl: 600 }),
});Persist session.sessionNonce with the session. Report each completed order
with a stable purchaseId; exact retries return the same child request id.
const child = await client.reportSessionPurchase({
sessionId,
purchaseId: order.id,
value: order.total,
currency: order.currency,
transactionId: order.paymentId,
sessionNonce,
});Use child.requestId plus the same session nonce with reportConversion for a
later refund or chargeback. A 409 is purchase_mismatch; a 410 is
session_expired; transient errors retain the normal bounded retry behavior.
Reporting a conversion (you call M2C)
import { M2CVendorClient, M2CConversionError, reportStoredConversion } from '@m2c/vendor';
const m2c = new M2CVendorClient({
baseUrl: 'https://api.m2cmarkets.com', // or http://localhost:8080 in dev
inboundKey: process.env.M2C_INBOUND_KEY!,
outboundKey: process.env.M2C_OUTBOUND_KEY!,
});
try {
await reportStoredConversion(m2c, nonceStore, {
requestId, // the auction id
status: 'completed',
value: 49.99, // optional; informational only
transactionId: 'your_txn_id',
});
} catch (err) {
if (err instanceof M2CConversionError && !err.retryable) {
// 400 / 401 / 422 - permanent. Fix the report; do not retry.
} else {
throw err; // transient failures were already retried and still failed
}
}If you already loaded the nonce yourself, call m2c.reportConversion({ ...,
conversionNonce }) directly.
Persist the complete outcome in your own durable storage before the first
attempt: request identity, nonce, outcome, amount/currency, payment identifier,
and any reversal identifier and incremental amount. reportStoredConversion
loads a nonce; it does not queue the report. A retained nonce alone cannot
recover the original payment outcome after your process restarts.
After an exhausted M2CConversionError with retryable: true, keep the outcome
for a bounded later retry within its original reporting window. Reconstruct the
same report and let the SDK generate fresh signing headers. A lost acknowledgment
can follow a committed report, so retain and replay the original meaning rather
than changing its status or starting another auction. Keep completed-payment
nonces available for later reversals. Surfaced permanent authentication or
contract errors need investigation; retain their evidence and stop automatic
retries. Assign an operator to the retained-report backlog and monitor its oldest
age so exhausted work does not silently cross the reporting deadline.
M2C bills the auctioned transaction value on a completed sale, so value is
optional and informational only. It must not exceed the auctioned transaction
value; a larger value is rejected with the generic 401 conversion claim
rejected, which the SDK surfaces as a permanent error.
Reversals (refunded / chargedback) require reversalValue in the original
auction currency and must land inside the 180-day reversal window.
reversalValue is the amount reversed by this event, not a running total:
the server adds it to every earlier reversal for the auction, and the sum must
not exceed the auctioned transaction value. A chargedback must reverse exactly
the remaining refundable amount. Violations return a permanent 400. A full
reversal zeroes the fee.
reportConversion resolves on success (HTTP 204) and retries transient failures
(429 / any 5xx / network) with exponential backoff, honoring Retry-After when
present. On the production endpoint, the SDK also retries the first 401 for an
initial completed, failed, or abandoned report exactly once within the same
retry budget, after waiting at least one second so the retry has a fresh signing
timestamp. A surfaced 401 is final; reversals and vendor-test reports never get
that bounded retry. Setting maxRetries: 0 disables all internal retries, so
transient responses surface immediately with retryable: true and an initial
401 gets no retry. Surfaced auth and contract failures (400 / 401 / 422) are
permanent.
Each retry re-signs with a fresh timestamp, which is safe: M2C's idempotency keys
on the auction, not the signature, so a re-delivered report collapses to a 204.
Handling refund requests (M2C calls you)
When a merchant asks M2C to refund a completed conversion, M2C POSTs a signed
refund.request to your registered refund_endpoint_url. Verify it exactly like a
bid request, execute the refund at your PSP, then report a refunded conversion (the
same report you'd send for a vendor-initiated refund). That report - not the HTTP
response to this call - is what M2C treats as the authoritative reversal.
The dashboard requires this endpoint to pass a signed diagnostic and admin review before it can receive production refund requests. A replacement remains staged while the currently approved endpoint stays live.
import { handleRefundRequest, reportStoredConversion } from '@m2c/vendor';
// In your refund-endpoint route (capture the RAW body, as with the bid endpoint):
const res = await handleRefundRequest({
outboundKey: OUTBOUND_KEY,
rawBody, // exact bytes M2C sent
headers: req.headers,
refund: async (r) => {
// r.refundRequestId, r.requestId, r.amount, r.currency, r.reason?, r.mustConfirmBy
// Pass refundRequestId as the PSP idempotency key: M2C retries delivery until you
// return 2xx, so this callback can run more than once, and a check-then-act flag
// leaves a window (the refund succeeds, a later line throws, the retry refunds again).
await psp.refund(r.requestId, r.amount, { idempotencyKey: r.refundRequestId });
await reportStoredConversion(m2c, nonceStore, {
requestId: r.requestId,
status: 'refunded',
reversalValue: r.amount, // original auction currency; see "Reporting a conversion"
reversalId: r.refundRequestId,
});
},
});
res.status; // 204 on success, 401 on a bad/missing signature - write it to your responseA few things to get right:
- Make each side effect idempotent on
refundRequestId. M2C retries delivery until your endpoint returns 2xx, so this callback can run more than once. PassrefundRequestIdas your PSP's idempotency key so a retry can't double-refund; userefundRequestIdas the report'sreversalIdso an exact retry is idempotent. The replay comparison covers the auction, status, amount, andtransactionId- repeat all four exactly on a retry, or the report is rejected as a conflict (422) without collapsing later partial refunds. Throwing fromrefundsurfaces as a 5xx, which M2C retries. - Return 2xx only once the work is durable. M2C treats any 2xx as final delivery and
stops re-sending. Do the refund + report inside
refund(as above) so a 2xx means it is done; if you instead accept-and-process-async, persist the request first, or an ack before durable acceptance can strand the request when the async work later fails. - Keep the nonce through partial refunds.
reportStoredConversionpreserves it afterrefundedso later partial refunds can reuse the same conversion nonce. It deletes the nonce after truly terminal statuses such aschargedback.
For finer control, verifyRefundRequest(outboundKey, rawBody, headers) is the
lower-level primitive: it verifies and returns the typed RefundRequest, leaving the
response shaping to you.
CLI
The package ships a small m2c-vendor CLI (also runnable with npx @m2c/vendor)
that wraps reportConversion. Its main use is finishing a test loop's conversion
leg without wiring auto-convert: the dashboard's Run full test loop shows you a
request_id + conversion_nonce, and:
npx @m2c/vendor report-conversion --request-id <uuid> --nonce <64-hex>reports it (defaulting to status=completed against the vendor-test endpoint).
Provide keys via env so they don't land in shell history:
export M2C_INBOUND_KEY=... # your X-API-Key
export M2C_OUTBOUND_KEY=... # your HMAC signing key
export M2C_BASE_URL=https://api.m2cmarkets.com # this is the defaultThe CLI also accepts the aliases INBOUND_KEY, OUTBOUND_KEY, and
BID_SERVER_URL in addition to the M2C_* names above, and it loads a .env
file from the current directory before reading env values.
Flags: --status (completed | failed | abandoned | refunded | chargedback),
--value, --reversal-value, --transaction-id, --endpoint
(vendor-test | production), and --base-url / --inbound-key / --outbound-key
to override the env. Run npx @m2c/vendor --help for the full list. It signs and
posts exactly like reportConversion - it's that call wrapped for convenience.
Error handling
All SDK errors extend M2CError, so one catch (e) { if (e instanceof M2CError) }
covers everything.
M2CSignatureError(re-exported from@m2c/core) - fromverifyBidRequestwhen the inbound signature can't be trusted.reasonismissing|incomplete|malformed|timestamp_skew|mismatch|empty_secret. A malformed-but-present signature is a tampering signal, kept distinct from a fully-absent one - never treat them the same.M2CConversionError- fromreportConversionon a non-204 (or network) failure. Branch oncode(bad_request|unauthorized|unprocessable|rate_limited|server_error|unavailable|unknown) or theretryableflag.statusis0when no HTTP response was received.unprocessable(422) is deliberately cause-neutral: the server returns it both for an expired reversal window and for a billing period that has already been archived - the latter needs a support escalation, not a write-off.reportStoredConversionthrows plainM2CErrorbefore any network call when the nonce store has no entry for the request.M2CError- invalid input (a bad bid, a malformed report) caught before any network call, and a verified-but-malformed bid-request body.
What this SDK does not do
The crypto is the small, dangerous part; the SDK owns it. The rest of the integration is yours and an SDK can't abstract it:
- Host your bid endpoint. You stand up the HTTPS server M2C calls; it must be publicly reachable and pass M2C's SSRF validation at registration.
- Store the conversion nonce durably. Persist it keyed by
requestId- in yourpricecallback, or via anonceStoreyou back with a durable store - so it survives restarts and is present at conversion time. - Price your bid or map your payment lifecycle to the M2C status set.
- Meet the latency budget. Respond to a bid request quickly; M2C caps the response window tightly and drops slow vendors.
