Skip to content

BRC-121 Scheme (Simple 402 Payments)

The BSV Association's BRC-121 simple HTTP payment protocol. Stateless server, BRC-100 wallet handles validation and replay detection. Works zero-config with any compatible wallet.

Description

BRC-121 is BRC-105's simpler sibling. The server advertises a price and its identity key; the client builds a BRC-29 payment transaction with a client-generated derivation prefix and a timestamp suffix, then submits it in a single round trip. No handshake, no server-generated nonce, no persistent state on the server.

Replay protection is split between two mechanisms:

  1. Timestamp freshness window (§5 step 2)x-bsv-time must be within 30 seconds of the server's clock. Prevents capture-and-delayed-replay attacks outright.
  2. Wallet-level deduplication (§5 step 5) — the spec requires the server to check isMerge on the wallet's internalisation result and reject if the transaction has already been seen.

Ruby wallet replay gap

The current Ruby BSV::Wallet::WalletClient does not yet expose an isMerge field. BRC121Gateway compensates by using an X402::BSV::TxidStore (in-memory by default) to reject duplicate txids within the freshness window. The 30-second timestamp window remains the primary defence; the TxidStore is a belt-and-braces second layer.

Unlike BSV-pay and BSV-proof, BRC-121 does not use partial transaction templates. The client builds the entire transaction using the BRC-29 derived payment address.

Headers

Direction Header Content
Server -> Client x-bsv-sats Required satoshi amount (decimal string)
Server -> Client x-bsv-server Server's compressed identity public key (hex)
Client -> Server x-bsv-beef base64-encoded BRC-95 BEEF transaction
Client -> Server x-bsv-sender Client's compressed identity public key (hex)
Client -> Server x-bsv-nonce base64-encoded BRC-29 derivation prefix
Client -> Server x-bsv-time Unix millisecond timestamp (decimal string)
Client -> Server x-bsv-vout Output index of the payment output (decimal string)
Server -> Client x-bsv-payment-satoshis-paid Amount settled (in successful 200 response)

Challenge

Server sends 402 Payment Required with two headers:

HTTP/1.1 402 Payment Required
x-bsv-sats: 100
x-bsv-server: 02ab...

That's the entire challenge. No JSON body, no base64 envelope, no derivation prefix.

Proof

Client re-sends the original request unchanged, plus five additional headers:

POST /api/expensive HTTP/1.1
x-bsv-beef: SGVsbG8s...
x-bsv-sender: 03cd...
x-bsv-nonce: YWJjMTIz...
x-bsv-time: 1719500000000
x-bsv-vout: 0

The BEEF transaction contains the BRC-29 derived P2PKH output paying the server at index x-bsv-vout.

Server validation

The gateway enforces a verify then internalise ordering: all cheap validation runs first, then on-chain visibility is confirmed via ARC, and only then is the transaction internalised into the wallet. If visibility verification fails there is no wallet state to roll back — the internalisation never happened.

  1. Check all five client headers are present (missing → 402)
  2. Verify x-bsv-time is within 30 seconds of the server clock (§5 step 2)
  3. Decode BEEF; locate the subject transaction
  4. Check the configured TxidStore for replay (belt-and-braces for the Ruby isMerge gap)
  5. Verify the output at x-bsv-vout pays at least x-bsv-sats satoshis
  6. Verify on-chain visibility via arc_client.status(txid) (see below)
  7. Call wallet.internalize_action(...) with the payment remittance (derivation prefix/suffix and sender identity key)
  8. Return 200 with x-bsv-payment-satoshis-paid in the response headers

If any step fails the server returns 402 (or 400 for malformed input, 503 for ARC outage).

Verify on-chain

Step 6 is the load-bearing check. A structurally valid BEEF proves nothing about whether the client actually broadcast the transaction — only ARC observation does. The gateway polls arc_client.status(txid) with bounded retries (4 attempts, ~1.75 s worst case) to absorb propagation lag, classifying the outcome into three buckets:

ARC status Interpretation HTTP response
SEEN_ON_NETWORK, MINED Tx is live on the network Continue to internalise → 200
UNKNOWN / not found (after retries) Client never broadcast — no_send exploit, client bug, or wrong ARC 402 "payment transaction not visible on the BSV network"
ARC unreachable / 5xx / timeout (after retries) Infrastructure fault, not client fault 503 "payment verification temporarily unavailable"

Positive observations are cached per-gateway for ~30 s keyed by txid, protecting ARC from duplicate-submission bursts. Negative observations are never cached — propagation can flip UNKNOWNSEEN_ON_NETWORK at any moment, and a cached negative would delay legitimate payments.

ARC is a hard dependency

On-chain verification makes ARC reachability a hard runtime dependency. Operators should monitor ARC health (5xx rate, latency) the same way they monitor their database. An ARC outage will surface as elevated 503s. Elevated 402s instead indicate client misbehaviour or exploit attempts — the distinction is deliberate and should drive different alert paths.

For local development and unit testing where wallets may never broadcast, set config.verify_on_chain = false (or X402_VERIFY_ON_CHAIN=false). The default is true; disabling it emits a WARN banner at startup.

Configuration

The minimal zero-config setup:

X402.configure do |c|
  c.domain = "api.example.com"
  c.wallet = X402::Wallet.load       # reads ~/.bsv-wallet/wallet.key
  c.arc_url = "https://arcade.gorillapool.io"
  c.protect method: :GET, path: "/api/expensive", amount_sats: 100
end

use X402::Middleware

BRC121Gateway is auto-enabled whenever wallet: is set. PayGateway is also auto-enabled when arc_url: is available. Clients can pay via either protocol — the middleware dispatches on proof header.

For explicit control:

config.enable :brc121_gateway                        # uses shared wallet
config.enable :brc121_gateway, wallet: other_wallet  # per-gateway override

See process flow for the full settlement sequence.