A specification for token-gated API access using NIP-98 authentication.
HTTP 402 Payment Required is an underutilized status code designed for digital payment systems. This specification defines how to use it with:
- NIP-98 for authentication (Nostr HTTP Auth)
- Token balances for authorization
- Automatic deduction on successful requests
Client Server
| |
| GET /api/resource |
|-------------------------------------->|
| |
| 402 Payment Required |
| { cost: 1, nip98: {...} } |
|<--------------------------------------|
| |
| GET /api/resource |
| Authorization: Nostr <base64-event> |
|-------------------------------------->|
| |
| (verify event, deduct tokens) |
| |
| 200 OK |
| { data: ..., remaining: 99 } |
|<--------------------------------------|
Create a kind 27235 event with the request details:
{
"kind": 27235,
"created_at": 1704000000,
"tags": [
["u", "http://api.example.com/resource"],
["method", "GET"]
],
"content": "",
"pubkey": "<64-char-hex>",
"id": "<64-char-hex>",
"sig": "<128-char-hex>"
}Base64 encode and send in the Authorization header:
Authorization: Nostr eyJraW5kIjoyNzIzNSwi...
When payment is required:
{
"error": "Insufficient balance",
"cost": 1,
"balance": 0,
"depositAddress": "tb1p...",
"nip98": {
"kind": 27235,
"url": "http://api.example.com/resource",
"method": "GET"
}
}- Parse
Authorization: Nostr <base64>header - Decode and parse JSON event
- Verify event kind is 27235
- Verify
created_atis within 60 seconds - Verify
utag matches request URL exactly - Verify
methodtag matches request method - Verify event ID matches serialized content hash
- Verify schnorr signature
- Check user balance >= cost
- Deduct tokens and process request
Include remaining balance in response:
{
"data": { ... },
"paid": 1,
"remaining": 99
}Users acquire tokens through:
- AMM deposit - Send sats to deposit address, receive tokens
- Transfer - Receive tokens from another user
- Faucet - Free tokens for testing
Users are identified by their Nostr public key as a DID:
did:nostr:<64-char-hex-pubkey>
Reference implementation: gitmark-amm
// Server-side verification
function verify402Payment(req, cost, fullUrl) {
const authHeader = req.headers['authorization'];
if (!authHeader?.startsWith('Nostr ')) {
return { authorized: false, error: 'Missing NIP-98 Authorization header' };
}
const base64 = authHeader.slice(6);
const event = JSON.parse(Buffer.from(base64, 'base64').toString('utf-8'));
// Verify event...
if (event.kind !== 27235) return { authorized: false, error: 'Invalid kind' };
// ... (see full implementation in gitmark-amm)
}
// Client-side event creation
function createNip98Event(method, url, privkey) {
const event = {
kind: 27235,
created_at: Math.floor(Date.now() / 1000),
tags: [['u', url], ['method', method]],
content: '',
pubkey: getPublicKey(privkey)
};
event.id = sha256(serialize(event));
event.sig = schnorr.sign(event.id, privkey);
return event;
}- Replay protection: 60-second timestamp window
- URL binding: Event is bound to specific URL
- Method binding: Event is bound to HTTP method
- No secrets in URL: Use POST body for sensitive data
- HTTPS recommended: Prevents header interception
Public Domain (CC0)