Use this file to discover all available pages before exploring further.
Gate any API route behind a Lightning payment. No accounts, no subscriptions — clients pay a Lightning invoice and get immediate access.This is an L402-compatible implementation (bLIP-26). Any client that speaks the L402 protocol (HTTP 402 + L402 auth scheme) can interact with your API out of the box. The legacy LSAT scheme is also accepted for backwards compatibility.
Option A: Use an AI coding assistant (recommended)Install the MCP server and let your AI agent handle setup. When the agent asks for your email, use a real email address so you can log in to your dashboard later.
Cursor
Click to install MCP in Cursor
VS Code
Click to install MCP in VS Code
Claude Code:
claude mcp add moneydevkit --transport http https://mcp.moneydevkit.com/mcp/
After signup, it’s highly recommended to log in at moneydevkit.com and switch to the authenticated MCP server (see the “Existing Account” tab). This connects your agent to your account so it can manage apps, view payments, and access your dashboard.
Option B: Manual setup
Create a moneydevkit account at moneydevkit.com or run npx @moneydevkit/create to generate credentials locally, then grab your api_key and mnemonic.
// app/api/mdk/route.jsexport { POST } from '@moneydevkit/nextjs/server/route'
Configure Next.js:
// next.config.js / next.config.mjsimport withMdkCheckout from '@moneydevkit/nextjs/next-plugin'export default withMdkCheckout({})
Option A: Use Replit Agent (recommended)Click the button below to install moneydevkit directly in Replit Agent. When the agent asks for your email, use a real email address so you can log in to your dashboard later.
After signup, it’s highly recommended to log in at moneydevkit.com and switch to the authenticated MCP server (see the “Existing Account” tab). This connects your agent to your account so it can manage apps, view payments, and access your dashboard.
Option B: Manual setup
Create a moneydevkit account at moneydevkit.com or run npx @moneydevkit/create to generate credentials locally, then grab your api_key and mnemonic.
Install the SDK (Express is a peer dependency):
npm install @moneydevkit/replit express
Add environment variables to .env (or Replit Secrets):
import express, { type Request as ExpressRequest, type Response as ExpressResponse } from 'express'import { withPayment } from '@moneydevkit/replit/server/express'const app = express()function expressToFetchRequest(req: ExpressRequest): Request { const headers = new Headers() for (const [key, value] of Object.entries(req.headers)) { if (key.toLowerCase() === 'content-length') { continue } if (Array.isArray(value)) { value.forEach((item) => headers.append(key, item)) } else if (value !== undefined) { headers.set(key, value) } } return new Request(`${req.protocol}://${req.get('host')}${req.originalUrl}`, { method: req.method, headers, })}async function sendFetchResponse(res: ExpressResponse, response: Response) { response.headers.forEach((value, key) => res.setHeader(key, value)) res.status(response.status).send(Buffer.from(await response.arrayBuffer()))}const handler = async (req: Request) => { return Response.json({ content: 'Premium data' })}const paidHandler = withPayment( { amount: 100, currency: 'SAT' }, handler,)app.get('/api/premium', async (req, res) => { const response = await paidHandler(expressToFetchRequest(req)) await sendFetchResponse(res, response)})
Every request without a valid token returns a 402 with a Lightning invoice per the L402 protocol. After payment, the same request with the authorization header returns the premium data.
withPayment accepts the same shape as the dashboard SDK’s createCheckout from @moneydevkit/core — a single discriminated union that picks AMOUNT or PRODUCTS mode based on the type field. The L402 wrapper adds two things: every field accepts a (req: Request) => value resolver for per-request dynamic pricing/metadata, and an expirySeconds field controls the credential + invoice lifetime.
Field
Type
Required
Description
type
'AMOUNT' | 'PRODUCTS'
conditional
Discriminator. Defaults to 'AMOUNT' when omitted. PRODUCTS endpoints must set 'PRODUCTS' explicitly.
amount
number | (req) => number
AMOUNT
Fixed price or a function that derives it from the request.
currency
'SAT' | 'USD'
AMOUNT
Currency for the AMOUNT price.
product
string | (req) => string
PRODUCTS
Product ID. Price and currency come from the product’s active price.
title
string | (req) => string
AMOUNT only
Short label for dashboard/order/webhook surfaces. Rejected on PRODUCTS endpoints — the product’s own name drives those surfaces.
description
string | (req) => string
AMOUNT only
Free-form description. Flows through to the BOLT11 invoice’s d-tag (server-side truncated to 200 bytes). Ignored in preview/sandbox mode. Rejected on PRODUCTS endpoints — the product’s own description is used.
Arbitrary string-valued metadata. Merged with system keys (source: '402', resource: <url>, sandbox: 'true' when in preview); system keys win.
customer
CustomerInput
optional
Customer info attached to the checkout (merchant collects out-of-band).
requireCustomerData
string[]
optional
Customer fields the merchant requires on the checkout (e.g., ['email']).
expirySeconds
number
optional
Credential + invoice lifetime in seconds. Default: 900 (15 minutes).
// AMOUNT mode (default — `type` omitted)withPayment({ amount: 100, currency: 'SAT' }, handler)// AMOUNT mode with metadatawithPayment( { amount: 100, currency: 'SAT', title: 'Premium pairing', description: 'AI-generated wine pairing for the day', metadata: { tier: 'pro' }, }, handler,)// PRODUCTS mode (price + label come from the product on the dashboard)withPayment( { type: 'PRODUCTS', product: 'prod_abc123', }, handler,)
On every authenticated retry, PRODUCTS-mode endpoints re-resolve the product and look for a price whose (amount, currency) matches the credential’s frozen values. If no matching price is found (the price was retired or replaced), the request is rejected with amount_mismatch (403, recoverable) — the agent should request a fresh 402 challenge. If the product fetch fails (e.g., the product was deleted), the request fails with pricing_error (500, phase: 'verify').
The pricing function is evaluated both when creating the invoice and when verifying the token. If the price changes between issuance and verification (e.g., the client replays a cheap token on an expensive tier), the request is rejected with amount_mismatch.
When a merchant’s app is running in sandbox mode (e.g. Replit preview), the 402 response signals sandbox state on three independent channels so any client implementation can detect it:
JSON body field — sandbox: true appears alongside the standard fields.
WWW-Authenticate parameter — the L402 challenge includes sandbox="true".
BOLT11 description tag — the invoice’s description is set to the literal string you_are_in_sandbox_mode_do_not_attempt_payment_just_pass_a_fake_preimage (the whole description, not a prefix).
The invoice is not payable — and the BOLT11 description literally instructs decoders not to try. AI agents should detect any of the three signals and skip the payment attempt entirely. In sandbox mode, preimage verification on subsequent requests is skipped, so the client can submit any 64-character hex preimage (e.g., "0".repeat(64)) and retry the same endpoint to proceed past the 402.
By default, withPayment marks the credential as used immediately before your handler runs. If your handler fails after the credential is consumed, the payer can’t retry.Use withDeferredSettlement when the service delivery might fail and you want the payer to be able to retry. Your handler receives a settle() callback - call it only after you’ve successfully delivered the service:
Next.js
Replit / Express
// app/api/ai/route.tsimport { withDeferredSettlement, type SettleResult } from '@moneydevkit/nextjs/server'const handler = async (req: Request, settle: () => Promise<SettleResult>) => { const { prompt } = await req.json() // Do the expensive work first const result = await runAiInference(prompt) // Work succeeded - now mark the credential as used const { settled } = await settle() if (!settled) { return Response.json({ error: 'settlement_failed' }, { status: 500 }) } return Response.json({ result })}export const POST = withDeferredSettlement( { amount: 100, currency: 'SAT' }, handler,)
import express, { type Request as ExpressRequest, type Response as ExpressResponse } from 'express'import { withDeferredSettlement, type SettleResult } from '@moneydevkit/replit/server/express'const app = express()app.use(express.json())function expressToFetchRequest(req: ExpressRequest): Request { const headers = new Headers() for (const [key, value] of Object.entries(req.headers)) { if (key.toLowerCase() === 'content-length') { continue } if (Array.isArray(value)) { value.forEach((item) => headers.append(key, item)) } else if (value !== undefined) { headers.set(key, value) } } const init: RequestInit = { method: req.method, headers, } if (req.method !== 'GET' && req.method !== 'HEAD' && req.body !== undefined) { init.body = typeof req.body === 'string' || Buffer.isBuffer(req.body) ? req.body : JSON.stringify(req.body) } return new Request(`${req.protocol}://${req.get('host')}${req.originalUrl}`, init)}async function sendFetchResponse(res: ExpressResponse, response: Response) { response.headers.forEach((value, key) => res.setHeader(key, value)) res.status(response.status).send(Buffer.from(await response.arrayBuffer()))}const handler = async (req: Request, settle: () => Promise<SettleResult>) => { const { prompt } = await req.json() const result = await runAiInference(prompt) const { settled } = await settle() if (!settled) { return Response.json({ error: 'settlement_failed' }, { status: 500 }) } return Response.json({ result })}const paidHandler = withDeferredSettlement( { amount: 100, currency: 'SAT' }, handler,)app.post('/api/ai', async (req, res) => { const response = await paidHandler(expressToFetchRequest(req)) await sendFetchResponse(res, response)})
If your handler returns without calling settle() (e.g. it throws or the service fails), the credential stays valid and the payer can retry with the same macaroon and preimage.settle() is callable only once per request. A second call returns { settled: false, error: 'already_settled' } without hitting the backend.
A 402 is only returned when no L402/LSAT authorization header is present. If the header is present but malformed or invalid, you get a 401 - not a new invoice. This prevents wasting invoices on bad auth attempts.
Credential’s frozen amount/currency no longer matches the endpoint’s current price (AMOUNT: dynamic callback returned a different value; PRODUCTS: no active price on the product matches the credential). Recoverable: request a fresh 402.
500
configuration_error
MDK_ACCESS_TOKEN is not set
500
pricing_error
Dynamic amount or product callback threw an error. Carries phase: 'create' | 'verify' to distinguish 402-issuance vs retry-time failure
500
config_error
Dynamic title, description, or metadata callback threw an error
500
config_invalid
Static config field failed validation (e.g., non-finite amount, empty product). Distinct from pricing_error which is reserved for runtime callback failures
502
checkout_creation_failed
Failed to create the checkout or invoice
502
invoice_mint_failed
mintInvoice returned a checkout without an attached invoice
Error responses optionally include two fields beyond { code, message, details }:
recoverable: boolean — true means the client should discard the credential and request a fresh 402 (price changed, product/price retired).
phase: 'create' | 'verify' — present on pricing_error to indicate whether the failure occurred at 402 issuance (create) or during an authenticated retry (verify). Useful for merchants grepping logs by failure point.