Skip to content
create-bsv-app

Accept a payment

Take a BSV payment from the user's wallet and receive it straight into your server's wallet. One React button, one Express route, BRC-29 under the hood.

Your server names a price. The user's wallet pays to a fresh key that only your server can spend, after the user approves it. The browser hands the signed transaction to your server, and your server's wallet checks it and keeps the coins. No addresses to copy, no payment processor, and the user never leaves your app.

This builds on the full-stack starter. Everything on this page compiles against a fresh --starter full-stack --capabilities wallet-login,signed-requests scaffold. We couldn't approve a payment in a real wallet from a test script, so the final hop was checked with stand-in transactions: see what was tested.

How it works#

  1. Quote. The client calls POST /api/pay with an empty body. The server answers 402 Payment Required with the price and a one-time derivationPrefix.
  2. Derive. The client picks a random derivationSuffix and asks the wallet for a key derived from the server's identity key (BRC-29 (opens in a new tab)). Only the server can spend from it.
  3. Pay. wallet.createAction builds the transaction. The user approves it in their wallet.
  4. Receive. The client posts the transaction (AtomicBEEF) with the prefix, the suffix and its identity key. The server's wallet calls internalizeAction, which checks the proofs and the key, and adds the coins to its balance.

Give the server a wallet that can receive#

The scaffold's serverWallet is a ProtoWallet. It signs and verifies, which is all login and signed requests need, but it has no storage, so it can't hold coins. To receive payments, add a real wallet from @bsv/wallet-toolbox-client:

Terminal
cd server
npm install @bsv/wallet-toolbox-client

The wallet below uses the same SERVER_PRIVATE_KEY, so its identity key matches what GET /api/identity already returns. It needs:

  • Storage, to remember which coins it owns. By default it uses Babbage's hosted wallet storage (the staging instance on testnet). Pass storageUrl to use your own.
  • A network, from BSV_NETWORK. This page covers test and main.
  • No funding. Receiving costs the server nothing. Coins it receives become its balance, which it can spend later with its own createAction.

The server route#

Add this file next to index.ts:

server/src/payments.ts
// Take a BRC-29 payment from the user's wallet and receive it into the server's wallet.
import type { Request, Response } from 'express'
import { PrivateKey, Transaction, Utils, createNonce, verifyNonce } from '@bsv/sdk'
import { SetupClient, type Wallet } from '@bsv/wallet-toolbox-client'
import { SERVER_PRIVATE_KEY, BSV_NETWORK } from './bsv/config.js'

const PRICE = 1000 // satoshis

// Same key as the ProtoWallet in index.ts, so the identity key clients see doesn't change.
let wallet: Promise<Wallet> | undefined
function paymentWallet (): Promise<Wallet> {
  wallet ??= SetupClient.createWalletClientNoEnv({
    chain: BSV_NETWORK === 'main' ? 'main' : 'test',
    rootKeyHex: PrivateKey.fromString(SERVER_PRIVATE_KEY).toHex()
  })
  return wallet
}

const usedTxids = new Set<string>() // use your database in production

export async function payRoute (req: Request, res: Response): Promise<void> {
  const server = await paymentWallet()
  const payment = req.body?.payment

  // 1. No payment yet: say what it costs, and hand out a one-time derivation prefix.
  if (payment == null) {
    const derivationPrefix = await createNonce(server)
    res.status(402).json({ satoshisRequired: PRICE, derivationPrefix })
    return
  }

  const { derivationPrefix, derivationSuffix, transaction, senderIdentityKey } = payment
  if (typeof derivationPrefix !== 'string' || typeof derivationSuffix !== 'string' ||
      typeof transaction !== 'string' || typeof senderIdentityKey !== 'string') {
    res.status(400).json({ error: 'malformed payment' })
    return
  }

  // 2. The prefix must be one this server issued.
  if (!await verifyNonce(derivationPrefix, server).catch(() => false)) {
    res.status(400).json({ error: 'unknown derivation prefix' })
    return
  }

  // 3. Output 0 must pay at least the price, and each transaction only counts once.
  let txid: string
  try {
    const tx = Transaction.fromAtomicBEEF(Utils.toArray(transaction, 'base64'))
    txid = tx.id('hex')
    if ((tx.outputs[0]?.satoshis ?? 0) < PRICE) throw new Error('underpaid')
  } catch {
    res.status(402).json({ error: 'payment does not cover the price', satoshisRequired: PRICE })
    return
  }
  if (usedTxids.has(txid)) {
    res.status(409).json({ error: 'payment already used' })
    return
  }
  usedTxids.add(txid)

  // 4. The wallet checks the proofs, checks output 0 is locked to its own BRC-29 key, and keeps it.
  try {
    await server.internalizeAction({
      tx: Utils.toArray(transaction, 'base64'),
      outputs: [{
        outputIndex: 0,
        protocol: 'wallet payment',
        paymentRemittance: { derivationPrefix, derivationSuffix, senderIdentityKey }
      }],
      description: 'Payment for premium content'
    })
  } catch {
    usedTxids.delete(txid)
    res.status(400).json({ error: 'payment rejected' })
    return
  }

  res.json({ paid: PRICE, txid, from: senderIdentityKey })
}

Then mount it:

server/src/index.ts
import { consumeNonce } from './bsv/nonceStore.js'
import { payRoute } from './payments.js'
// ...
app.post('/api/login', loginRoute(serverWallet))
app.post('/api/pay', payRoute)

internalizeAction throws if output 0 isn't locked to the key the server derives from that prefix, suffix and sender. So a successful payment also proves who sent it: only the holder of senderIdentityKey could have derived that key. You get the payer's identity without a separate login.

Deep diveWhy not @bsv/payment-express-middleware?

It does the same job (it's the BRC-105 (opens in a new tab) flow, with headers instead of a JSON body), but it must run after @bsv/auth-express-middleware, which authenticates every request with BRC-103 sessions. The scaffold signs individual requests instead, so a plain route is the smaller change. If you move to BRC-103 auth, switch to the middleware, and the client can use AuthFetch from @bsv/sdk, which pays 402 responses for you.

The client button#

Drop this component anywhere inside WalletProviders (next to <ConnectWallet /> works well). It uses the scaffold's useWallet, getServerIdentity and apiFetch:

client/src/PayButton.tsx
import { useState } from 'react'
import { P2PKH, PublicKey, Random, Utils } from '@bsv/sdk'
import { useWallet } from './bsv/WalletContext.js'
import { getServerIdentity } from './bsv/serverIdentity.js'
import { apiFetch, readApiJson } from './bsv/apiClient.js'

const post = (body: unknown) => apiFetch('/api/pay', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify(body)
})

export function PayButton () {
  const { wallet, identityKey } = useWallet()
  const [status, setStatus] = useState('')

  async function pay () {
    if (wallet === null || identityKey === null) return
    try {
      // 1. Ask the server for the price and a one-time derivation prefix.
      const quote = await readApiJson(await post({})) as { satoshisRequired: number, derivationPrefix: string }
      const { satoshisRequired, derivationPrefix } = quote

      // 2. Derive a fresh key that only the server can spend (BRC-29).
      const derivationSuffix = Utils.toBase64(Random(16))
      const { publicKey } = await wallet.getPublicKey({
        protocolID: [2, '3241645161d8'],
        keyID: `${derivationPrefix} ${derivationSuffix}`,
        counterparty: await getServerIdentity()
      })

      // 3. The wallet asks the user to approve, then builds and signs the transaction.
      const { tx } = await wallet.createAction({
        description: 'Unlock premium content',
        outputs: [{
          lockingScript: new P2PKH().lock(PublicKey.fromString(publicKey).toAddress()).toHex(),
          satoshis: satoshisRequired,
          outputDescription: 'Payment to the app'
        }],
        options: { randomizeOutputs: false } // keep the payment at output 0
      })
      if (tx === undefined) throw new Error('wallet returned no transaction')

      // 4. Hand the transaction to the server, which receives it into its own wallet.
      const res = await post({
        payment: { derivationPrefix, derivationSuffix, transaction: Utils.toBase64(tx), senderIdentityKey: identityKey }
      })
      setStatus(res.ok ? 'Paid. Thank you!' : `Payment failed (${res.status})`)
    } catch (e) {
      setStatus(e instanceof Error ? e.message : String(e))
    }
  }

  if (wallet === null) return null
  return (
    <>
      <button onClick={() => { void pay() }}>Pay 1000 satoshis</button>
      <p role="status">{status}</p>
    </>
  )
}

[2, '3241645161d8'] is the protocol ID BRC-29 reserves for payments, and options: { randomizeOutputs: false } keeps your payment at output 0, where the server looks for it. The wallet adds the user's change after it.

Try it on testnet#

New projects target testnet (BSV_NETWORK=test), so the coins are free and worthless. Put some in your wallet from the BSV Faucet (opens in a new tab), then click the button. One day of faucet coins covers thousands of 1000-satoshi test payments.

Switch to real money by setting BSV_NETWORK=main on the server and VITE_BSV_NETWORK=main on the client. See Networks.

What accepted means#

internalizeAction returns once the server's wallet has checked the transaction and stored it. It doesn't wait for a miner, and it doesn't prove the network has accepted the transaction: in our tests, a well-formed transaction that was never broadcast still came back accepted, and the wallet dropped it later. For small amounts that risk is usually fine. For something valuable, check the network has seen the transaction before you deliver:

server/src/payments.ts
export async function seenByNetwork (txid: string): Promise<boolean> {
  const server = await paymentWallet()
  const { results } = await server.getServices().getStatusForTxids([txid])
  return results[0]?.status === 'known' || results[0]?.status === 'mined'
}

The user's wallet usually broadcasts as it signs, but give it a few seconds and retry before you call a payment missing.

Common errors#

You see Why Fix
400 unknown derivation prefix The prefix came from a different server key, often after a dev restart without SERVER_PRIVATE_KEY. Set SERVER_PRIVATE_KEY, then ask for a new quote.
402 payment does not cover the price Output 0 pays less than PRICE, or the payment isn't at output 0. Pay satoshisRequired and keep randomizeOutputs: false.
400 payment rejected internalizeAction threw: the proofs didn't check out, or output 0 isn't locked to the key derived from the prefix, suffix and senderIdentityKey. Send the connected wallet's own identity key, and the same prefix and suffix you derived with.
409 payment already used That transaction was already counted. Ask for a new quote and pay again. Keep usedTxids in a database so this survives restarts.
413 Payload Too Large The transaction and its ancestors are bigger than the scaffold's 64kb JSON limit. Raise limit in express.json in server/src/index.ts.
The wallet rejects the action Not enough coins, the user declined, or a description outside 5 to 50 characters. Fund the wallet from the faucet on testnet, and keep descriptions short.

What was tested#

On a fresh scaffold with create-bsv-app 1.1.2, both client/ and server/ build with this code. Against the running server, with throwaway keys standing in for the user's wallet: the quote returns 402, an underpaid transaction gets 402, a transaction locked to the wrong sender's key gets 400, a correctly derived payment is accepted, and sending it again gets 409. What we couldn't do from a script is approve a real payment in a real wallet, so try it once on testnet before you rely on it.

Using an AI assistant?#

Paste this:

Read https://www.createbsvapp.com/docs/accept-payments.md and add a "Pay 1000 satoshis" button to my create-bsv-app full-stack project, with the /api/pay route on the server. Keep my existing login and signed requests working.