Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.moneydevkit.com/llms.txt

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.

How it works

1

Client requests a protected endpoint without credentials

2

Server returns 402 with a Lightning invoice and a signed token

3

Client pays the invoice and receives a preimage (proof of payment)

4

Client retries with Authorization: L402 <token>:<preimage>

5

Server verifies the token, expiry, and preimage — then forwards to the handler

Setup

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
  1. Create a moneydevkit account at moneydevkit.com or run npx @moneydevkit/create to generate credentials locally, then grab your api_key and mnemonic.
  2. Install the SDK:
    npm install @moneydevkit/nextjs
    
  3. Add environment variables to .env:
    MDK_ACCESS_TOKEN=your_api_key_here
    MDK_MNEMONIC=your_mnemonic_here
    
  4. Expose the moneydevkit endpoint:
    // app/api/mdk/route.js
    export { POST } from '@moneydevkit/nextjs/server/route'
    
  5. Configure Next.js:
    // next.config.js / next.config.mjs
    import withMdkCheckout from '@moneydevkit/nextjs/next-plugin'
    
    export default withMdkCheckout({})
    
If you’ve already set up moneydevkit for checkouts, you can skip the steps above — L402 uses the same MDK_ACCESS_TOKEN and MDK_MNEMONIC.

Basic usage

Wrap any route handler with withPayment to require a Lightning payment:
// app/api/premium/route.ts
import { withPayment } from '@moneydevkit/nextjs/server'

const handler = async (req: Request) => {
  return Response.json({ content: 'Premium data' })
}

export const GET = withPayment(
  { amount: 100, currency: 'SAT' },
  handler,
)
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.

PaymentConfig

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.
FieldTypeRequiredDescription
type'AMOUNT' | 'PRODUCTS'conditionalDiscriminator. Defaults to 'AMOUNT' when omitted. PRODUCTS endpoints must set 'PRODUCTS' explicitly.
amountnumber | (req) => numberAMOUNTFixed price or a function that derives it from the request.
currency'SAT' | 'USD'AMOUNTCurrency for the AMOUNT price.
productstring | (req) => stringPRODUCTSProduct ID. Price and currency come from the product’s active price.
titlestring | (req) => stringAMOUNT onlyShort label for dashboard/order/webhook surfaces. Rejected on PRODUCTS endpoints — the product’s own name drives those surfaces.
descriptionstring | (req) => stringAMOUNT onlyFree-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.
metadataRecord<string, string> | (req) => Record<string, string>optionalArbitrary string-valued metadata. Merged with system keys (source: '402', resource: <url>, sandbox: 'true' when in preview); system keys win.
customerCustomerInputoptionalCustomer info attached to the checkout (merchant collects out-of-band).
requireCustomerDatastring[]optionalCustomer fields the merchant requires on the checkout (e.g., ['email']).
expirySecondsnumberoptionalCredential + invoice lifetime in seconds. Default: 900 (15 minutes).
// AMOUNT mode (default — `type` omitted)
withPayment({ amount: 100, currency: 'SAT' }, handler)

// AMOUNT mode with metadata
withPayment(
  {
    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').

Dynamic pricing

Pass a function instead of a fixed number to compute the price from the request:
export const POST = withPayment(
  {
    amount: (req: Request) => {
      const url = new URL(req.url)
      const tier = url.searchParams.get('tier')
      if (tier === 'pro') return 500
      return 100
    },
    currency: 'SAT',
  },
  handler,
)
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.

Fiat pricing

Use currency: 'USD' to price in US cents. The SDK converts to sats at the current exchange rate when generating the invoice:
export const GET = withPayment(
  { amount: 50, currency: 'USD' },  // $0.50
  handler,
)

Token expiry

Tokens and their invoices expire after 15 minutes by default. Override with expirySeconds:
export const GET = withPayment(
  { amount: 100, currency: 'SAT', expirySeconds: 300 },  // 5 minutes
  handler,
)

Sandbox responses

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:
  1. JSON body fieldsandbox: true appears alongside the standard fields.
  2. WWW-Authenticate parameter — the L402 challenge includes sandbox="true".
  3. 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).
Example sandbox 402 response:
HTTP/1.1 402 Payment Required
content-type: application/json
www-authenticate: L402 macaroon="eyJ...", invoice="lnbc...", sandbox="true"

{
  "error": { "code": "payment_required", "message": "Payment required" },
  "macaroon": "eyJ...",
  "invoice": "lnbc...",
  "paymentHash": "abc123...",
  "amountSats": 100,
  "expiresAt": 1234567890,
  "sandbox": true
}
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.

Client integration

Any HTTP client can consume an L402 endpoint.

curl

# 1. Request the protected resource
curl -s https://example.com/api/premium

# Response: 402
# {
#   "macaroon": "eyJ...",
#   "invoice": "lnbc...",
#   "paymentHash": "abc123...",
#   "amountSats": 100,
#   "expiresAt": 1234567890
# }

# 2. Pay the invoice with any Lightning wallet and get the preimage

# 3. Retry with the token and preimage
curl -s https://example.com/api/premium \
  -H "Authorization: L402 eyJ...:ff00aa..."

# Response: 200 { "content": "Premium data" }
The WWW-Authenticate header follows the bLIP-26 format:
WWW-Authenticate: L402 macaroon="eyJ...", invoice="lnbc..."

Programmatic (Node.js / AI agent)

async function callPaidEndpoint(
  url: string,
  payFn: (invoice: string) => Promise<string>,
) {
  // Step 1: get the 402 challenge
  const challenge = await fetch(url)
  if (challenge.status !== 402) return challenge

  const { macaroon, invoice } = await challenge.json()

  // Step 2: pay the invoice (returns preimage)
  const preimage = await payFn(invoice)

  // Step 3: retry with token + proof of payment
  return fetch(url, {
    headers: { Authorization: `L402 ${macaroon}:${preimage}` },
  })
}

Deferred settlement

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:
// app/api/ai/route.ts
import { 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,
)
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.

Error codes

StatusCodeMeaning
402payment_requiredNo valid token - pay the returned invoice
401invalid_credentialToken is malformed or has a bad signature
401invalid_payment_proofPreimage does not match the payment hash
401credential_consumedCredential has already been used
403resource_mismatchToken was issued for a different endpoint
403amount_mismatchCredential’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.
500configuration_errorMDK_ACCESS_TOKEN is not set
500pricing_errorDynamic amount or product callback threw an error. Carries phase: 'create' | 'verify' to distinguish 402-issuance vs retry-time failure
500config_errorDynamic title, description, or metadata callback threw an error
500config_invalidStatic config field failed validation (e.g., non-finite amount, empty product). Distinct from pricing_error which is reserved for runtime callback failures
502checkout_creation_failedFailed to create the checkout or invoice
502invoice_mint_failedmintInvoice returned a checkout without an attached invoice

Error envelope extensions

Error responses optionally include two fields beyond { code, message, details }:
  • recoverable: booleantrue 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.