Custom
Build your own payment method
The mppx SDK supports dynamic extensibility for new payment methods. You can implement custom payment methods to integrate any payment rail—other blockchains, card processors, or proprietary systems.
| Approach | Description | Best for |
|---|---|---|
| Dynamic extension | Define a method inline in your app | Integrating a new payment rail |
| First-party SDK | Package your method as a standalone npm module | Publishing a reusable method for the ecosystem |
Dynamic extension
A custom payment method requires three pieces:
- Method definition — Define the method name, intent, and schemas for request parameters and Credential payloads
- Client logic — Create Credentials when the client gets a
402response - Server logic — Verify Credentials and return Receipts
Define a method
Start by defining your payment method with Method.from. The definition includes the method name, intent type, and schemas for request parameters and Credential payloads.
import { , } from 'mppx'
const = .({
: 'charge',
: 'lightning',
: {
: {
: .({
: .(),
}),
},
: .({
: .(),
: .(),
: .(),
: .(),
: .(),
}),
},
})Client implementation
Extend the method with Credential creation logic using Method.toClient. The createCredential function runs when the client gets a 402 response:
const = .(, {
async ({ }) {
const = await (..)
return .({
,
: {
: .,
},
})
},
})Server implementation
Extend the method with verification logic using Method.toServer. For Lightning Network, verify that the preimage hashes to the payment hash:
const = .(, {
async ({ }) {
const = ..
const = ...
const = ((()))
if ( !== ) {
throw new ('Preimage does not match payment hash')
}
return .({
: 'lightning',
: ,
: 'success',
: new ().(),
})
},
})Use in your app
Client
Pass the client method to Mppx.create:
const { } = .({
: [],
: false,
})
const = await ('https://api.example.com/premium')Server
Pass the server method to Mppx.create:
const = .({
: [],
})Advanced options
Pre-fill defaults
Use defaults to pre-fill request parameters so callers don't repeat them. Fields in defaults become optional at the call site.
const = .(, {
: {
: 'BTC',
: 'lnbc1...',
},
async ({ }) {
return .({
: 'lightning',
: ..,
: 'success',
: new ().(),
})
},
})Transform with z.pipe
Use z.pipe to accept human-readable input and emit a normalized wire format. The built-in tempo method uses this to convert dollar amounts to atomic units.
import { , } from 'mppx'
import { } from 'viem'
export const = .({
: 'charge',
: 'acme-pay',
: {
: {
: .({ : .() }),
},
: .(
.({
: .(),
: .(),
: .(),
: .(),
}),
.(({ , , ... }) => ({
...,
: (, ).(),
})),
),
},
})Callers pass { amount: '1.50', decimals: 6 }, the Challenge contains { amount: '1500000' }. Use parseUnits from viem for decimal-safe conversion—never use Number() for monetary amounts.
Client context
Declare a context schema to accept per-call parameters. The context is validated at runtime before createCredential runs.
const = .(, {
: .({ : .() }),
async ({ , }) {
const = await (.., .)
return .({ , : { : . } })
},
})Request hook
Use the request hook to enrich parameters before the Challenge is issued:
const = .(, {
async ({ }) {
const = await (.)
return { ..., : ., : . }
},
async ({ }) {
return .({
: 'lightning',
: ..,
: 'success',
: new ().(),
})
},
})Respond hook
Use respond to return a Response directly after verification, skipping the route handler. Return undefined to let the handler run normally.
const = .(, {
async ({ }) {
return .({
: 'lightning',
: ..,
: 'success',
: new ().(),
})
},
({ }) {
if (. === 'POST' && ..('content-length') === '0') {
return new (null, { : 204 })
}
return
},
})First-party SDK
When you want others to use your payment method, package it as a standalone npm module. Users install it and import your method the same way they use tempo or stripe—a single import gives them both Mppx and your method factory.
Package structure
Organize your SDK with three export paths: root (shared schemas), ./client, and ./server. Start with a single intent (charge) and add more later.
my-method-sdk/
├── src/
│ ├── index.ts # Re-export shared schemas
│ ├── Methods.ts # Shared Method.from() definitions
│ ├── client/
│ │ ├── index.ts # ./client entry point
│ │ └── Charge.ts # Client charge implementation
│ └── server/
│ ├── index.ts # ./server entry point
│ └── Charge.ts # Server charge implementation
├── package.json
└── tsconfig.json
Exports map
Define three entry points in package.json. Declare mppx as a peer dependency so the user's app shares a single instance.
{
"name": "@my-org/my-method-sdk",
"type": "module",
"sideEffects": false,
"files": ["dist", "src"],
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./client": {
"types": "./dist/client/index.d.ts",
"default": "./dist/client/index.js"
},
"./server": {
"types": "./dist/server/index.d.ts",
"default": "./dist/server/index.js"
}
},
"peerDependencies": {
"mppx": ">=0.3.15"
}
}Shared method definition
Define your schemas once in a shared file. Both client and server import from here.
import { Method, z } from 'mppx'
export const charge = Method.from({
intent: 'charge',
name: 'my-method',
schema: {
credential: {
payload: z.object({ proof: z.string() }),
},
request: z.object({
amount: z.string(),
currency: z.string(),
recipient: z.string(),
}),
},
})Re-export Mppx
Re-export Mppx (and Expires, Store on the server) from your entry points so users need only one import:
export { charge } from './Charge.js'
export { Mppx, Expires, Store } from 'mppx/server'export { charge } from './Charge.js'
export { Mppx } from 'mppx/client'Users get a single-line import:
import { Mppx, charge } from '@my-org/my-method-sdk/server'
const mppx = Mppx.create({
methods: [charge({ /* config */ })],
})Advanced SDK patterns
Method namespace
When your SDK supports multiple intents, export a namespace that groups them under a single name. Calling the namespace directly defaults to charge.
import { charge as charge_ } from './Charge.js'
import { session as session_ } from './Session.js'
export function myMethod(parameters: myMethod.Parameters) {
return myMethod.charge(parameters)
}
export namespace myMethod {
export type Parameters = charge_.Parameters
export const charge = charge_
export const session = session_
}import { myMethod } from '@my-org/my-method-sdk/server'
myMethod(opts) // defaults to charge
myMethod.charge(opts) // explicit charge
myMethod.session(opts) // explicit sessionAugment the returned method
Use Object.assign to attach lifecycle methods (like cleanup or close) to the method object returned by Method.toClient or Method.toServer:
import { Credential, Method } from 'mppx'
import * as Methods from '../Methods.js'
export function charge(parameters: charge.Parameters) {
let connection: WebSocket | null = null
const method = Method.toClient(Methods.charge, {
async createCredential({ challenge }) {
// ... pay and return credential
},
})
async function cleanup() {
connection?.close()
}
return Object.assign(method, { cleanup })
}Reference implementations
| SDK | Payment rail | Intents | Source |
|---|---|---|---|
@buildonspark/lightning-mpp-sdk | Lightning Network | charge, session | GitHub |
Gotchas
- Always reject invalid proofs. Throw an error from
verifywhen verification fails. Never return a success Receipt without checking the proof. - Use decimal-safe math for amounts. Use
parseUnits/formatUnitsfrom viem instead ofNumber()or floating-point arithmetic. - Verify against the original Challenge. Always check the Credential's proof against the request fields from the Challenge (amount, currency, recipient). Don't trust the payload alone.
- Keep secrets server-side. The Challenge is sent to the client. Don't put API keys, private keys, or other secrets in request fields.
- Use
methodDetailsfor method-specific fields. Nest non-standard request fields under amethodDetailsobject to avoid collisions with the base schema. - Make invoice/order creation idempotent. The
requesthook runs on both the initial402and the Credential submission. Don't generate a new invoice if one already exists for the Challenge. - Clean up resources. If your client method opens WebSocket connections, SDK instances, or listeners, expose a
cleanup()method so callers can tear them down.
SDK references
Method.from— Define a payment method with schemasMethod.toClient— Extend a method with client-side Credential creation logicMethod.toServer— Extend a method with server-side verification logic