Skip to content

Build your own payment rail

The payments layer is designed so you can add new settlement methods without changing transports.

To add a new payment rail you implement:

  • a server-side PaymentProcessor (issue + verify)
  • a client-side PaymentHandler (pay)

Both are keyed by a PMI string (for example my-rail-v1).

Pick a stable string identifier.

  • Good: acme-checkout-v1
  • Avoid: version-less strings that you can’t evolve later

Processors must be able to:

  • create a pay_req that encodes enough information for a client handler to pay
  • later verify settlement for that pay_req

Skeleton:

import type {
PaymentProcessor,
PaymentProcessorCreateParams,
PaymentProcessorVerifyParams,
} from '@contextvm/sdk/payments';
export class MyRailPaymentProcessor implements PaymentProcessor {
public readonly pmi = 'my-rail-v1';
public async createPaymentRequired(
params: PaymentProcessorCreateParams,
): Promise<{
amount: number;
pay_req: string;
description?: string;
pmi: string;
}> {
// 1) Create a provider checkout/invoice
// 2) Encode whatever the client needs to complete payment
// 3) Return an opaque pay_req understood by the handler
return {
amount: params.amount,
pay_req: JSON.stringify({
invoiceId: '...',
requestEventId: params.requestEventId,
}),
description: params.description,
pmi: this.pmi,
};
}
public async verifyPayment(
params: PaymentProcessorVerifyParams,
): Promise<{ _meta?: Record<string, unknown> }> {
// Check provider for invoice status and fail if unpaid.
return { _meta: { verifiedAt: Date.now() } };
}
}

Guidance:

  • The processor runs on the server; never embed server secrets in pay_req.
  • Make verification idempotent per requestEventId.

Handlers must be able to pay a pay_req for their PMI.

Skeleton:

import type {
PaymentHandler,
PaymentHandlerRequest,
} from '@contextvm/sdk/payments';
export class MyRailPaymentHandler implements PaymentHandler {
public readonly pmi = 'my-rail-v1';
public async canHandle(_req: PaymentHandlerRequest): Promise<boolean> {
// Optional: enforce client policy (max amount, disabled rail, etc.)
return true;
}
public async handle(req: PaymentHandlerRequest): Promise<void> {
const decoded = JSON.parse(req.pay_req) as { invoiceId: string };
// Pay invoice using your local wallet/provider.
await payInvoice(decoded.invoiceId);
}
}

On the server:

withServerPayments(transport, {
processors: [new MyRailPaymentProcessor()],
pricedCapabilities,
});

On the client:

withClientPayments(baseTransport, {
handlers: [new MyRailPaymentHandler()],
});

Recommended tests:

  • server emits payment_required with your PMI
  • client selects your handler
  • server emits payment_accepted after verification
  • rejection path works (resolvePricepayment_rejected)