Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/request-pricing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@inflowpayai/x402-seller': minor
'@inflowpayai/mpp-seller': minor
---

Support request-time pricing in seller helpers, including configured currency selection and synchronous or asynchronous
callbacks. Preserve fixed-price integrations and delegate payment processing to the existing upstream handlers.
7 changes: 7 additions & 0 deletions docs/mpp/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ that encrypted payload. The seller checks the credential against its challenge a
This is the exact analog of the x402 facilitator boundary documented in
[../x402/architecture.md](../x402/architecture.md).

For request-time `inflow/charge` prices, pass a synchronous or asynchronous callback to `inflowCharges` or
`inflowChargesNodeListener` instead of a fixed price list. The callback receives a Web `Request` or Node
`IncomingMessage`, respectively, and returns the same `{ amount, currency }` entries. It runs once per request,
including paid retries. Keep the price stable for the same purchase: a credential for a different amount or currency
does not authorize the request. Configuration lookup, rail selection, challenge binding, and receipt handling remain
with the existing helpers and method. See the [Express example](../../examples/mpp-seller-express/src/index.ts).

## Package layering

```
Expand Down
28 changes: 24 additions & 4 deletions docs/x402/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ resolution via declaration order. InFlow plugs into that with these factories an
| `createUnauthenticatedInflowFacilitator` | foundation `FacilitatorClient` | same — for facilitator-only deployments |
| `createInflowSellerClient` | `InflowSellerClient` | drives `inflowAccepts` |
| `inflowAccepts(client, options)` | foundation `PaymentOption[]` | a route's `accepts` field in `RoutesConfig` |
| `inflowRoute(client, options)` | foundation `RouteConfig` | a route's offers and token-gated sponsorship declarations |
| `inflowRoute(client, options)` | `RouteConfig` or route resolver | a route's offers and token-gated sponsorship declarations |
| `inflowSchemeRegistrations(client)` | `Promise<SchemeRegistration[]>` | a foundation adapter's `schemes` argument — the foundation refuses to boot without these registrations |

The buyer side ships `InflowClient`, a subclass of the foundation's `x402Client`. The buyer composes by passing the
Expand Down Expand Up @@ -83,8 +83,26 @@ buyer foundation middleware InFlow facilitator

The seller fetches `/v1/x402/config` once at startup (via `createInflowSellerClient`), expands it into `PaymentOption[]`
via `inflowAccepts`, and hands the result to the foundation middleware in each route's `accepts` field. The foundation
middleware never calls `/v1/x402/config` itself — it consumes `PaymentOption[]` shapes that already have `payTo`,
`asset`, and atomic `amount` resolved.
middleware never calls `/v1/x402/config` itself. Fixed prices have `payTo`, `asset`, and atomic `amount` resolved at
startup. Request-time route callbacks call the same helpers with the price for the current purchase.

`createInflowHTTPResourceServer(server, routes)` accepts fixed route objects and callbacks that receive the foundation's
`HTTPRequestContext` and return a concrete `RouteConfig`. Each matching callback runs once per request, before HTTP
protected-request hooks receive the resolved route. It can await pricing, call `inflowAccepts`, and filter, reorder, or
edit the resulting offers. The HTTP server uses upstream routing and delegates payment processing to a request-local
upstream HTTP server. The shared resource server retains its registered schemes, facilitators, and hooks. Settlement
uses the same resolved route as verification. Initialization is shared; a failed initialization can be retried. Callback
routes are validated at request time. Pricing errors and empty resolved offer lists stop the request before
verification, settlement, or the protected handler. See the
[request-priced Express example](../../examples/x402-seller-express/src/index.ts).

Callbacks run again on paid retries; keep the price stable for the same purchase. Fixed prices remain compatible with
`paymentMiddlewareFromConfig`. Other facilitators and fixed options can share the resource server. For body-based
pricing, Express needs its body parser before payment middleware. Hono and Next provide body access through their
request adapters. Fastify's payment middleware runs before body parsing; use query, path, or header inputs there. For
Fastify, import `x402ResourceServer` from `@x402/fastify` and pass `{ resourceServer: x402ResourceServer }` to
`createInflowResourceServer`. Likewise pass its `x402HTTPResourceServer` as the `httpServer` option to
`createInflowHTTPResourceServer`. This keeps the returned servers compatible with the adapter's own x402 core version.

## Buyer side — request lifecycle

Expand Down Expand Up @@ -184,7 +202,9 @@ Permit2 settlement operation; the facilitator broadcasts only at settlement. Del
failure.

`inflowRoute`'s optional `assetTransferMethod: 'permit2'` selects configured Permit2 alternatives without changing
ordinary offers. `FacilitatorClient.getSupported().extensions` advertises capability names, not route declarations.
ordinary offers. `FacilitatorClient.getSupported().extensions` advertises capability names, not route declarations. With
a price callback, `inflowRoute` returns a route resolver after capturing seller configuration and facilitator support
during setup. Sponsorship uses each request's resolved offers and the same capability checks as fixed prices.

## `inflowSchemeRegistrations`

Expand Down
27 changes: 14 additions & 13 deletions examples/mpp-seller-express/README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Example — MPP seller on Express

A minimal Express app that accepts MPP payments via InFlow. Uses `mppx`'s own Express adapter (`Mppx` from
`mppx/express`) with InFlow's `inflow` seller method. `Mppx.create` mints and HMAC-binds the challenge locally with
A minimal Express app that accepts MPP payments via InFlow. Uses `inflowChargesNodeListener` with request-time pricing
and InFlow's `inflow` seller method on `mppx/server`. `Mppx.create` mints and HMAC-binds the challenge locally with
`secretKey`; the `inflow` method's `verify` redeems and settles through the InFlow PSP.

## Prerequisite
Expand Down Expand Up @@ -52,17 +52,18 @@ seller must use the same environment. Send API keys only to an InFlow API you tr

The server listens on `http://localhost:3000` and serves these routes:

| Route | Price | Notes |
| -------------------- | -------------------------- | ------------------------------------------------------------------------------- |
| `GET /api/widgets` | `0.01 USDC` | Single currency via the Express adapter's `charge`. Crypto → `balance` rail. |
| `POST /api/upload` | `0.10 USDC` | Single currency via `charge`. Crypto → `balance` rail. |
| `GET /api/subscribe` | `1.00 USDC` monthly | Recurring subscription on the `balance` rail. |
| `GET /api/checkout` | `1.0 USD` or `0.0095 USDC` | Multi-currency: one challenge per price (USD → `instrument`, USDC → `balance`). |
| `GET /free` | — | Not gated; passes through. |

The Express adapter (`mppx/express`) exposes only the single-currency `charge` — it strips `compose`. The multi-currency
`GET /api/checkout` and `GET /api/subscribe` therefore use core `mppx/server` instances, bridged into Express with
InFlow's Node listener helpers.
| Route | Price | Notes |
| -------------------- | --------------------------- | ------------------------------------------------------------------------------ |
| `GET /api/widgets` | `0.01` or `0.05 USDC` | `?bundle=large` selects the larger bundle; balance rail. |
| `POST /api/upload` | `0.10` or `0.20 USDC` | `?priority=high` selects priority processing; balance rail. |
| `GET /api/subscribe` | `1.00 USDC` monthly | Recurring subscription on the `balance` rail. |
| `GET /api/checkout` | `1.00 USD` or `0.0095 USDC` | `?bundle=large` doubles both prices (USD → `instrument`, USDC → `balance`). |
| `GET /api/search` | Request-priced USD or USDC | `?detail=full` selects USD 1.00 or USDC 0.25; otherwise USD 0.50 or USDC 0.10. |
| `GET /free` | — | Not gated; passes through. |

Each charge callback reads the request and returns the seller's prices; it does not accept a buyer-supplied amount. The
helper handles both single- and multi-currency routes. Prices must remain stable between the unpaid request and its paid
retry. Fixed price lists are also supported. Subscriptions use a separate helper with fixed plan terms.

Hit it with the matching buyer example or any other MPP client:

Expand Down
61 changes: 40 additions & 21 deletions examples/mpp-seller-express/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import 'dotenv/config';
import express from 'express';
import { Mppx } from 'mppx/express';
import { Mppx as MppxServer } from 'mppx/server';
import {
inflow,
Expand All @@ -16,22 +15,13 @@ if (apiKey === undefined || apiKey === '') {
}
const baseUrl = process.env['INFLOW_BASE_URL'];

// Build the `inflow` method (which primes `GET /v1/mpp/config` once) and the binding `secretKey` a single time, then
// stand up two instances over them — they expose different APIs:
// - `mppx` (mppx/express) wraps each method as an Express `RequestHandler` for ergonomic single-currency routes,
// but the framework adapters strip `compose`.
// - `core` (mppx/server) keeps `compose(...)`, which the multi-currency route below needs; its handlers are
// Web-fetch functions, bridged into Express with InFlow's `inflowChargesNodeListener` helper.
// `Mppx.create` only reads its argument, so sharing the method object is safe and avoids a second `/config` fetch.
// The `methods` array stays inline in each call so mppx can infer the precise method tuple (a hoisted config object
// would widen it and drop the typed handlers).
// One core instance handles single- and multi-currency prices through the Node listener helper.
const method = inflow({
apiKey,
environment: 'sandbox',
...(baseUrl === undefined || baseUrl === '' ? {} : { baseUrl }),
});
const secretKey = process.env['MPP_SECRET_KEY'];
const mppx = Mppx.create({ methods: [method], secretKey, transport: paymentHttpTransport() });
const core = MppxServer.create({ methods: [method], secretKey, transport: paymentHttpTransport() });
const subscriptionMethod = inflow.subscription({
apiKey,
Expand All @@ -47,12 +37,24 @@ const subscriptionCore = MppxServer.create({
const app = express();
app.use(express.json());

// The rail is derived from the charge currency (crypto USDC → `balance`); the buyer never chooses it.
app.get('/api/widgets', mppx.charge({ amount: '0.01', currency: 'USDC' }), (_req, res) => {
// Prices come from the seller's price list, not an amount supplied by the buyer.
const widgets = inflowChargesNodeListener(core, (request) => {
const bundle = new URL(request.url ?? '/', 'http://localhost').searchParams.get('bundle');
return [{ amount: bundle === 'large' ? '0.05' : '0.01', currency: 'USDC' }];
});
app.get('/api/widgets', async (req, res) => {
const result = await widgets(req, res);
if (result.status === 402) return;
res.json({ widgets: [1, 2, 3] });
});

app.post('/api/upload', mppx.charge({ amount: '0.10', currency: 'USDC' }), (_req, res) => {
const upload = inflowChargesNodeListener(core, (request) => {
const priority = new URL(request.url ?? '/', 'http://localhost').searchParams.get('priority');
return [{ amount: priority === 'high' ? '0.20' : '0.10', currency: 'USDC' }];
});
app.post('/api/upload', async (req, res) => {
const result = await upload(req, res);
if (result.status === 402) return;
res.json({ status: 'received' });
});

Expand All @@ -74,10 +76,13 @@ app.get('/api/subscribe', async (req, res) => {

// Multi-currency: one WWW-Authenticate challenge per price. USD settles on the `instrument` rail, USDC on `balance`;
// the buyer picks one. `inflowChargesNodeListener` returns a Node listener, mountable directly in an Express route.
const checkout = inflowChargesNodeListener(core, [
{ amount: '1.0', currency: 'USD' },
{ amount: '0.0095', currency: 'USDC' },
]);
const checkout = inflowChargesNodeListener(core, (request) => {
const bundle = new URL(request.url ?? '/', 'http://localhost').searchParams.get('bundle');
return [
{ amount: bundle === 'large' ? '2.00' : '1.00', currency: 'USD' },
{ amount: bundle === 'large' ? '0.019' : '0.0095', currency: 'USDC' },
];
});
app.get('/api/checkout', async (req, res) => {
// On 402 the challenge response is already written; on 200 the receipt header is set, so we write the body.
const result = await checkout(req, res);
Expand All @@ -87,16 +92,30 @@ app.get('/api/checkout', async (req, res) => {
res.json({ ok: true });
});

const search = inflowChargesNodeListener(core, (request) => {
const detail = new URL(request.url ?? '/', 'http://localhost').searchParams.get('detail');
return [
{ amount: detail === 'full' ? '1.00' : '0.50', currency: 'USD' },
{ amount: detail === 'full' ? '0.25' : '0.10', currency: 'USDC' },
];
});
app.get('/api/search', async (req, res) => {
const result = await search(req, res);
if (result.status === 402) return;
res.json({ results: ['Sample search result'] });
});

app.get('/free', (_req, res) => {
res.json({ ok: true, note: 'no payment required' });
});

const port = Number(process.env['PORT'] ?? 3000);
app.listen(port, () => {
console.log(`mpp seller listening on http://localhost:${port.toString()}`);
console.log(` GET /api/widgets (0.01 USDC, single currency)`);
console.log(` POST /api/upload (0.10 USDC, single currency)`);
console.log(` GET /api/widgets (0.01 USDC; ?bundle=large costs 0.05 USDC)`);
console.log(` POST /api/upload (0.10 USDC; ?priority=high costs 0.20 USDC)`);
console.log(` GET /api/subscribe (1.00 USDC each month)`);
console.log(` GET /api/checkout (1.0 USD on instrument, or 0.0095 USDC on balance)`);
console.log(` GET /api/checkout (1.00 USD or 0.0095 USDC; ?bundle=large doubles the price)`);
console.log(` GET /api/search?detail=full (request-time USD and USDC prices)`);
console.log(` GET /free (no payment)`);
});
26 changes: 12 additions & 14 deletions examples/mpp-seller-hono/README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# Example — MPP seller on Hono

A minimal Hono app that accepts MPP payments via InFlow. Uses `mppx`'s own Hono adapter (`Mppx` from `mppx/hono`) with
InFlow's `inflow` seller method, served on Node via `@hono/node-server`. `Mppx.create` mints and HMAC-binds the
challenge locally with `secretKey`; the `inflow` method's `verify` redeems and settles through the InFlow PSP.
A minimal Hono app that accepts MPP payments via InFlow. Uses `mppx/server` with InFlow's `inflowCharges` request-time
pricing helper and `inflow` seller method, served on Node via `@hono/node-server`. `Mppx.create` mints and HMAC-binds
the challenge locally with `secretKey`; the `inflow` method's `verify` redeems and settles through the InFlow PSP.

## Prerequisite

Expand All @@ -24,17 +24,15 @@ pnpm dev

The server listens on `http://localhost:3000` and serves these routes:

| Route | Price | Notes |
| -------------------- | -------------------------- | ------------------------------------------------------------------------------- |
| `GET /api/widgets` | `0.01 USDC` | Single currency via the Hono adapter's `charge`. Crypto → `balance` rail. |
| `POST /api/upload` | `0.10 USDC` | Single currency via `charge`. Crypto → `balance` rail. |
| `GET /api/subscribe` | `1.00 USDC` monthly | Recurring subscription on the `balance` rail. |
| `GET /api/checkout` | `1.0 USD` or `0.0095 USDC` | Multi-currency: one challenge per price (USD → `instrument`, USDC → `balance`). |
| `GET /free` | — | Not gated; passes through. |

The Hono adapter (`mppx/hono`) exposes only the single-currency `charge` — it strips `compose`. The multi-currency
`GET /api/checkout` and `GET /api/subscribe` therefore use core `mppx/server` instances; Hono drives the InFlow
Web-fetch handlers directly from `c.req.raw`.
| Route | Price | Notes |
| -------------------- | ----------------------------------------------------------------------- | --------------------------------------------- |
| `GET /api/widgets` | `0.01 USDC`; `?bundle=large`: `0.05 USDC` | `balance` rail. |
| `POST /api/upload` | `0.10 USDC`; `?priority=high`: `0.20 USDC` | `balance` rail. |
| `GET /api/subscribe` | `1.00 USDC` monthly | Recurring subscription on the `balance` rail. |
| `GET /api/checkout` | `1.0 USD` or `0.0095 USDC`; `?bundle=large`: `2.0 USD` or `0.0190 USDC` | USD on `instrument`; USDC on `balance`. |
| `GET /free` | — | Not gated; passes through. |

Hono passes `c.req.raw` to the InFlow Web-fetch handlers, returning the challenge or adding the receipt to the response.

Hit it with the matching buyer example:

Expand Down
Loading
Loading