Connect payment to actual delivery.
Sign once for a one-hour login. Every request still checks live paid access. For websites with a server or API.
1. Create a plan and bind your service origin
Create a plan with your merchant wallet, price, period and HTTPS origin. Pin its plan ID in trusted server configuration; do not accept arbitrary buyer-selected plans. A checkout link alone does not protect content.
2. Download the components and configure a session secret
access.mjs ↓ · access-session.mjs ↓
Place both files in lib. Import access-session.mjs only on the server; browsers import access.mjs. Install viem and set BBBFI_ACCESS_SESSION_SECRET to a random value of at least 32 bytes in the merchant server environment. Never prefix it with NEXT_PUBLIC_.
npm install viem@2.48.1
# Generate a merchant-server secret, then store it in your server environment:
node -e "console.log(require('node:crypto').randomBytes(32).toString('base64url'))"3. Verify on the server before delivery
// pages/api/member-content.js — trusted SERVER config only.
import { createPublicClient, http, parseAbi } from 'viem';
import { issueAccessSession, verifyAccessSession } from '../../lib/access-session.mjs';
const client = createPublicClient({ transport: http('https://xlayer.drpc.org') });
const deployment = { enabled: true, chainId: 196, address: "0x36757BaA2F0b767Ea4DCFb434F46ACD020046f47" };
const abi = parseAbi(['function hasAccess(uint256,address,bytes32) view returns(bool,uint64)']);
const options = { secret: process.env.BBBFI_ACCESS_SESSION_SECRET,
planId: "YOUR_PLAN_ID", audience: "https://YOUR-SERVICE.example",
resource: '/api/member-content', maxAge: 3600,
};
export default async function handler(req, res) {
res.setHeader('Cache-Control', 'private, no-store');
if (req.method !== 'GET') return res.status(405).end();
try {
const header = req.headers.authorization || '';
let grant;
if (header) {
if (!header.startsWith('BBBFi ') || header.length > 24000) throw Error('Invalid proof');
const proof = JSON.parse(Buffer.from(header.slice(6), 'base64url').toString('utf8'));
const session = await issueAccessSession(client, deployment, abi, proof, options);
grant = session.grant;
res.setHeader('Set-Cookie', 'bbbfi_access=' + session.token
+ '; HttpOnly; Secure; SameSite=Strict; Path=/api/member-content; Max-Age=3600');
} else {
const cookie = (req.headers.cookie || '').split(';').map(s => s.trim())
.find(s => s.startsWith('bbbfi_access='));
grant = await verifyAccessSession(client, deployment, abi,
cookie?.slice('bbbfi_access='.length), options);
}
// Every request checks fresh onchain paid status, including session reads.
return res.json({ content: 'YOUR_PAID_CONTENT', paidUntil: grant.paidUntil });
} catch { return res.status(401).json({ error: 'Paid membership required' }); }
}4. Customer signs in; subsequent reads use the session
// First request: one read-only wallet signature, no token spending.
import { createWalletClient, custom } from 'viem';
import { accessProofMessage } from './access.mjs';
const wallet = createWalletClient({ transport: custom(window.ethereum) });
const [address] = await wallet.getAddresses();
const proof = { version: 1, chainId: 196,
contract: "0x36757BaA2F0b767Ea4DCFb434F46ACD020046f47", planId: "YOUR_PLAN_ID", address,
audience: "https://YOUR-SERVICE.example", resource: '/api/member-content',
issuedAt: Math.floor(Date.now() / 1000), nonce: crypto.randomUUID(),
};
proof.signature = await wallet.signMessage({ account: address, message: accessProofMessage(proof) });
const encoded = btoa(JSON.stringify(proof)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
await fetch('/api/member-content', { headers: { Authorization: 'BBBFi ' + encoded } });
// Subsequent reads use the HttpOnly session cookie without another wallet prompt.
const response = await fetch('/api/member-content');The initial signature binds chain, contract, plan, wallet, origin, resource and a one-minute proof window. It is a short-lived reusable read proof, not a one-use receipt or spending mandate. The example exchanges it for a one-hour server login, using an HttpOnly Secure cookie for later reads. A session authenticates a wallet only: every request still checks fresh onchain entitlement, rejecting refunds, expiry and failed reads. The maximum configurable login lifetime is 12 hours.
Before serving paid customers
- Deny unpaid, expired, wrong-plan/origin, tampered-cookie and invalid-signature requests. Cancellation preserves paid time; a refund closes it immediately.
- Deploy the cookie example over HTTPS. Keep protected content and the session secret out of frontend bundles. Merchants host their content/API.
- Merchants remain responsible for invoices, taxes, delivery and refunds. Public source and tests are not an independent audit.