Skip to content
LogoLogo

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.

ApproachDescriptionBest for
Dynamic extensionDefine a method inline in your appIntegrating a new payment rail
First-party SDKPackage your method as a standalone npm modulePublishing a reusable method for the ecosystem

Dynamic extension

A custom payment method requires three pieces:

  1. Method definition — Define the method name, intent, and schemas for request parameters and Credential payloads
  2. Client logic — Create Credentials when the client gets a 402 response
  3. 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.

methods.ts
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:

methods.client.ts
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:

methods.server.ts
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:

client.ts
const {  } = .({
  : [],
  : false,
})
 
const  = await ('https://api.example.com/premium')

Server

Pass the server method to Mppx.create:

server.ts
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.

methods.server.ts
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.

methods.ts
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.

methods.client.ts
const  = .(, {
  : .({ : .() }),
  async ({ ,  }) {
    const  = await (.., .)
    return .({ , : { : . } })
  },
})

Request hook

Use the request hook to enrich parameters before the Challenge is issued:

methods.server.ts
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.

methods.server.ts
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.

package.json
{
  "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.

src/Methods.ts
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:

src/server/index.ts
export { charge } from './Charge.js'
export { Mppx, Expires, Store } from 'mppx/server'
src/client/index.ts
export { charge } from './Charge.js'
export { Mppx } from 'mppx/client'

Users get a single-line import:

server.ts
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.

src/server/Methods.ts
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_
}
server.ts
import { myMethod } from '@my-org/my-method-sdk/server'
 
myMethod(opts)             // defaults to charge
myMethod.charge(opts)      // explicit charge
myMethod.session(opts)     // explicit session

Augment 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:

src/client/Charge.ts
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

SDKPayment railIntentsSource
@buildonspark/lightning-mpp-sdkLightning Networkcharge, sessionGitHub

Gotchas

  • Always reject invalid proofs. Throw an error from verify when verification fails. Never return a success Receipt without checking the proof.
  • Use decimal-safe math for amounts. Use parseUnits/formatUnits from viem instead of Number() 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 methodDetails for method-specific fields. Nest non-standard request fields under a methodDetails object to avoid collisions with the base schema.
  • Make invoice/order creation idempotent. The request hook runs on both the initial 402 and 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 schemas
  • Method.toClient — Extend a method with client-side Credential creation logic
  • Method.toServer — Extend a method with server-side verification logic