Tutorial: Your First Payment Flow
Build a complete HTTP 402 payment flow in 5 minutes. No Sui connection needed — we'll use in-memory mocks to demonstrate the full protocol.
What You'll Build
Client Server
|--- GET /joke -------->|
|<-- 402 "pay me" ------|
| |
| (build mock payment) |
|--- GET + payment ---->|
|<-- 200 + joke --------|A joke API that requires payment. The client gets a 402, builds a payment, retries, and gets the joke.
Prerequisites
mkdir s402-demo && cd s402-demo
npm init -y
npm install s402 # or: pnpm add s402 / bun add s402 / deno add npm:s402Node.js >= 20 required (for native
fetch,Headers, andglobalThis.crypto).
Step 1: The Server
Create server.ts:
import {
encodePaymentRequired,
decodePaymentPayload,
encodeSettleResponse,
S402_HEADERS,
type s402PaymentRequirements,
type s402SettleResponse,
} from 's402';
const PORT = 3402;
const PRICE = '1000000'; // 0.001 SUI
const requirements: s402PaymentRequirements = {
s402Version: '1',
accepts: ['exact'],
network: 'sui:testnet',
asset: '0x2::sui::SUI',
amount: PRICE,
payTo: '0x' + 'a'.repeat(64), // demo address
expiresAt: Date.now() + 60 * 60 * 1000, // 1 hour
};
const jokes = [
'Why do programmers prefer dark mode? Because light attracts bugs.',
'There are 10 kinds of people: those who understand binary and those who don\'t.',
'A SQL query walks into a bar, sees two tables, and asks: "Can I JOIN you?"',
];
// Minimal HTTP server using Node.js built-in module
import { createServer } from 'node:http';
const server = createServer(async (req, res) => {
if (req.url !== '/joke') {
res.writeHead(404).end('Not found');
return;
}
// Check for payment header
const paymentHeader = req.headers[S402_HEADERS.PAYMENT] as string | undefined;
if (!paymentHeader) {
// No payment — send 402
res.writeHead(402, {
[S402_HEADERS.PAYMENT_REQUIRED]: encodePaymentRequired(requirements),
}).end('Payment Required');
return;
}
// Verify payment (in production, forward to a facilitator)
try {
const payload = decodePaymentPayload(paymentHeader);
// In production: facilitator.process(payload, requirements)
// For this demo, we accept any valid-shaped payload
const settlement: s402SettleResponse = {
success: true,
txDigest: '0x' + 'f'.repeat(64),
finalityMs: 390,
};
const joke = jokes[Math.floor(Math.random() * jokes.length)];
res.writeHead(200, {
'content-type': 'application/json',
[S402_HEADERS.PAYMENT_RESPONSE]: encodeSettleResponse(settlement),
}).end(JSON.stringify({ joke }));
} catch (e) {
res.writeHead(400, { 'content-type': 'application/json' })
.end(JSON.stringify({ error: String(e) }));
}
});
server.listen(PORT, () => {
console.log(`Joke API running on http://localhost:${PORT}/joke`);
console.log(`Price: ${PRICE} MIST (0.001 SUI)`);
});Step 2: The Client
Create client.ts:
import {
extractRequirementsFromResponse,
encodePaymentPayload,
decodeSettleResponse,
S402_HEADERS,
type s402ExactPayload,
} from 's402';
const API_URL = 'http://localhost:3402/joke';
async function getJoke() {
// 1. Request the resource
console.log('→ GET /joke');
const res = await fetch(API_URL);
if (res.status !== 402) {
console.log(' Unexpected status:', res.status);
return;
}
// 2. Read the 402 requirements
const requirements = extractRequirementsFromResponse(res);
if (!requirements) {
console.log(' No payment requirements in response');
return;
}
console.log('← 402 Payment Required');
console.log(` Schemes: [${requirements.accepts.join(', ')}]`);
console.log(` Amount: ${requirements.amount} MIST`);
console.log(` Network: ${requirements.network}`);
console.log(` Pay to: ${requirements.payTo}`);
// 3. Build a payment payload
// In production: use @mysten/sui to build a real PTB and sign it.
// For this demo: a mock payload that satisfies the protocol shape.
const payment: s402ExactPayload = {
s402Version: '1',
scheme: 'exact',
payload: {
transaction: btoa('mock-signed-ptb-bytes'), // would be real PTB
signature: btoa('mock-ed25519-signature'), // would be real sig
},
};
// 4. Retry with payment
console.log('→ GET /joke + x-payment header');
const paidRes = await fetch(API_URL, {
headers: {
[S402_HEADERS.PAYMENT]: encodePaymentPayload(payment),
},
});
if (paidRes.status === 200) {
// 5. Read the joke and settlement receipt
const data = await paidRes.json();
const receiptHeader = paidRes.headers.get(S402_HEADERS.PAYMENT_RESPONSE);
const receipt = receiptHeader ? decodeSettleResponse(receiptHeader) : null;
console.log('← 200 OK');
console.log(` Joke: ${data.joke}`);
if (receipt) {
console.log(` TX Digest: ${receipt.txDigest}`);
console.log(` Finality: ${receipt.finalityMs}ms`);
}
} else {
console.log('← Error:', paidRes.status, await paidRes.text());
}
}
getJoke();Step 3: Run It
In one terminal, start the server:
npx tsx server.ts
# or: bun server.tsIn another terminal, run the client:
npx tsx client.tsYou should see:
→ GET /joke
← 402 Payment Required
Schemes: [exact]
Amount: 1000000 MIST
Network: sui:testnet
Pay to: 0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
→ GET /joke + x-payment header
← 200 OK
Joke: Why do programmers prefer dark mode? Because light attracts bugs.
TX Digest: 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
Finality: 390msWhat Just Happened
- Client sent a normal GET request
- Server responded with HTTP 402 + payment requirements in the
payment-requiredheader - Client decoded the requirements, saw it accepts
exact, built a payment payload - Client retried the request with the signed payload in the
x-paymentheader - Server decoded the payment, verified it (mocked here), settled it, and returned the joke with a receipt in the
payment-responseheader
This is the complete s402 protocol flow. In production, step 3 uses the Sui SDK to build a real PTB, and step 5 uses a facilitator to verify and broadcast on-chain.
Making It Real
🛑 The snippet below will not connect today. Sui has deprecated JSON-RPC on public fullnodes —
https://fullnode.{testnet,mainnet}.sui.ioreturns-32601for every core method (verified 2026-08-16). ASuiClientpointed at a public fullnode is dead on arrival, so treat this section as illustrating the shape of the integration, not as runnable today. Reaching a live network requires the gRPC or GraphQL transport; migration is tracked separately.Everything above this section is unaffected: the s402 protocol layer has no Sui dependency (ADR-002, invariant S7), so the encoding, validation and error handling you just worked through all still run offline.
To connect this to Sui, replace the mock payment in client.ts with real Sui SDK code:
import { SuiClient } from '@mysten/sui/client';
import { Transaction } from '@mysten/sui/transactions';
import { Ed25519Keypair } from '@mysten/sui/keypairs/ed25519';
// Build a real payment
const tx = new Transaction();
const [coin] = tx.splitCoins(tx.gas, [requirements.amount]);
tx.transferObjects([coin], requirements.payTo);
const keypair = new Ed25519Keypair(); // or load from keystore
const suiClient = new SuiClient({ url: 'https://fullnode.testnet.sui.io' });
const txBytes = await tx.build({ client: suiClient });
const { bytes, signature } = await keypair.signTransaction(txBytes);
const payment: s402ExactPayload = {
s402Version: '1',
scheme: 'exact',
payload: {
transaction: bytes, // base64 TX bytes
signature: signature, // base64 signature
},
};And replace the mock verification in server.ts with a real facilitator that broadcasts the TX on-chain.
Next Steps
- Which Scheme Do I Need? — pick the right scheme for your use case
- Error Handling — handle failures gracefully
- Security Model — understand the trust boundaries
- API Reference — full type and function reference