x402 Payment Protocol
x402 is Coinbase's HTTP 402 payment protocol designed for one-time, per-request payments. Unlike MPP which supports session management and streaming workflows, x402 focuses on simplicity: each API request requires a separate payment. This makes x402 ideal for simple API access, single-shot AI inference requests, and scenarios where session state is undesirable or unnecessary.
Protocol Overview
x402 implements a streamlined two-phase flow:
- Payment Required Response: When a client requests a paid resource without payment proof, the server responds with HTTP 402 and an
X402PaymentRequired structure specifying the payment amount, recipient, and facilitator information. - Payment Payload Submission: The client creates an
X402PaymentPayload proving they have paid (or will pay) the required amount. This payload is attached to the retry request via the X-Payment header. The server verifies the payment and grants access.
x402 is stateless by design: the server does not maintain session state between requests. Each request is fully self-contained with payment proof. This simplifies server implementation but requires clients to pay for every request individually.
Core Components
X402PaymentRequired
The X402PaymentRequired structure is returned by the server in the HTTP 402 response body. It specifies payment requirements:
- amount: Payment amount in smallest denomination (e.g., 1e18 = 1 TNZO)
- currency: "TNZO" on Tenzro Network
- recipient: Payment recipient wallet address (API provider)
- facilitator: Optional payment facilitator service (Coinbase Pay, etc.)
- memo: Optional payment memo/reference (invoice ID, resource identifier)
- expires_at: Payment requirement expiration timestamp
// Server creates payment requirement
use tenzro_payments::x402::*;
let payment_required = X402PaymentRequired {
amount: 100_000_000_000_000_000, // 0.1 TNZO
currency: "TNZO".into(),
recipient: provider_wallet_address.clone(),
facilitator: Some(X402Facilitator {
name: "Tenzro Network".into(),
url: "https://api.tenzro.network/payment".into(),
supported_methods: vec!["wallet".into(), "hosted".into()],
}),
memo: Some("invoice-12345".into()),
expires_at: Some(chrono::Utc::now() + chrono::Duration::minutes(10)),
};
// Return HTTP 402 with payment requirement
HttpResponse::PaymentRequired()
.header("WWW-Authenticate", "x402")
.json(&payment_required)
X402PaymentPayload
The X402PaymentPayload is created by the client after receiving a payment requirement. It proves payment was made:
- payment_id: Unique payment identifier (UUID)
- payer: Client wallet address
- recipient: Must match payment requirement recipient
- amount: Amount paid (must match or exceed requirement)
- currency: Must match payment requirement currency
- transaction_hash: On-chain transaction hash proving payment (optional for pre-authorization)
- signature: Ed25519 signature over canonical payload by payer
- timestamp: Payment creation timestamp
// Client creates payment payload
use tenzro_wallet::WalletService;
use tenzro_crypto::signatures::Ed25519Signer;
// Option 1: Pay first, then create payload with transaction hash
let tx = wallet.send_transaction(
payment_required.recipient.clone(),
payment_required.amount,
Asset::Tnzo,
).await?;
let mut payload = X402PaymentPayload {
payment_id: Uuid::new_v4().to_string(),
payer: wallet.get_address(&Asset::Tnzo).await?,
recipient: payment_required.recipient.clone(),
amount: payment_required.amount,
currency: payment_required.currency.clone(),
transaction_hash: Some(tx.hash.clone()),
signature: vec![],
timestamp: chrono::Utc::now(),
};
// Option 2: Create payload as pre-authorization (no tx hash yet)
// Server will check signature and may escrow payment
let mut payload = X402PaymentPayload {
payment_id: Uuid::new_v4().to_string(),
payer: wallet.get_address(&Asset::Tnzo).await?,
recipient: payment_required.recipient.clone(),
amount: payment_required.amount,
currency: payment_required.currency.clone(),
transaction_hash: None, // Server will pull funds or verify balance
signature: vec![],
timestamp: chrono::Utc::now(),
};
// Sign payload
let signing_payload = payload.signing_payload();
let signature = wallet.sign(&signing_payload).await?;
payload.signature = signature;
// Attach to retry request
client
.post(original_url)
.header("X-Payment", base64::encode(serde_json::to_vec(&payload)?))
.json(&request_body)
.send()
.await?
X402Facilitator
The optional X402Facilitator structure advertises payment facilitation services. Clients without direct wallet access can redirect to the facilitator URL for hosted payment:
pub struct X402Facilitator {
pub name: String, // "Coinbase Pay", "Tenzro Wallet"
pub url: String, // Redirect URL for payment flow
pub supported_methods: Vec<String>, // ["wallet", "hosted", "card"]
}
// Client redirects to facilitator for payment
if let Some(facilitator) = payment_required.facilitator {
let payment_url = format!(
"{}?amount={}¤cy={}&recipient={}&return_url={}",
facilitator.url,
payment_required.amount,
payment_required.currency,
payment_required.recipient,
url::encode(original_url),
);
// Redirect user to payment_url
// After payment, facilitator redirects back with payment payload
}
Complete x402 Flow Example
Here is a complete example showing the full x402 lifecycle from payment requirement to verification:
use tenzro_payments::x402::*;
use tenzro_payments::PaymentProtocol;
use tenzro_wallet::WalletService;
// 1. CLIENT: Initial request without payment
let response = client
.get("https://api.provider.com/data/premium-dataset")
.send()
.await?;
// 2. SERVER: Returns HTTP 402 with payment requirement
if response.status() == 402 {
let payment_required: X402PaymentRequired = response.json().await?;
println!("Payment required: {} {}",
payment_required.amount as f64 / 1e18,
payment_required.currency);
// 3. CLIENT: Execute payment on-chain
let tx = wallet.send_transaction(
payment_required.recipient.clone(),
payment_required.amount,
Asset::Tnzo,
).await?;
println!("Payment sent: {}", tx.hash);
// 4. CLIENT: Create and sign payment payload
let mut payload = X402PaymentPayload {
payment_id: Uuid::new_v4().to_string(),
payer: wallet.get_address(&Asset::Tnzo).await?,
recipient: payment_required.recipient.clone(),
amount: payment_required.amount,
currency: payment_required.currency.clone(),
transaction_hash: Some(tx.hash.clone()),
signature: vec![],
timestamp: chrono::Utc::now(),
};
let signing_payload = payload.signing_payload();
let signature = wallet.sign(&signing_payload).await?;
payload.signature = signature;
// 5. CLIENT: Retry request with payment proof
let response = client
.get("https://api.provider.com/data/premium-dataset")
.header("X-Payment", base64::encode(serde_json::to_vec(&payload)?))
.send()
.await?;
// 6. SERVER: Verify payment payload
let x402 = X402Protocol::new(challenge_store.clone(), settlement_engine.clone());
let payment_header = request.headers().get("X-Payment")
.ok_or("Missing payment")?;
let payload_bytes = base64::decode(payment_header)?;
let payload: X402PaymentPayload = serde_json::from_slice(&payload_bytes)?;
// Verify signature
let verification = x402.verify_credential(&payload).await?;
if !verification.verified {
return HttpResponse::PaymentRequired()
.json(&json!({ "error": "Invalid payment proof" }));
}
// Verify on-chain transaction if hash provided
if let Some(tx_hash) = &payload.transaction_hash {
let tx = blockchain.get_transaction(tx_hash).await?;
if tx.to != payment_required.recipient || tx.amount < payment_required.amount {
return HttpResponse::PaymentRequired()
.json(&json!({ "error": "Invalid transaction" }));
}
}
// 7. SERVER: Return protected resource
HttpResponse::Ok()
.json(&premium_dataset);
// 8. CLIENT: Access granted
let data = response.json().await?;
Ok(data)
}
Server-Side Implementation
Implementing an x402-protected endpoint on Tenzro Network:
use tenzro_payments::x402::*;
use axum::{Extension, Json, http::StatusCode};
#[derive(Clone)]
pub struct X402Config {
pub amount: u64,
pub currency: String,
pub recipient: String,
}
async fn protected_endpoint(
Extension(x402_config): Extension<X402Config>,
Extension(x402_protocol): Extension<Arc<X402Protocol>>,
headers: HeaderMap,
) -> Result<Json<DataResponse>, (StatusCode, Json<X402PaymentRequired>)> {
// Check for payment header
let payment_header = match headers.get("X-Payment") {
Some(h) => h,
None => {
// No payment, return 402 with requirement
let payment_required = X402PaymentRequired {
amount: x402_config.amount,
currency: x402_config.currency.clone(),
recipient: x402_config.recipient.clone(),
facilitator: Some(X402Facilitator {
name: "Tenzro Network".into(),
url: "https://wallet.tenzro.network/pay".into(),
supported_methods: vec!["wallet".into()],
}),
memo: Some("data-access".into()),
expires_at: Some(chrono::Utc::now() + chrono::Duration::minutes(10)),
};
return Err((StatusCode::PAYMENT_REQUIRED, Json(payment_required)));
}
};
// Decode and verify payment payload
let payload_bytes = base64::decode(payment_header.to_str().unwrap())
.map_err(|_| (
StatusCode::BAD_REQUEST,
Json(X402PaymentRequired::default()),
))?;
let payload: X402PaymentPayload = serde_json::from_slice(&payload_bytes)
.map_err(|_| (
StatusCode::BAD_REQUEST,
Json(X402PaymentRequired::default()),
))?;
// Verify signature and amounts
let verification = x402_protocol.verify_credential(&payload).await
.map_err(|_| (
StatusCode::PAYMENT_REQUIRED,
Json(X402PaymentRequired::default()),
))?;
if !verification.verified {
return Err((
StatusCode::PAYMENT_REQUIRED,
Json(X402PaymentRequired::default()),
));
}
// Payment verified, return protected resource
Ok(Json(DataResponse {
data: fetch_premium_data().await,
}))
}
HTTP Middleware for Automatic x402
Tenzro provides axum middleware for automatic x402 challenge/verification:
use tenzro_payments::middleware::X402Middleware;
use axum::{Router, routing::get};
let x402_config = X402Config {
amount: 100_000_000_000_000_000, // 0.1 TNZO per request
currency: "TNZO".into(),
recipient: provider_wallet.get_address(&Asset::Tnzo).await?,
};
let x402_middleware = X402Middleware::new(
x402_protocol.clone(),
x402_config.clone(),
);
let app = Router::new()
.route("/premium-data", get(handle_premium_data))
.route("/premium-inference", get(handle_premium_inference))
.layer(x402_middleware);
// Middleware automatically:
// 1. Checks for X-Payment header
// 2. If missing, returns HTTP 402 with payment requirement
// 3. If present, verifies payment payload signature
// 4. Validates transaction hash if provided
// 5. Allows request on successful verification
Comparison: x402 vs MPP
When should you use x402 versus MPP on Tenzro Network?
Use x402 when:
- You want simple, stateless payment verification
- Each request is independent (no multi-turn workflows)
- Payment amount is fixed per request
- You don't need session management overhead
- Clients can tolerate one on-chain transaction per request
- You want Coinbase ecosystem compatibility
Use MPP when:
- You need session management for multi-turn conversations
- Payment amounts vary per request (e.g., per-token AI inference)
- You want to amortize on-chain costs across many requests
- You need prepaid balance and voucher management
- You want streaming workflows with incremental billing
- You want Stripe/Tempo ecosystem integration
Integration with Tenzro Settlement
x402 integrates with Tenzro's settlement engine for payment finalization:
use tenzro_settlement::{SettlementEngine, SettlementRequest, SettlementMethod};
// Server settles x402 payment
let settlement_request = SettlementRequest {
request_id: payload.payment_id.clone(),
payer: payload.payer.clone(),
recipient: payload.recipient.clone(),
amount: payload.amount,
currency: Asset::Tnzo,
method: SettlementMethod::Immediate,
proof: None, // x402 uses signature verification, not ZK proofs
};
let settlement_result = settlement_engine
.settle(settlement_request)
.await?;
// If payload included transaction_hash, verify it matches settlement
if let Some(expected_tx) = payload.transaction_hash {
if settlement_result.transaction_hash != expected_tx {
return Err("Transaction hash mismatch")?;
}
}
// Settlement confirmed, grant access
log::info!("x402 payment settled: {} TNZO from {} to {}",
payload.amount as f64 / 1e18,
payload.payer,
payload.recipient
);
Client SDK Integration
Tenzro's TypeScript SDK provides automatic x402 support:
import { TenzroClient } from '@tenzro/sdk';
const client = new TenzroClient({
rpcUrl: 'https://rpc.tenzro.network',
wallet: myWallet, // Connects to browser wallet or private key
});
// SDK automatically handles x402 flow
try {
const data = await client.fetchWithPayment(
'https://api.provider.com/premium-data',
{
method: 'GET',
autoPayX402: true, // Enable automatic x402 payment
}
);
console.log('Data received:', data);
} catch (err) {
if (err.code === 'PAYMENT_REQUIRED') {
console.log('Payment required:', err.paymentDetails);
// Manual payment flow if needed
const payment = await client.createX402Payment(err.paymentDetails);
const data = await client.fetchWithPayment(url, {
payment: payment,
});
}
}
// Under the hood, SDK:
// 1. Makes initial request
// 2. Receives HTTP 402 with X402PaymentRequired
// 3. Creates payment transaction on-chain
// 4. Builds X402PaymentPayload with transaction hash
// 5. Signs payload with wallet
// 6. Retries request with X-Payment header
// 7. Returns response data
Security Considerations
Signature Verification: Always verify the payment payload signature using Ed25519 via tenzro_crypto::signatures::verify(). Unsigned payloads must be rejected.
Transaction Validation: If a transaction hash is provided, verify it on-chain before granting access. Check recipient, amount, and confirmation status.
Amount Matching: Verify payment amount matches or exceeds the requirement. Reject underpayments.
Expiration: If the payment requirement includes an expiration, reject stale payment payloads.
Replay Protection: Track used payment IDs to prevent replay attacks. A payment payload should only grant access once.
Next Steps
- Explore Tempo integration for stablecoin settlement
- Review settlement architecture for payment finalization
- Compare with MPP for session-based payments
- Understand TDIP identity binding for delegated authorization