Express middleware for the legacy authenticated x-bsv-payment JSON flow. It
runs after @bsv/auth-express-middleware, validates an Atomic BEEF payment,
atomically rejects transaction-ID reuse, internalizes output zero, and exposes
a verified receipt.
This contract is distinct from the newer BRC-121 implementation in
@bsv/402-pay. Their headers and clients are not interchangeable.
npm install @bsv/payment-express-middleware @bsv/auth-express-middleware @bsv/sdk expressNode.js 22 or newer is required. The package provides native ESM and CommonJS entry points with matching declarations.
import express from 'express'
import { createAuthMiddleware } from '@bsv/auth-express-middleware'
import { createPaymentMiddleware, type PaymentRequest } from '@bsv/payment-express-middleware'
const app = express()
app.use(express.json())
app.use(createAuthMiddleware({ wallet }))
app.use(
createPaymentMiddleware({
wallet,
calculateRequestPrice(req) {
if (req.path === '/free') return 0
return 100
},
replayStore
})
)
app.get('/paid', (req: PaymentRequest, res) => {
res.json({
satoshisPaid: req.payment?.satoshisPaid,
txid: req.payment?.txid
})
})Prices must be zero or a positive safe integer. Zero-cost requests receive an accepted zero-value receipt. Invalid pricing fails closed.
402 plus the version, required satoshis, and a
server-created derivation prefix.x-bsv-payment JSON with canonical-base64
derivationPrefix, derivationSuffix, and transaction.{ accepted: true } from a newly internalized payment authorizes the
route.req.payment.satoshisPaid and the response header report the actual output
value.The raw header is bounded to 64 KiB by default. Duplicated, malformed,
underfunded, replayed, rejected, or ambiguous payments never call next().
PaymentReplayStore has one required operation:
interface PaymentReplayStore {
claim(transactionId: string): boolean | Promise<boolean>
}The claim must be a single atomic insert-if-absent operation. It returns true once and false for every reuse.
The default InMemoryPaymentReplayStore is bounded and fail-closed, but it is
process-local and loses claims on restart. Use a shared durable implementation
for replicas and production services:
const replayStore = {
async claim(transactionId: string) {
return await database.insertPaymentClaimIfAbsent(transactionId)
}
}Claims are retained after ambiguous wallet errors. A derivation nonce proves the server created a prefix; it is not an expiring single-use replay database.
wallet — required BRC-100 wallet with internalizeActioncalculateRequestPrice — sync or async price; defaults to 100replayStore — atomic transaction-ID claim storemaxPaymentHeaderBytes — positive safe integer; defaults to 64 KiBlogger — optional structured error/warn sinkThe logger receives sanitized failure metadata, not exception messages, transaction IDs, wallet objects, or payment bodies. HTTP responses are also stable and sanitized.
interface PaymentReceipt {
satoshisPaid: number
accepted: true
tx: string
txid: string
}For a free request, satoshisPaid is zero and tx/txid are empty.
The middleware does not hard-code CORS, CSP, or origin restrictions. Public
payment services may remain cross-origin by default, with an operator opt-in
allowlist at the application or edge. Browser clients need
x-bsv-payment-* response headers exposed through CORS. Do not combine a
wildcard origin with credentialed CORS.
409) and unavailable/capacity (503) responses.Runtime:
createPaymentMiddlewareInMemoryPaymentReplayStoreTypes:
BSVPaymentPaymentLoggerPaymentMiddlewareOptionsPaymentReceiptPaymentReplayStorePaymentRequest@bsv/auth-express-middleware — required payer authentication@bsv/402-pay — separate BRC-121 payment protocol@bsv/sdk — nonce, Atomic BEEF, and wallet primitives