npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

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 your NonceStore, 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 200 with the signed body. An unsigned or wrongly-signed 200 is silently dropped by M2C's BID_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 401 with the signed invalid_merchant_account body. 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() or Buffer.from(await req.arrayBuffer()); do not call await req.json() first.

  • Node http: concatenate the request stream into a Buffer yourself 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:8080

When 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) - from verifyBidRequest when the inbound signature can't be trusted. reason is missing | 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 - from reportConversion on a non-204 (or network) failure. Branch on code (bad_request | unauthorized | unprocessable | rate_limited | server_error | unavailable | unknown) or the retryable flag. status is 0 when 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.
  • reportStoredConversion throws plain M2CError before 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 your price callback, or via a nonceStore you 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 DB

Status

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.