> ## 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.

# L402: Pay-per-call APIs

> Gate any API route behind a Lightning payment using the L402 protocol (bLIP-26)

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](https://github.com/lightning/blips/pull/26)-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

```mermaid theme={null}
sequenceDiagram
    participant C as Client
    participant S as Your Server
    participant L as Lightning

    C->>S: GET /api/premium
    S-->>C: 402 { invoice, token }
    C->>L: pay invoice
    L-->>C: preimage
    C->>S: GET /api/premium<br/>Authorization: L402 token:preimage
    S->>S: verify token + preimage
    S-->>C: 200 OK { data }
```

<Steps>
  <Step title="Client requests a protected endpoint without credentials" />

  <Step title="Server returns 402 with a Lightning invoice and a signed token" />

  <Step title="Client pays the invoice and receives a preimage (proof of payment)" />

  <Step title="Client retries with Authorization: L402 <token>:<preimage>" />

  <Step title="Server verifies the token, expiry, and preimage — then forwards to the handler" />
</Steps>

## Setup

<Tabs>
  <Tab title="Next.js">
    **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.

    <CardGroup cols={2}>
      <Card title="Cursor" icon="wand-magic-sparkles" href="cursor://anysphere.cursor-deeplink/mcp/install?name=moneydevkit&config=eyJ1cmwiOiJodHRwczovL21jcC5tb25leWRldmtpdC5jb20vbWNwLyJ9">
        Click to install MCP in Cursor
      </Card>

      <Card title="VS Code" icon="code" href="vscode://mcp/install?name=moneydevkit&config=%7B%22url%22%3A%22https%3A//mcp.moneydevkit.com/mcp/%22%7D">
        Click to install MCP in VS Code
      </Card>
    </CardGroup>

    **Claude Code:**

    ```bash theme={null}
    claude mcp add moneydevkit --transport http https://mcp.moneydevkit.com/mcp/
    ```

    <Tip>
      After signup, it's highly recommended to log in at [moneydevkit.com](https://moneydevkit.com) and switch to the [authenticated MCP server](/nextjs#existing-account) (see the "Existing Account" tab). This connects your agent to your account so it can manage apps, view payments, and access your dashboard.
    </Tip>

    **Option B: Manual setup**

    1. **Create a moneydevkit account** at [moneydevkit.com](https://moneydevkit.com) or run `npx @moneydevkit/create` to generate credentials locally, then grab your `api_key` and mnemonic.
    2. **Install the SDK**:
       ```bash theme={null}
       npm install @moneydevkit/nextjs
       ```
    3. **Add environment variables** to `.env`:
       ```env theme={null}
       MDK_ACCESS_TOKEN=your_api_key_here
       MDK_MNEMONIC=your_mnemonic_here
       ```
    4. **Expose the moneydevkit endpoint**:
       ```js theme={null}
       // app/api/mdk/route.js
       export { POST } from '@moneydevkit/nextjs/server/route'
       ```
    5. **Configure Next.js**:
       ```js theme={null}
       // next.config.js / next.config.mjs
       import withMdkCheckout from '@moneydevkit/nextjs/next-plugin'

       export default withMdkCheckout({})
       ```
  </Tab>

  <Tab title="Replit / Express">
    **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.

    [![Install moneydevkit](https://replit.com/badge?caption=Install%20moneydevkit)](https://replit.com/integrations?mcp=eyJkaXNwbGF5TmFtZSI6Im1vbmV5ZGV2a2l0IiwiYmFzZVVybCI6Imh0dHBzOi8vbWNwLm1vbmV5ZGV2a2l0LmNvbS9tY3AifQ==)

    <Tip>
      After signup, it's highly recommended to log in at [moneydevkit.com](https://moneydevkit.com) and switch to the [authenticated MCP server](/nextjs#existing-account) (see the "Existing Account" tab). This connects your agent to your account so it can manage apps, view payments, and access your dashboard.
    </Tip>

    **Option B: Manual setup**

    1. **Create a moneydevkit account** at [moneydevkit.com](https://moneydevkit.com) or run `npx @moneydevkit/create` to generate credentials locally, then grab your `api_key` and mnemonic.
    2. **Install the SDK** (Express is a peer dependency):
       ```bash theme={null}
       npm install @moneydevkit/replit express
       ```
    3. **Add environment variables** to `.env` (or Replit Secrets):
       ```env theme={null}
       MDK_ACCESS_TOKEN=your_api_key_here
       MDK_MNEMONIC=your_mnemonic_here
       ```
    4. **Mount the moneydevkit endpoint** in your Express server:
       ```ts theme={null}
       // server/index.ts
       import { createMdkExpressRouter } from '@moneydevkit/replit/server/express'

       app.use('/api/mdk', createMdkExpressRouter())
       ```
  </Tab>
</Tabs>

<Info>
  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`.
</Info>

## Basic usage

Wrap any route handler with `withPayment` to require a Lightning payment:

<Tabs>
  <Tab title="Next.js">
    ```ts theme={null}
    // 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,
    )
    ```
  </Tab>

  <Tab title="Replit / Express">
    ```ts theme={null}
    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)
    })
    ```
  </Tab>
</Tabs>

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.

| 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. |
| `metadata`            | `Record<string, string> \| (req) => Record<string, string>` | optional    | 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).                                                                                                                                            |

```ts theme={null}
// 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:

```ts theme={null}
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:

```ts theme={null}
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`:

```ts theme={null}
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 field** — `sandbox: 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 theme={null}
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

```bash theme={null}
# 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)

```ts theme={null}
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:

<Tabs>
  <Tab title="Next.js">
    ```ts theme={null}
    // 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,
    )
    ```
  </Tab>

  <Tab title="Replit / Express">
    ```ts theme={null}
    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)
    })
    ```
  </Tab>
</Tabs>

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.

<Note>
  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.
</Note>

## Error codes

| Status | Code                       | Meaning                                                                                                                                                                                                                                      |
| ------ | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 402    | `payment_required`         | No valid token - pay the returned invoice                                                                                                                                                                                                    |
| 401    | `invalid_credential`       | Token is malformed or has a bad signature                                                                                                                                                                                                    |
| 401    | `invalid_payment_proof`    | Preimage does not match the payment hash                                                                                                                                                                                                     |
| 401    | `credential_consumed`      | Credential has already been used                                                                                                                                                                                                             |
| 403    | `resource_mismatch`        | Token was issued for a different endpoint                                                                                                                                                                                                    |
| 403    | `amount_mismatch`          | 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 envelope extensions

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.
