# @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@0.12.0
```

## Quick start

Version 0.11.0 requires refund responses containing `requested_at` and
`must_confirm_by`. Deploy the updated bid server and retire older serving
revisions before upgrading. See [CHANGELOG.md](CHANGELOG.md#0110---2026-10-05).

```ts
import { M2CClient } from '@m2c/server';

const m2c = new M2CClient({
  secretKey: process.env.M2C_SECRET_KEY!,
});

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',
}, { idempotencyKey: order.checkoutKey }); // persist once per checkout attempt

// Send auction.winner.checkoutUrl to the client to open. Persist auction.requestId
// against your order so you can correlate the conversion webhook later.
```

`order` is loaded from your authenticated application's database. Persist the
price, request parameters and checkout key before the first call and reuse them
unchanged on retries, including customer metadata. The examples below use
application-owned fulfillment helpers; the SDK verifies and dispatches events
but does not store orders or make fulfillment idempotent for you.

## Catalog pricing

Secret keys can use your publisher-defined product ID instead of a raw amount:

```ts
const auction = await m2c.createAuction({
  productId: 'gems_100',
  quantity: 2,
  currency: 'USD',
  customerIp: req.ip,
  reference: order.id,
  successUrl: 'https://store.example.com/thanks',
  cancelUrl: 'https://store.example.com/cart',
}, { idempotencyKey: order.checkoutKey });
```

Create the product in Dashboard > Products for the account that owns the key.
The catalog is shared across live and test mode. Quantity defaults to 1 and must
respect the product's configured limit. Currency must be uppercase; omit it only
when the product has a single active currency. M2C resolves the amount and
description and includes the immutable purchase in the signed conversion webhook
as `catalogPurchase`.

Choose either `productId` (with optional `quantity`) or `transactionValue` (with
optional `description`). Combining the two, overriding a catalog description, or
supplying quantity without a product is rejected. Raw pricing remains available
for custom amounts and backend-calculated prices. Publishable-key SDKs always
require a catalog product. Deploy the bid server with secret-key catalog support
before using this form.

## Configuration

`new M2CClient(options)` accepts:

| Option | Type | Default | Notes |
|---|---|---|---|
| `secretKey` | `string` | (required) | Your `sec_...` key. Server-side only. |
| `baseUrl` | `string` | `https://api.m2cmarkets.com` | The production API host is baked in; you normally leave this unset. Override only to point at a mock or non-production endpoint when testing. A trailing slash is trimmed. |
| `timeoutMs` | `number` | `10000` | Per-request timeout. On expiry the call throws `M2CApiError` with `code: 'unavailable'` and `status: 0`. |
| `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:

```ts
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:

```ts
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: async (event) => {
      // Sandbox conversions arrive at this same URL with the signed test flag
      // set - never fulfill real goods for them.
      if (event.test) return;
      // Your application helper must correlate the saved order and commit
      // event identity, sequence and fulfillment/reversal in one transaction.
      // Await the commit; throw on failure so delivery can retry.
      await applyConversionInTransaction(event);
    },
  });
  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:

  ```ts
  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:

  ```ts
  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:

```ts
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()
```

## Requesting a refund

Ask M2C to start a refund for a completed conversion. M2C relays a signed request to
the winning vendor, which executes it at its PSP and confirms back on the conversion
webhook (`status: 'refunded'`) - that webhook, not this call, is the authoritative
refund signal. You may request multiple partial refunds until their cumulative value
reaches the original refundable total.

```ts
const refund = await m2c.requestRefund(requestId, {
  amount: 9.99, // major units in the conversion's currency; > 0 and <= the original total
  reason: 'requested_by_customer', // optional reason code, relayed to the vendor
  idempotencyKey: 'refund-order-1234', // required; a retry with the same key replays the same request
});
// refund.refundRequestId - correlate with the eventual webhook
// refund.status - 'pending' on first accept, or the existing status on an idempotent replay
// refund.requestedAt - original request timestamp, unchanged on replay
// refund.mustConfirmBy - fixed deadline, or null for unknown historical deadlines
```

Requires a **secret** key (a publishable key is rejected). The amount is shape-checked
locally; the refundable total is enforced server-side. Failures throw `M2CApiError`
with a stable `code` (see [Error handling](#error-handling)): `404` (`not_found` -
conversion not found or not yours, distinct from the auction's `no_winner`), `409`
(`conflict` - idempotency-key mismatch, not refundable, or the vendor has no refund
endpoint), `422` (`unprocessable` - refund window expired or billing period
archived), and `400` (`bad_request` - missing/invalid idempotency key or invalid amount). None are retryable.

## 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_eligible_vendors` | 404 | No eligible vendor is linked. Check account configuration. |
| `no_bids` / `no_valid_bids` | 404 | No usable checkout was returned. Offer a customer-visible retry or another payment method. |
| `no_winner` | 404 | Auction 404 without a more specific code. |
| `not_found` | 404 | `requestRefund` only: the conversion doesn't exist or isn't yours. (The auction route uses `no_winner` for 404.) |
| `auction_in_progress` | 409 | Retry the same key and body after `Retry-After`. |
| `idempotency_mismatch` | 422 | Same key, different body. Fix the caller; do not blindly switch keys. |
| `conflict` / `unprocessable` | 409 / 422 | Other conflicts or policy limits, including refund state/window restrictions. |
| `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 `auction_in_progress`, 429, any 5xx, and network/timeout failures.

For retryable errors, preserve both key and body, honor `retryAfterSeconds`, and use bounded exponential backoff with jitter (for example at most three attempts, waiting at least 1, 2, then 4 seconds). After that, keep the order pending for reconciliation. Never silently create a new checkout or switch vendors after a timeout: the first attempt may have succeeded. Successful auction results are replayable for 24 hours; do not blindly retry after that window or reopen an expired checkout URL.

`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 owns its bounded transient retry behavior. Conversion reports remain idempotent on `request_id` for initial outcomes or `reversalId` for refunds and chargebacks.

```ts
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 && ['no_winner', 'no_eligible_vendors', 'no_bids', 'no_valid_bids'].includes(err.code)) {
    // No vendor available - show an alternative path, don't treat as a crash.
  } 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. |
| `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`:

For publishable catalog checkouts, `event.catalogPurchase.productId` is the publisher's own product ID, such as `gems_100`. The snapshot also contains `productName`, `quantity`, `currency`, `unitAmountMicrocents`, and `totalAmountMicrocents`. One major currency unit is 1,000,000 microcents. Use the verified snapshot, conversion status and live/test flag when fulfilling; price edits do not alter earlier purchases. Generated product and price IDs are not part of this contract.

| 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. |
| `eventSequence` | `number?` | Monotonic per-auction transition order on current live deliveries. Compare and advance it in the same transaction as fulfillment or reversal state. Absent on sandbox and legacy queued deliveries. |
| `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. |
| `sessionId` | `string?` | Shop-session identifier for a session purchase or its reversal. Use with `purchaseId` for fulfillment correlation. |
| `purchaseId` | `string?` | Vendor-supplied idempotent purchase identifier for a session purchase or its reversal. |
| `value` | `number?` | Converted amount in the original auction currency, when applicable. |
| `reversalId` | `string?` | Stable identifier for this refund or chargeback event. |
| `reversalValue` | `number?` | Incremental reversed amount in the original auction currency. |
| `cumulativeReversalValue` | `number?` | Total reversed after this event. |
| `remainingRefundableValue` | `number?` | Amount still available for later reversals. |

`requestRefund(requestId, params)` takes `RequestRefundParams` and returns `RefundRequestResult`:

| Field | Type | Notes |
|---|---|---|
| `amount` (param) | `number` | Refund amount in major units. `> 0` and `<=` the refundable total. |
| `reason` (param) | `string?` | Optional reason code: `requested_by_customer`, `duplicate`, `fraudulent`, `not_received`, or `other`. Any other value returns `400`. |
| `idempotencyKey` (param) | `string` | Required 1-255 printable-ASCII key; the same key replays the same request. |
| `refundRequestId` (result) | `string` | M2C's id for the request; correlate it with the eventual `refunded` webhook. |
| `status` (result) | `string` | `pending` \| `sent` \| `confirmed` \| `failed` \| `rejected`. `failed` means delivery is uncertain; `rejected` means delivery was definitively not accepted, including when no vendor target was available. Advisory; the webhook is authoritative. |
| `cumulativeReversalValue` (result) | `number` | Total value already reversed. |
| `remainingRefundableValue` (result) | `number` | Unreserved value still available for another refund request. |
| `requestedAt` (result) | `string` | Original request timestamp; unchanged on idempotent replay. |
| `mustConfirmBy` (result) | `string \| null` | Fixed confirmation deadline, capped by the original reversal cutoff. `null` preserves an unknown historical deadline. |

An elapsed deadline or a `failed` relay is not proof of nonexecution. The
request can remain reserved while the vendor outcome is uncertain. Keep its
stable `refundRequestId` for reconciliation; the signed conversion webhook
remains the authority for a completed refund.

Money is represented in decimal major units at this SDK's surface (for example,
dollars for USD or euros for EUR).
