Operations Guide
Best practices, performance tuning, and troubleshooting for T402 in production.
Best Practices
Guidelines for building robust t402-enabled applications.
Payment Flow Design
Verify Before Processing
Always verify payments before executing expensive operations:
// Good: Verify first, then process
app.post('/api/compute', async (req, res) => {
const payment = extractPayment(req);
// Verify payment BEFORE doing expensive work
const verified = await facilitator.verify(payment, requirements);
if (!verified.isValid) {
return res.status(402).json({ error: 'Invalid payment' });
}
// Now do the expensive computation
const result = await expensiveComputation(req.body);
// Settle after success
await facilitator.settle(payment, requirements);
return res.json(result);
});Idempotent Settlements
Handle duplicate settlement attempts gracefully:
// Track processed payments
const processedPayments = new Set();
async function settlePayment(payment, requirements) {
const paymentId = `${payment.nonce}-${payment.from}`;
if (processedPayments.has(paymentId)) {
return { alreadyProcessed: true };
}
const result = await facilitator.settle(payment, requirements);
processedPayments.add(paymentId);
return result;
}Atomic Operations
Ensure payment and service delivery are atomic:
// Use database transactions
async function processPayment(payment, requirements) {
const tx = await db.beginTransaction();
try {
// Record payment intent
await tx.insert('payments', {
nonce: payment.nonce,
status: 'pending',
});
// Settle payment
const result = await facilitator.settle(payment, requirements);
// Update status
await tx.update('payments', {
status: 'completed',
txHash: result.transaction,
});
await tx.commit();
return result;
} catch (error) {
await tx.rollback();
throw error;
}
}Error Handling
Graceful Degradation
// Provide fallback for payment failures
async function handleRequest(req, res) {
try {
const payment = extractPayment(req);
const result = await processWithPayment(payment);
return res.json(result);
} catch (error) {
if (error.code === 'FACILITATOR_UNAVAILABLE') {
// Fallback to queued processing
await queueForLaterProcessing(req);
return res.status(202).json({
message: 'Payment queued for processing',
});
}
throw error;
}
}Informative Error Messages
// Return helpful error information
function handlePaymentError(error, res) {
const errorResponses = {
INVALID_SIGNATURE: {
status: 402,
message: 'Payment signature is invalid. Please sign again.',
},
EXPIRED_PAYMENT: {
status: 402,
message: 'Payment has expired. Create a new payment.',
},
INSUFFICIENT_FUNDS: {
status: 402,
message: 'Insufficient balance in payer wallet.',
},
NETWORK_MISMATCH: {
status: 400,
message: 'Payment network does not match required network.',
},
};
const response = errorResponses[error.code] || {
status: 500,
message: 'Payment processing failed',
};
return res.status(response.status).json({
error: response.message,
code: error.code,
retryable: error.retryable ?? false,
});
}Retry Logic
// Implement exponential backoff for transient failures
async function settleWithRetry(payment, requirements, maxRetries = 3) {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await facilitator.settle(payment, requirements);
} catch (error) {
if (!error.retryable || attempt === maxRetries) {
throw error;
}
// Exponential backoff
const delay = Math.min(1000 * Math.pow(2, attempt), 10000);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}Security
Validate All Inputs
import { z } from 'zod';
// Define strict schemas
const PaymentPayloadSchema = z.object({
t402Version: z.literal(2),
scheme: z.string().min(1),
network: z.string().regex(/^(eip155|solana|ton|tron|near|aptos|tezos|polkadot|stacks|cosmos):/),
payload: z.object({
authorization: z.object({
from: z.string(),
to: z.string(),
value: z.string(),
validAfter: z.string(),
validBefore: z.string(),
nonce: z.string(),
}),
signature: z.string(),
}),
});
function validatePayment(data: unknown) {
return PaymentPayloadSchema.parse(data);
}Protect Against Replay Attacks
Each payment nonce should only be usable once. Track used nonces to prevent replay attacks.
// Use Redis for nonce tracking
import Redis from 'ioredis';
const redis = new Redis();
async function checkNonce(nonce: string, network: string) {
const key = `nonce:${network}:${nonce}`;
// Set with expiry (24 hours)
const set = await redis.set(key, '1', 'EX', 86400, 'NX');
if (!set) {
throw new Error('Payment nonce already used');
}
}Amount Validation
// Validate payment amounts match requirements
function validateAmount(payment, requirements) {
const paymentAmount = BigInt(payment.payload.authorization.value);
const requiredAmount = BigInt(requirements.maxAmountRequired);
if (paymentAmount < requiredAmount) {
throw new Error(
`Insufficient payment: got ${paymentAmount}, need ${requiredAmount}`
);
}
// Optional: Check for overpayment
const maxOverpayment = requiredAmount * 2n;
if (paymentAmount > maxOverpayment) {
throw new Error('Payment amount suspiciously high');
}
}User Experience
Clear Payment Requirements
// Return detailed payment requirements
function buildPaymentRequired(route, req) {
return {
t402Version: 2,
accepts: route.accepts.map((option) => ({
...option,
// Include human-readable descriptions
description: route.description,
displayPrice: formatPrice(option.price),
networkName: getNetworkName(option.network),
})),
resource: {
path: req.path,
method: req.method,
description: route.description,
},
error: 'Payment required to access this resource',
};
}Progress Indicators
// For long-running settlements, provide status updates
app.get('/api/payment/:id/status', async (req, res) => {
const status = await getPaymentStatus(req.params.id);
res.json({
status: status.state, // 'pending', 'verifying', 'settling', 'completed'
progress: status.progress, // 0-100
message: status.message,
estimatedTime: status.estimatedTime,
});
});Helpful Error Pages
// Custom 402 response handler
function handle402(requirements, res) {
res.status(402).json({
...requirements,
// Add helpful context
help: {
documentation: 'https://docs.t402.io/getting-started',
supportedWallets: ['MetaMask', 'Coinbase Wallet', 'WalletConnect'],
faucets: {
'eip155:84532': 'https://faucet.base.org',
},
},
});
}Testing
Unit Tests
import { describe, it, expect, vi } from 'vitest';
describe('PaymentMiddleware', () => {
it('should return 402 when no payment provided', async () => {
const req = createMockRequest('/api/premium');
const res = createMockResponse();
await paymentMiddleware(req, res, next);
expect(res.status).toHaveBeenCalledWith(402);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
t402Version: 2,
accepts: expect.any(Array),
})
);
});
it('should proceed when valid payment provided', async () => {
const req = createMockRequest('/api/premium', {
headers: { 'payment-signature': validPaymentHeader },
});
const res = createMockResponse();
vi.spyOn(facilitator, 'verify').mockResolvedValue({ isValid: true });
await paymentMiddleware(req, res, next);
expect(next).toHaveBeenCalled();
});
});Integration Tests
import { test, expect } from '@playwright/test';
test('complete payment flow', async ({ request }) => {
// Step 1: Request without payment
const initial = await request.get('/api/premium');
expect(initial.status()).toBe(402);
const requirements = await initial.json();
expect(requirements.accepts).toHaveLength(1);
// Step 2: Create payment
const payment = await createTestPayment(requirements.accepts[0]);
// Step 3: Request with payment
const paid = await request.get('/api/premium', {
headers: { 'payment-signature': encodePayment(payment) },
});
expect(paid.status()).toBe(200);
expect(paid.headers()['payment-response']).toBeDefined();
});Load Testing
// k6 load test script
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
vus: 50,
duration: '5m',
};
export default function () {
// Test 402 response time
const res = http.get('https://api.example.com/premium');
check(res, {
'status is 402': (r) => r.status === 402,
'response time < 500ms': (r) => r.timings.duration < 500,
});
sleep(1);
}Monitoring and Alerting
Key Metrics
// Track these metrics
const metrics = {
// Counters
paymentsTotal: new Counter('t402_payments_total'),
paymentsSuccess: new Counter('t402_payments_success'),
paymentsFailed: new Counter('t402_payments_failed'),
// Histograms
verificationLatency: new Histogram('t402_verification_latency'),
settlementLatency: new Histogram('t402_settlement_latency'),
// Gauges
pendingSettlements: new Gauge('t402_pending_settlements'),
};Alert Thresholds
| Metric | Warning | Critical |
|---|---|---|
| Payment failure rate | >5% | >10% |
| Verification latency p99 | >3s | >10s |
| Settlement latency p99 | >30s | >60s |
| Facilitator error rate | >1% | >5% |
| Pending settlements | >100 | >500 |
Documentation
API Documentation
/**
* Premium data endpoint
*
* @route GET /api/premium
* @group Premium - Premium data access
*
* @payment
* - scheme: exact
* - network: eip155:8453
* - price: $0.01
*
* @returns {object} 200 - Premium data
* @returns {PaymentRequired} 402 - Payment required
*/
app.get('/api/premium', handler);Changelog
Maintain a changelog for payment-related changes:
## [1.2.0] - 2026-01-15
### Added
- Support for TON payments on /api/premium endpoint
### Changed
- Increased price for /api/compute from $0.05 to $0.10
### Fixed
- Fixed race condition in settlement processingPerformance Tuning
Optimize T402 for high-throughput payment processing.
Connection Management
HTTP Client Pooling
Reuse HTTP connections to reduce latency and resource usage.
import { Agent } from 'undici';
// Create a connection pool
const agent = new Agent({
keepAliveTimeout: 60_000,
keepAliveMaxTimeout: 120_000,
connections: 100,
pipelining: 1,
});
// Use with fetch
const response = await fetch(url, { dispatcher: agent });RPC Connection Management
Use multiple RPC providers with fallback for reliability.
import { createPublicClient, http, fallback } from 'viem';
import { base } from 'viem/chains';
const client = createPublicClient({
chain: base,
transport: fallback([
http('https://mainnet.base.org'),
http('https://base.llamarpc.com'),
http('https://base.drpc.org'),
], {
rank: true, // Automatically rank by latency
retryCount: 3,
retryDelay: 150,
}),
});Caching Strategies
Payment Requirements Cache
Cache payment requirements to reduce 402 round-trips.
import { LRUCache } from 'lru-cache';
const requirementsCache = new LRUCache<string, PaymentRequirements[]>({
max: 1000, // Maximum entries
ttl: 1000 * 60 * 5, // 5 minute TTL
});
async function getRequirements(url: string): Promise<PaymentRequirements[]> {
const cached = requirementsCache.get(url);
if (cached) return cached;
// Fetch fresh requirements
const response = await fetch(url);
if (response.status === 402) {
const requirements = parseRequirements(response);
requirementsCache.set(url, requirements);
return requirements;
}
throw new Error('No payment required');
}Token Balance Cache
Cache wallet balances to avoid redundant RPC calls.
const balanceCache = new Map<string, { balance: bigint; timestamp: number }>();
const BALANCE_TTL = 30_000; // 30 seconds
async function getCachedBalance(
address: string,
token: string,
chainId: number
): Promise<bigint> {
const key = `${chainId}:${token}:${address}`;
const cached = balanceCache.get(key);
if (cached && Date.now() - cached.timestamp < BALANCE_TTL) {
return cached.balance;
}
const balance = await fetchBalance(address, token, chainId);
balanceCache.set(key, { balance, timestamp: Date.now() });
return balance;
}Nonce Management
Pre-fetch and cache nonces to avoid delays.
class NonceManager {
private nonces = new Map<string, bigint>();
private locks = new Map<string, Promise<void>>();
async getNextNonce(address: string, chainId: number): Promise<bigint> {
const key = `${chainId}:${address}`;
// Wait for any pending nonce operation
const lock = this.locks.get(key);
if (lock) await lock;
let nonce = this.nonces.get(key);
if (nonce === undefined) {
nonce = await this.fetchOnChainNonce(address, chainId);
}
this.nonces.set(key, nonce + 1n);
return nonce;
}
resetNonce(address: string, chainId: number): void {
this.nonces.delete(`${chainId}:${address}`);
}
}Batch Operations
Batch Settlements
Group multiple settlements into a single facilitator call (when supported).
// Instead of settling one by one
for (const payment of payments) {
await facilitator.settle(payment); // Slow
}
// Batch settle
const results = await facilitator.settleBatch(payments); // FastBatch settlement support depends on the facilitator implementation. Check /supported endpoint for capabilities.
Parallel Verification
Verify multiple payments concurrently.
// Sequential (slow)
const results = [];
for (const payment of payments) {
results.push(await facilitator.verify(payment));
}
// Parallel (fast)
const results = await Promise.all(
payments.map(payment => facilitator.verify(payment))
);Rate Limiting
Client-Side Rate Limiting
Implement rate limiting to avoid hitting API limits.
import Bottleneck from 'bottleneck';
const limiter = new Bottleneck({
maxConcurrent: 10, // Max concurrent requests
minTime: 100, // Min time between requests (ms)
reservoir: 100, // Requests per interval
reservoirRefreshAmount: 100,
reservoirRefreshInterval: 60_000, // Refresh every minute
});
// Wrap your API calls
const verify = limiter.wrap(async (payload: PaymentPayload) => {
return await facilitator.verify(payload);
});Exponential Backoff
Handle rate limit errors gracefully.
async function withRetry<T>(
fn: () => Promise<T>,
maxRetries = 3,
baseDelay = 1000
): Promise<T> {
let lastError: Error;
for (let i = 0; i < maxRetries; i++) {
try {
return await fn();
} catch (error) {
lastError = error as Error;
// Check if it's a rate limit error
if (error.code === 'T402-2005') {
const delay = baseDelay * Math.pow(2, i);
await new Promise(resolve => setTimeout(resolve, delay));
continue;
}
throw error;
}
}
throw lastError;
}Server-Side Optimization
Middleware Configuration
Optimize payment middleware for high traffic.
import { paymentMiddleware } from '@t402/express';
app.use(paymentMiddleware(routes, resourceServer, {
// Skip payment check for health endpoints
skip: (req) => req.path === '/health' || req.path === '/metrics',
// Cache verification results
verificationCache: {
ttl: 60_000, // 1 minute
max: 10_000, // Max entries
},
// Concurrent verification limit
maxConcurrentVerifications: 50,
}));Async Settlement
Settle payments asynchronously after sending response.
app.get('/api/data', paymentRequired(config), async (req, res) => {
// Send response immediately
res.json({ data: 'your content' });
// Settle payment in background
setImmediate(async () => {
try {
await resourceServer.settle(req.payment);
} catch (error) {
logger.error('Settlement failed', { error, paymentId: req.payment.id });
// Queue for retry
await settlementQueue.add(req.payment);
}
});
});Async settlement improves response times but requires robust error handling and retry mechanisms.
Monitoring
Key Metrics to Track
| Metric | Description | Target |
|---|---|---|
t402_verification_duration_ms | Time to verify payment | < 100ms |
t402_settlement_duration_ms | Time to settle payment | < 500ms |
t402_cache_hit_rate | Requirements cache hit rate | > 80% |
t402_rpc_latency_ms | Blockchain RPC latency | < 200ms |
t402_error_rate | Payment error rate | < 1% |
Prometheus Integration
import { Counter, Histogram } from 'prom-client';
const verificationDuration = new Histogram({
name: 't402_verification_duration_seconds',
help: 'Payment verification duration',
labelNames: ['network', 'scheme', 'status'],
buckets: [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5],
});
const settlementCounter = new Counter({
name: 't402_settlements_total',
help: 'Total payment settlements',
labelNames: ['network', 'scheme', 'status'],
});
// Use in middleware
const timer = verificationDuration.startTimer({ network, scheme });
try {
const result = await verify(payment);
timer({ status: 'success' });
settlementCounter.inc({ network, scheme, status: 'success' });
} catch (error) {
timer({ status: 'error' });
settlementCounter.inc({ network, scheme, status: 'error' });
}Hardware Recommendations
Production Server
| Component | Minimum | Recommended |
|---|---|---|
| CPU | 2 cores | 4+ cores |
| RAM | 4 GB | 8+ GB |
| Storage | SSD 50 GB | NVMe 100 GB |
| Network | 100 Mbps | 1 Gbps |
Scaling Guidelines
- < 100 req/s: Single instance
- 100-1000 req/s: 2-4 instances with load balancer
- > 1000 req/s: Kubernetes with horizontal pod autoscaling
Performance Checklist
Before going to production, verify:
- Connection pooling configured
- Caching implemented for requirements and balances
- Rate limiting in place
- Monitoring and alerting configured
- Async settlement with retry queue (if using)
- Load testing completed
- Fallback RPC providers configured
Troubleshooting
Common issues and solutions when working with T402.
Payment Verification Failures
T402-1006: Invalid Signature
Symptoms: Payment verification fails with “Signature verification failed”
Common Causes:
- Wrong signer: The address signing doesn’t match the
fromaddress - Chain ID mismatch: Signing for wrong chain
- Incorrect typed data: Domain separator or message hash mismatch
Solutions:
// Ensure signer address matches payload
const signer = privateKeyToAccount(privateKey);
console.log('Signer address:', signer.address);
// Verify chain ID
const chainId = await client.getChainId();
console.log('Chain ID:', chainId);T402-1011: Expired Payment
Symptoms: “Payment has expired” error
Cause: The validBefore timestamp has passed
Solution: Generate a new payment with a fresh deadline:
const deadline = Math.floor(Date.now() / 1000) + 3600; // 1 hour from nowAlways use server time, not client time, to avoid clock skew issues.
Settlement Failures
T402-3003: Insufficient Balance
Symptoms: Settlement fails with “Insufficient balance”
Diagnostic Steps:
- Check token balance on the source chain
- Verify the correct token contract is being used
- Ensure balance covers amount + gas (if paying gas)
# Using t402 CLI
t402 balance 0xYourAddress --network eip155:8453T402-3002: Settlement Failed
Symptoms: Generic settlement failure
Common Causes:
- Nonce already used: Payment was already settled
- Gas price spike: Transaction underpriced
- RPC issues: Node connection problems
Solutions:
- Check transaction status on block explorer
- Retry with fresh nonce
- Try alternative RPC endpoint
Network Connectivity
T402-4001: Chain Unavailable
Symptoms: Cannot connect to blockchain RPC
Solutions:
- Check RPC URL: Ensure endpoint is correct and accessible
// Test RPC connection
const blockNumber = await provider.getBlockNumber();
console.log('Connected, block:', blockNumber);- Use fallback RPCs: Configure multiple providers
const providers = [
'https://mainnet.base.org',
'https://base.llamarpc.com',
'https://base.drpc.org',
];- Check rate limits: You may be hitting RPC rate limits
T402-2004: RPC Unavailable (Facilitator)
Symptoms: Facilitator cannot reach blockchain
Action: This is a server-side issue. Check:
- https://facilitator.t402.io/health
- https://status.t402.io (if available)
Debug Logging
Enable Verbose Logging
// Enable debug mode
process.env.T402_DEBUG = 'true';
// Or use debug package
import debug from 'debug';
debug.enable('t402:*');Inspect Payment Payloads
# Decode a base64 payment payload
t402 decode eyJzY2hlbWUiOiJleGFjdCIs...
# Verify payload matches requirements
t402 verify <payload> --requirements <requirements>Common Integration Issues
CORS Errors (Browser)
Symptoms: “CORS policy” errors in browser console
Solution: The resource server must include CORS headers:
// Express.js
app.use(cors({
origin: ['https://your-frontend.com'],
exposedHeaders: ['Payment-Required', 'Payment-Response', 'Payment-Signature'],
}));Missing Headers
Symptoms: 402 response but no payment requirements
Check: Ensure Payment-Required header is exposed:
// Client-side: Check response headers
console.log(response.headers.get('Payment-Required'));Framework Middleware Order
Symptoms: Payments not being processed
Solution: Ensure T402 middleware runs before route handlers:
// CORRECT: Payment middleware before routes
app.use(paymentMiddleware(config));
app.get('/api/data', handler);
// WRONG: Routes before middleware
app.get('/api/data', handler);
app.use(paymentMiddleware(config)); // Too late!Gasless Payment Issues
ERC-4337: UserOperation Failed
Symptoms: “AA” prefixed errors (AA21, AA25, etc.)
| Error | Meaning | Solution |
|---|---|---|
| AA21 | Didn’t pay prefund | Paymaster rejected sponsorship |
| AA23 | Reverted during validation | Check calldata and account |
| AA25 | Invalid signature | Wrong owner or signature format |
| AA31 | Paymaster stake too low | Use different paymaster |
Paymaster Rejection
Symptoms: Paymaster refuses to sponsor
Common Causes:
- Daily limit exceeded
- Token not on allowlist
- Amount too high/low
Solution: Check paymaster policies or use self-pay mode
Getting Help
If you can’t resolve your issue:
- Search existing issues: GitHub Issues
- Check documentation: docs.t402.io
- Open a new issue with:
- Error code and message
- SDK version
- Minimal reproduction code
- Network and chain details
When reporting issues, never share private keys or production credentials.