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 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.
- 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 - Broadcast the subject transaction to ARC via
arc_client.broadcast(subject_tx)(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).
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.