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.11.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.
  • handleRefundRequest(...) / verifyRefundRequest(...) - handle an M2C-initiated refund: verify the signature, get a typed RefundRequest, execute it at your PSP, then report the result as a refunded conversion.

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 200 with the signed body. M2C rejects an unsigned or wrongly-signed 200, 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 = {
  // 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 response

A 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. Pass refundRequestId as your PSP's idempotency key so a retry can't double-refund; use refundRequestId as the report's reversalId so an exact retry is idempotent. The replay comparison covers the auction, status, amount, and transactionId - repeat all four exactly on a retry, or the report is rejected as a conflict (422) without collapsing later partial refunds. Throwing from refund surfaces 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. reportStoredConversion preserves it after refunded so later partial refunds can reuse the same conversion nonce. It deletes the nonce after truly terminal statuses such as chargedback.

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 default

The 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) - 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.