When HTTP/1.1 was published in 1997, it included a status code for payments. 402 Payment Required. The spec said it was "reserved for future use." The web got 200, 301, 404, 500 — but the money code was left empty, waiting for someone to fill it in.
Meanwhile, the web built an entire payment infrastructure around HTTP. Stripe. PayPal. App stores taking 30%. Cookie-based subscriptions. Login walls. Every solution required a middleman, an account, and trust in a third party.
The status code was always there. The web just never had the pieces to use it. Now it does:
| Piece | What It Does | From |
|---|---|---|
| Solid pod | Hosts content with fine-grained access control | Part 1 |
| Nostr key | Cryptographic identity + payment address | Part 2 |
| PaymentCondition | One field in an ACL — "pay to read" | This article |
| Testnet sats | Free Bitcoin test tokens for development | Faucets |
In Part 1 you learned that access control is just a JSON file. Public, private, per-user — all controlled by .acl files. A PaymentCondition is one more field in that same file:
{
"@context": {"acl": "http://www.w3.org/ns/auth/acl#"},
"@type": "acl:Authorization",
"acl:agentClass": {"@id": "acl:AuthenticatedAgent"},
"acl:accessTo": {"@id": "/premium/article.json"},
"acl:mode": [{"@id": "acl:Read"}],
"acl:condition": {
"@type": "PaymentCondition",
"amount": "0",
"currency": "tbtc4"
}
}
That's a membership gate. amount: "0" means: don't charge anything, just verify the reader has deposited sats at some point. Prove you're a paying customer. No charge per read.
When someone requests this resource:
| Scenario | Response |
|---|---|
| No authentication | 401 Unauthorized |
| Authenticated, never deposited | 402 Payment Required |
| Authenticated, has deposited | 200 OK + content |
Start JSS with payments enabled (you need --pay):
jss start --pay --pay-cost 1 --pay-chains "tbtc3,tbtc4"
Create Alice's pod if you haven't already:
curl -s -X POST http://localhost:4443/.pods \
-H "Content-Type: application/json" \
-d '{"name": "alice"}' | jq .
TOKEN="paste-your-token-here"
Write a premium article:
curl -X PUT http://localhost:4443/alice/public/premium.json \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/ld+json" \
-d '{
"@context": "https://schema.org",
"@type": "Article",
"headline": "The Future of Web Payments",
"author": "Alice",
"articleBody": "This article is behind a membership gate. If you can read this, you have deposited sats."
}'
Now lock it with a PaymentCondition:
curl -X PUT http://localhost:4443/alice/public/premium.json.acl \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/ld+json" \
-d '[
{
"@context": {"acl": "http://www.w3.org/ns/auth/acl#"},
"@id": "#owner",
"@type": "acl:Authorization",
"acl:agent": {"@id": "http://localhost:4443/alice/profile/card#me"},
"acl:accessTo": {"@id": "http://localhost:4443/alice/public/premium.json"},
"acl:mode": [{"@id": "acl:Read"}, {"@id": "acl:Write"}, {"@id": "acl:Control"}]
},
{
"@context": {"acl": "http://www.w3.org/ns/auth/acl#"},
"@id": "#members",
"@type": "acl:Authorization",
"acl:agentClass": {"@id": "acl:AuthenticatedAgent"},
"acl:accessTo": {"@id": "http://localhost:4443/alice/public/premium.json"},
"acl:mode": [{"@id": "acl:Read"}],
"acl:condition": {
"@type": "PaymentCondition",
"amount": "0",
"currency": "tbtc4"
}
}
]'
Now try to read it. You need a Nostr key (from Part 2). Generate a NIP-98 auth header and request the article:
curl -H "Authorization: Nostr $NIP98_TOKEN" \ http://localhost:4443/alice/public/premium.json
{
"type": "PaymentRequired",
"amount": "0",
"currency": "tbtc4"
}
There it is. The status code that waited twenty-nine years. The server checked your identity, found you in the ACL, saw the PaymentCondition, checked the ledger — and you have no balance. 402.
Here's the fun part. Your Nostr key is a secp256k1 keypair. The same curve Bitcoin uses. That means your Nostr public key is also a valid taproot Bitcoin address.
| Function | Same Key |
|---|---|
| Sign Nostr events | ✓ |
| Authenticate to a Solid pod (did:nostr) | ✓ |
| Receive Bitcoin (taproot address) | ✓ |
| Hold a balance in a webledger | ✓ |
To pass the membership gate, you need to deposit testnet sats. Testnet4 is Bitcoin's test network — the sats are free, the protocol is identical to mainnet. JSS uses it by default for development.
Get testnet4 sats from a faucet. The simplest way to deposit: get your personal deposit address and send sats directly from any wallet:
curl http://localhost:4443/pay/.address?chain=tbtc4
# {"address": "tb1p...", "chain": "tbtc4"}
Send testnet sats to that address. Then check your balance — the pod detects the deposit automatically:
curl -H "Authorization: Nostr $NIP98_TOKEN" \ http://localhost:4443/pay/.balance
Alternatively, deposit a TXO URI directly if you have one (the format is txo:chain:txid:vout):
curl -X POST http://localhost:4443/pay/.deposit \ -H "Authorization: Nostr $NIP98_TOKEN" \ -d "txo:tbtc4:abc123def456...789:0"
Either way, the server verifies the transaction on the testnet4 mempool API and credits your did:nostr in the webledger:
{
"did": "did:nostr:5e7617...45af",
"deposited": 5000,
"balance": 5000,
"unit": "sat",
"chain": "tbtc4"
}
Your key now has a balance. The same key you used to authenticate.
curl -H "Authorization: Nostr $NIP98_TOKEN" \ http://localhost:4443/alice/public/premium.json
{
"@context": "https://schema.org",
"@type": "Article",
"headline": "The Future of Web Payments",
"author": "Alice",
"articleBody": "This article is behind a membership gate. If you can read this, you have deposited sats."
}
You're in. The server checked your identity, found the PaymentCondition, looked up your did:nostr in the webledger, confirmed you have an entry, and served the content. No sats were deducted — the gate is free. You just proved you're a member.
A twenty-nine-year-old HTTP status code, a W3C access control spec, a Nostr cryptographic key, and a Bitcoin testnet transaction came together to create a membership gate. No Stripe. No PayPal. No app store. No email. No account. Just HTTP, cryptography, and a few bytes of JSON.
The same system works on mainnet with real sats. Change tbtc4 to btc and the membership gate costs real money. The code is identical.
| Use Case | How |
|---|---|
| Members-only blog | amount: "0" — deposit once, read forever |
| Anti-spam API | Require a balance to make requests — spammers won't deposit |
| Premium content | amount: "100" — charge per read (Part 4) |
| Tiered access | Different amounts for different resources |
| Anonymous membership | Nostr key + sats = identity without email |
The PaymentCondition is just another condition in the Web Access Control spec. The same ACL can combine it with specific agents, authenticated users, or any other condition. The server evaluates them all — and if it encounters a condition type it doesn't understand, it denies access. Fail-closed by design.
Part 4: Pay Per Read. Change one number and the server charges sats on every request. Watch the balance tick down. The web finally has a business model that doesn't require surveillance.