@m2c/vendor
v0.2.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.
Requires Node 18+ (built-in fetch and node:crypto).
Your two secrets
| 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. An unsigned or wrongly-signed200is silently dropped by M2C'sBID_RESPONSE_VERIFY- you'd see "no wins" with no error, 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 = {
// TTL must span your reversal window - refunds re-check the nonce (here ~180 days).
async save(requestId, nonce) { await redis.set(`m2c:nonce:${requestId}`, nonce, 'EX', 60 * 60 * 24 * 180); },
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 so you can report a later refund
or chargeback, and deletes after failed, abandoned, refunded, 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
checkoutUrl: await createCheckoutSession(bid),
ttl: 300, // seconds; values below 60 are rejected
});
res.set(headers).status(200).send(body);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.
M2C bills the auctioned transaction value on a completed sale, so value is
optional and informational only. Reversals (refunded / chargedback) require
reversalValue (in the original auction currency, > 0 and <= the auctioned
transaction value; a full reversal zeroes the fee) and must land inside the
180-day reversal window.
reportConversion resolves on success (HTTP 204) and retries transient failures
(429 / any 5xx / network) with exponential backoff, honoring Retry-After
when present. 502 and 504 cover the load balancer or Cloud Run front returning a
gateway error on a cold start, instance recycle, or upstream timeout - the report
is still deliverable. A bare 500 is retried too: the conversion handler returns
it for transient internal faults, and dropping the report could lose a billable
conversion. Auth and contract failures (400 / 401 / 422) are permanent and never
retried.
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.
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.your-m2c-host # default http://127.0.0.1:8080When running from sdk/examples, the CLI also accepts the example env aliases:
INBOUND_KEY, OUTBOUND_KEY, and BID_SERVER_URL. It loads a .env file from
the current directory before reading env values, so the examples' .env works
when you run the command from sdk/examples.
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.
Development
npm install # from the sdk/ workspace root
npm run build
npm test # hermetic; no network or DBStatus
Draft (0.1.0), tracking the vendor contract. Built on
@m2c/core; see model/bid.go, server/vendor/, and the
vendor integration section of the root README.md for the underlying wire
contract, and ../DESIGN.md for the suite status.
