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:
- Timestamp freshness window (§5 step 2) —
x-bsv-timemust be within 30 seconds of the server's clock. Prevents capture-and-delayed-replay attacks outright. - Wallet-level deduplication (§5 step 5) — the spec requires the server to check
isMergeon 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:
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.
- Check all five client headers are present (missing → 402)
- Verify
x-bsv-timeis within 30 seconds of the server clock (§5 step 2) - Decode BEEF; locate the subject transaction
- Check the configured
TxidStorefor replay (belt-and-braces for the RubyisMergegap) - Verify the output at
x-bsv-voutpays at leastx-bsv-satssatoshis - Verify on-chain visibility via
arc_client.status(txid)(see below) - Call
wallet.internalize_action(...)with the payment remittance (derivation prefix/suffix and sender identity key) - Return
200withx-bsv-payment-satoshis-paidin 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 UNKNOWN → SEEN_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.