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 broadcast then internalise ordering: cheap validation first, then the gateway broadcasts the client's signed BEEF to ARC itself, and only then is the transaction internalised into the wallet. If the broadcast 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. Broadcast the subject transaction to ARC via arc_client.broadcast(subject_tx) (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).

Vendor broadcast

Step 6 is the load-bearing check. A structurally valid BEEF proves nothing about whether the client actually broadcast the transaction — and under BSV's commerce model, it doesn't have to. The vendor is the settlement point. The gateway calls arc_client.broadcast(subject_tx) itself, classifying the outcome into three buckets:

ARC response Interpretation HTTP response
2xx (accepted, or already-seen idempotency) Tx is on the network Continue to internalise → 200
4xx (malformed, insufficient funds, double-spend) Client submitted an invalid payment 402 "payment broadcast rejected"
5xx / unreachable / timeout Infrastructure fault, not client fault 503 "payment verification temporarily unavailable"

ARC is idempotent — if the client already broadcast the same tx, ARC returns 2xx rather than duplicating work. This means self-funded clients that broadcast themselves, and no_send clients that rely on the vendor, are both handled correctly by the same code path.

ARC is a hard dependency

Vendor-broadcast 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 (malformed BEEF, insufficient funds, double-spend attempts) — the distinction is deliberate and should drive different alert paths.

There is no kill-switch. Vendor-broadcast is not optional — without it there is no meaningful enforcement of NO PAY → NO CONTENT. For dev or staging flows that don't broadcast, mock the gateway or avoid enabling payment middleware on un-gated routes.

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.