Skip to content

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

bash
mkdir s402-demo && cd s402-demo
npm init -y
npm install s402        # or: pnpm add s402 / bun add s402 / deno add npm:s402

Node.js >= 18 required (for native fetch and Headers).

Step 1: The Server

Create server.ts:

typescript
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:

typescript
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:

bash
npx tsx server.ts
# or: bun server.ts

In another terminal, run the client:

bash
npx tsx client.ts

You 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: 390ms

What Just Happened

  1. Client sent a normal GET request
  2. Server responded with HTTP 402 + payment requirements in the payment-required header
  3. Client decoded the requirements, saw it accepts exact, built a payment payload
  4. Client retried the request with the signed payload in the x-payment header
  5. Server decoded the payment, verified it (mocked here), settled it, and returned the joke with a receipt in the payment-response header

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

To connect this to Sui, replace the mock payment in client.ts with real Sui SDK code:

typescript
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

Released under the Apache 2.0 License.