JSPM

  • ESM via JSPM
  • ES Module Entrypoint
  • Export Map
  • Keywords
  • License
  • Repository URL
  • TypeScript Types
  • README
  • Created
  • Published
  • Downloads 41
  • Score
    100M100P100Q85832F
  • License MIT

Server-side Node/TypeScript SDK for the M2C payment-vendor auction API.

Package Exports

  • @m2c/server

Readme

@m2c/server

Server-side Node / TypeScript SDK for the M2C payment-vendor auction API.

It keeps the security-sensitive parts off your plate:

  • createAuction(...) - run an auction and get the winning vendor's hosted checkout URL.
  • handleConversionWebhook(...) - verify and dispatch the signed conversion webhook M2C delivers when a payment's status changes, in one call.
  • verifyConversionWebhook(...) - the lower-level verify-and-parse primitive the handler wraps, for when you want to own the dispatch and response yourself.

This is the secret-key SDK. Your secret key must never ship in a browser or mobile app; for client-side auctions use a publishable key and the mobile / web SDKs.

Requires Node 18+ (uses the built-in fetch and node:crypto).

Install

npm install @m2c/server

Quick start

import { M2CClient } from '@m2c/server';

const m2c = new M2CClient({
  secretKey: process.env.M2C_SECRET_KEY!,
  baseUrl: 'https://api.m2cmarkets.com', // the production API host; use http://localhost:8080 in dev
});

const auction = await m2c.createAuction({
  transactionValue: 49.99, // major units of currency
  currency: 'USD',
  customerIp: req.ip,      // the END USER's IP - see "Calling on behalf of a device"
  reference: order.id,     // echoed back in the conversion webhook
  successUrl: 'https://store.example.com/thanks',
  cancelUrl: 'https://store.example.com/cart',
  description: '100 Gems',
});

// Send auction.winner.checkoutUrl to the client to open. Persist auction.requestId
// against your order so you can correlate the conversion webhook later.

Configuration

new M2CClient(options) accepts:

Option Type Default Notes
secretKey string (required) Your sec_... key. Server-side only.
baseUrl string https://api.m2cmarkets.com Defaults to the production API host; override with http://localhost:8080 for local dev. A trailing slash is trimmed.
timeoutMs number 10000 Per-request timeout. On expiry createAuction throws M2CError.
fetch typeof fetch global fetch Override for testing or a custom HTTP stack. Requires Node 18+ otherwise.

Calling on behalf of a device

When your backend runs the auction for a mobile or web client, the connection IP M2C sees is your server's, not the user's. Secret keys derive geo from the customerIp you pass, so forward the real device IP:

await m2c.createAuction({ transactionValue: 9.99, customerIp: endUserIp });

You can also attach optional checkout-context metadata on createAuction: platform (web | webgl | ios | android | desktop) and deviceType. Both are recorded on the auction, forwarded to bidding vendors, and echoed back on the conversion webhook as event.platform / event.deviceType. Metadata only - never auth or fulfillment. (The client SDKs, @m2c/checkout and the Unity SDK, auto-detect and send these; on the server you pass whatever your request context knows.)

Fulfillment: act on the webhook, not the redirect

The customer being redirected back to your successUrl is a UX event, not proof of payment - it can be dropped or spoofed. Grant value only when you receive a verified completed conversion webhook. Correlate via requestId (or your reference).

handleConversionWebhook verifies the signature over the raw body and dispatches the verified event to your onEvent callback, returning the status + body to write back:

import { handleConversionWebhook } from '@m2c/server';

// Express example. You MUST verify against the RAW body bytes - capture them
// before any JSON parser rewrites them (see "Capturing the raw body"):
//   app.post('/webhooks/m2c', express.raw({ type: '*/*' }), handler)
async function handler(req, res) {
  const { status, body } = await handleConversionWebhook({
    secret: process.env.M2C_WEBHOOK_SECRET!,
    rawBody: req.body,    // raw Buffer / string, NOT a parsed object
    headers: req.headers,
    onEvent: (event) => {
      // Sandbox conversions arrive at this same URL with the signed test flag
      // set - never fulfill real goods for them.
      if (event.test) return;
      if (event.status === 'completed') {
        fulfill(event.reference ?? event.requestId, event.value);
      } else if (event.status === 'refunded' || event.status === 'chargedback') {
        reverse(event.requestId, event.reversalValue); // reversals carry reversalValue
      }
    },
  });
  res.status(status).send(body); // 204 on success, 400 on a bad/missing signature
}

The handler returns 400 (and does NOT call onEvent) on a bad or missing signature, and 204 after onEvent resolves. An empty secret is local configuration failure and still throws. Throw from onEvent to signal a transient failure: the throw propagates, so your route returns 5xx and M2C retries with backoff, then dead-letters.

Capturing the raw body

The signature covers timestamp + "\n" + rawBody. A re-serialized JSON object will not reproduce the exact signed bytes (key order, whitespace), so passing a parsed object always fails verification - hand the verifier the original request body. How you keep the raw bytes depends on your framework:

  • Express: mount the raw parser on this route before any JSON parser:

    app.post('/webhooks/m2c', express.raw({ type: '*/*', limit: '64kb' }), async (req, res, next) => {
      try {
        const result = await handleConversionWebhook({ rawBody: req.body, headers: req.headers, /* ... */ });
        res.status(result.status).send(result.body);
      } catch (err) {
        next(err);
      }
    });
  • Fastify: register a buffer parser for the webhook content type:

    fastify.addContentTypeParser(
      'application/json',
      { parseAs: 'buffer' },
      (_req, body, done) => done(null, body),
    );
    fastify.post('/webhooks/m2c', async (req, reply) => {
      const result = await handleConversionWebhook({ rawBody: req.body as Buffer, headers: req.headers, /* ... */ });
      return reply.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.

Lower-level: verifyConversionWebhook

When you want to own the dispatch and the response yourself, call the primitive the handler wraps. It verifies and parses, throwing M2CSignatureError on an untrusted delivery and plain M2CError on an authentic-but-off-contract payload:

import { verifyConversionWebhook, M2CSignatureError } from '@m2c/server';

let event;
try {
  event = verifyConversionWebhook(process.env.M2C_WEBHOOK_SECRET!, req.body, req.headers);
} catch (err) {
  if (err instanceof M2CSignatureError) {
    return res.status(400).json({ error: 'invalid signature' }); // untrusted - do not act
  }
  throw err;
}
// ... branch on event.test / event.status, then res.status(204).end()

Error handling

Invalid input (e.g. transactionValue out of range) throws M2CError before any network round-trip. All SDK errors extend M2CError, so a single catch (err) { if (err instanceof M2CError) ... } covers everything.

createAuction throws M2CApiError (a subclass) on a non-2xx response or a network/timeout failure, with a stable code so you can branch without matching message strings:

code HTTP Meaning
bad_request 400 Invalid parameters.
unauthorized 401 Missing or invalid key.
forbidden 403 Origin/redirect not allowed, or account suspended.
no_winner 404 No vendor won (no links, no bids, or no valid bids). Expected outcome.
conflict 409 Idempotent auction with the same key is still running.
unprocessable 422 Idempotency key reused with a different request body.
rate_limited 429 Slow down; see error.retryAfterSeconds.
server_error 500 (or other non-gateway 5xx) Internal error; may be a transient fault.
unavailable 502/503/504, or 0 Transient: gateway error, cold start, or a network/timeout failure with no HTTP response (status is 0). See error.retryAfterSeconds.

error.retryable is true for 429, any 5xx, and network/timeout failures.

A 409 conflict is deliberately not retryable: the idempotent auction for that key is still running, so an automated retry loop keyed on error.retryable would only hammer it. Treat 409 as caller-driven backoff - wait briefly, then retry (or poll) with the same idempotencyKey, which replays the original auction's outcome once it settles.

createAuction does not retry internally - it surfaces the M2CApiError with retryable set and leaves the decision to you. Without an idempotencyKey, a retry starts a fresh, separately billed auction, so only retry bare calls when running a new auction is acceptable. Pass an idempotencyKey (as below) and a retry replays the original auction's outcome instead. This is deliberately different from @m2c/vendor's reportConversion, which is idempotent on request_id and does retry transient failures for you.

import { randomUUID } from 'node:crypto';
import { M2CApiError } from '@m2c/server';

// One key per logical checkout, reused across retries: the server replays the
// original auction outcome instead of running a fresh, separately billed
// auction with a potentially different winner.
const idempotencyKey = randomUUID();
try {
  await m2c.createAuction({ transactionValue: 49.99 }, { idempotencyKey });
} catch (err) {
  if (err instanceof M2CApiError && err.code === 'no_winner') {
    // No vendor available - show an alternative path, don't treat as a crash.
  } else if (err instanceof M2CApiError && err.code === 'conflict') {
    // Auction for this key is still running - wait briefly, then retry or
    // poll with the SAME idempotencyKey. Intentionally not err.retryable.
  } else if (err instanceof M2CApiError && err.retryable) {
    // back off and retry with the SAME idempotencyKey
  } else {
    throw err;
  }
}

verifyConversionWebhook throws M2CSignatureError with a reason (missing | incomplete | malformed | timestamp_skew | mismatch | empty_secret). A malformed-but-present signature is a tampering signal, kept distinct from a fully-absent one.

If the signature verifies but the payload doesn't match the conversion contract (bad JSON, unknown status, value/reversal rule violation), it throws plain M2CError instead: the delivery is authentic, so treat it as contract drift to investigate, not tampering. Alerting on M2CSignatureError alone will therefore never page on benign contract evolution.

handleConversionWebhook folds the first case into a 400 response for you and lets the second (the authentic-but-off-contract M2CError) propagate, so it surfaces as a 5xx you can alert on rather than a silent 204.

Reference: data shapes

The SDK presents all fields in camelCase; the API's snake_case wire format is mapped for you in both directions.

createAuction(params) returns AuctionResult:

Field Type Notes
winner.vendorId string Winning vendor's id.
winner.checkoutUrl string Hosted checkout URL to open. Time-boxed by ttl.
winner.ttl number Seconds the checkout URL stays valid.
clearingRate number Winning fee rate as a percentage (e.g. 2.9 = 2.9%).
feeProceeds number Fee charged on the transaction, in the auction currency.
m2cFee number M2C commission, in the auction currency.
bidCount number Number of valid bids received.
requestId string Correlate with the conversion webhook.
latencyMs number Auction latency in milliseconds.

verifyConversionWebhook(...) returns a verified ConversionEvent:

Field Type Notes
event 'conversion' Event-type discriminator.
requestId string Matches the auction's requestId. Primary correlation key.
status ConversionStatus completed | failed | abandoned | refunded | chargedback.
vendor string Winning vendor's id.
transactionId string Vendor's transaction id (may be empty).
timestamp string RFC 3339 UTC emit time. Distinct from the signing-timestamp header.
deliveryId string? Present when a delivery ledger row exists; stable across retries.
reference string? Your auction reference, echoed back when set.
platform string? Checkout surface attached from the auction row when known. Metadata only; do not gate fulfillment on it.
deviceType string? Coarse device form factor attached from the auction row when present. Metadata only.
value number? Converted amount in the original auction currency, when applicable.
reversalValue number? Reversed amount in the original auction currency, for refunds/chargebacks.

Money is represented in decimal major units at this SDK's surface (for example, dollars for USD or euros for EUR). See ../openapi.yaml for the underlying wire contract.

Development

npm install
npm run typecheck
npm test       # hermetic; the webhook tests need no network or DB
npm run build

Status

Draft (0.1.0), tracking the API in this monorepo; see ../openapi.yaml for the underlying contract and ../DESIGN.md for the suite status.