Agent Public Payment — Local-Signed Payment Workflow
Full AI automation for X402 cross-chain payments using AgentTech SDK in public mode - generate wallets locally, sign X402 authorization locally, create intents, submit proofs, and poll status via SDK. No API key required, full control over private keys.
This skill enables AI agents to complete cross-chain stablecoin payments end-to-end without a browser or human: generate wallets locally, create a payment intent via the SDK, sign the x402 payload with your local key, submit the proof, and poll until settled. The payer chain and the target (merchant) chain can differ — both legs settle through the x402 protocol; the SDK derives the correct signing flavor for each chain from payment_requirements.
Use Case: Choose this mode when you need full control over private keys and want to sign transactions locally. No API key required - perfect for agents that need direct control over signing and wallet management. Private keys never leave your machine.
SDK Support: This skill uses the AgentTech SDK (JavaScript/TypeScript and Go) in public mode (PublicPayClient). No API key required - you sign X402 proofs locally.
JSON Schema Definition
{
"name": "agent_public_payment",
"description": "Complete end-to-end X402 cross-chain payment automation for AI agents using AgentTech SDK in public mode. Generates wallets locally, creates intents, signs X402 proofs locally, submits proofs, and polls for completion. No API key required, full control over private keys.",
"input_schema": {
"type": "object",
"properties": {
"recipient": {
"type": "string",
"description": "Recipient wallet address (Base 0x address) or email address"
},
"email": {
"type": "string",
"description": "Recipient email address (alternative to recipient)"
},
"amount": {
"type": "string",
"description": "USD amount as string (e.g. '10.50'). Minimum: 0.01, Maximum: 1,000,000. Up to 6 decimal places."
},
"payer_chain": {
"type": "string",
"description": "Source chain identifier. See Supported Chains documentation for the full list of supported chains."
},
"wallet_type": {
"type": "string",
"enum": ["evm", "solana"],
"description": "Type of wallet to use for signing (must match payer_chain)"
}
},
"required": ["amount", "payer_chain", "wallet_type"],
"oneOf": [
{ "required": ["email"] },
{ "required": ["recipient"] }
]
},
"output_schema": {
"type": "object",
"properties": {
"intent_id": {
"type": "string",
"description": "Unique identifier for the created intent"
},
"status": {
"type": "string",
"enum": ["TARGET_SETTLED", "EXPIRED", "VERIFICATION_FAILED", "PARTIAL_SETTLEMENT"],
"description": "Final status of the payment"
},
"transaction_hash": {
"type": "string",
"description": "Transaction hash on the target chain (available when status is TARGET_SETTLED)"
},
"payer_wallet": {
"type": "object",
"description": "Generated wallet information"
}
},
"required": ["intent_id", "status"]
}
}
Quick Start (4 Steps)
- Generate and save wallets (EVM for Base, Solana) — see Step 1: Generate Wallets.
- Create payment intent — Use SDK
createIntent()withemailorrecipient,amount,payer_chain→ getintent_idandpayment_requirements. - Sign X402 locally using
payment_requirementsand your wallet private key → producesettle_proof(base64 string). See Step 2: Sign and Produce settle_proof. - Submit proof — Use SDK
submitProof(intentId, settleProof), then pollgetIntent(intentId)untilstatusisTARGET_SETTLED(success) or a terminal failure (EXPIRED,VERIFICATION_FAILED,PARTIAL_SETTLEMENT).
Complete Example (TypeScript)
import { PublicPayClient } from '@cross402/usdc';
import { Wallet } from 'ethers';
import { buildEVMsettleProof } from './x402-signing';
async function completeX402Payment(recipient: string, amount: string) {
// Step 1: Generate or load wallet
const wallet = Wallet.createRandom();
const payerAddress = wallet.address;
const privateKey = wallet.privateKey;
// Step 2: Initialize SDK client
const client = new PublicPayClient({
baseUrl: 'https://api-pay.agent.tech',
});
// Step 3: Create intent — payer on Base, merchant paid on Ethereum.
const intent = await client.createIntent({
recipient,
amount,
payerChain: 'base',
targetChain: 'ethereum',
});
console.log(`Intent created: ${intent.intentId}`);
console.log(`Payment requirements:`, intent.paymentRequirements);
// Step 4: Sign X402 proof locally
const settleProof = buildEVMsettleProof(
intent.paymentRequirements,
payerAddress,
privateKey
);
// Step 5: Submit proof
const result = await client.submitProof(intent.intentId, settleProof);
console.log(`Proof submitted. Status: ${result.status}`);
// Step 6: Poll until completion
let finalIntent = result;
while (
finalIntent.status !== 'TARGET_SETTLED' &&
finalIntent.status !== 'PARTIAL_SETTLEMENT' &&
finalIntent.status !== 'EXPIRED' &&
finalIntent.status !== 'VERIFICATION_FAILED'
) {
await new Promise(resolve => setTimeout(resolve, 3000));
finalIntent = await client.getIntent(intent.intentId);
console.log(`Status: ${finalIntent.status}`);
}
if (finalIntent.status === 'TARGET_SETTLED') {
console.log(`Payment complete! Transaction: ${finalIntent.targetPayment.txHash}`);
return {
success: true,
intentId: finalIntent.intentId,
txHash: finalIntent.targetPayment.txHash,
};
} else {
throw new Error(`Payment failed: ${finalIntent.status}`);
}
}
Step 1: Generate Wallets
Generate payer wallets locally. Private keys never leave your machine; the SDK only ever receives a signed settle_proof.
EVM wallet (any EVM payer chain)
TypeScript/JavaScript (ethers):
npm install ethers
import { Wallet } from 'ethers';
const wallet = Wallet.createRandom();
const evmWallet = {
type: 'evm',
symbol: 'ETH',
address: wallet.address,
private_key: wallet.privateKey,
mnemonic: wallet.mnemonic?.phrase,
};
console.log(`EVM Address: ${wallet.address}`);
console.log(`Private Key: ${wallet.privateKey}`);
Go:
package main
import (
"crypto/ecdsa"
"fmt"
"github.com/ethereum/go-ethereum/accounts"
"github.com/ethereum/go-ethereum/crypto"
)
func generateEVMWallet() (string, string, error) {
privateKey, err := crypto.GenerateKey()
if err != nil {
return "", "", err
}
publicKey := privateKey.Public()
publicKeyECDSA, ok := publicKey.(*ecdsa.PublicKey)
if !ok {
return "", "", fmt.Errorf("error casting public key")
}
address := crypto.PubkeyToAddress(*publicKeyECDSA).Hex()
privateKeyHex := fmt.Sprintf("%x", crypto.FromECDSA(privateKey))
return address, privateKeyHex, nil
}
Solana
TypeScript/JavaScript (@solana/web3.js):
npm install @solana/web3.js
import { Keypair } from '@solana/web3.js';
import * as bs58 from 'bs58';
const keypair = Keypair.generate();
const solWallet = {
type: 'solana',
symbol: 'SOL',
address: keypair.publicKey.toBase58(),
private_key: bs58.encode(keypair.secretKey),
};
console.log(`Solana Address: ${keypair.publicKey.toBase58()}`);
Go:
package main
import (
"encoding/base64"
"fmt"
"github.com/gagliardetto/solana-go"
)
func generateSolanaWallet() (string, string, error) {
keypair := solana.NewWallet()
address := keypair.PublicKey().String()
privateKey := base64.StdEncoding.EncodeToString(keypair.PrivateKey)
return address, privateKey, nil
}
Save Wallets Locally
Store credentials at ~/.config/x402pay/wallets.json (or equivalent).
TypeScript:
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
function saveWallets(evmWallet: any, solWallet: any) {
const walletsData = {
created_at: new Date().toISOString(),
wallets: [evmWallet, solWallet],
};
const configDir = path.join(os.homedir(), '.config', 'x402pay');
fs.mkdirSync(configDir, { recursive: true });
const filePath = path.join(configDir, 'wallets.json');
fs.writeFileSync(filePath, JSON.stringify(walletsData, null, 2));
console.log(`Wallets saved to ${filePath}`);
}
Go:
package main
import (
"encoding/json"
"os"
"path/filepath"
"time"
)
type WalletData struct {
CreatedAt string `json:"created_at"`
Wallets []Wallet `json:"wallets"`
}
func saveWallets(evmWallet, solWallet Wallet) error {
walletsData := WalletData{
CreatedAt: time.Now().UTC().Format(time.RFC3339),
Wallets: []Wallet{evmWallet, solWallet},
}
configDir := filepath.Join(os.Getenv("HOME"), ".config", "x402pay")
os.MkdirAll(configDir, 0755)
filePath := filepath.Join(configDir, "wallets.json")
data, err := json.MarshalIndent(walletsData, "", " ")
if err != nil {
return err
}
return os.WriteFile(filePath, data, 0600)
}
Security: Private keys are generated and stored only on your machine. The SDK never receives your private key; it only receives the base64-encoded signed payload (settle_proof).
Step 2: Sign and Produce settle_proof
The SDK expects settle_proof to be exactly: Base64(JSON.stringify(x402_v2_payload)). The backend decodes base64, parses JSON, verifies the payload (including accepted.amount vs intent), and forwards it to the X402 facilitator for settlement. If the format is wrong, verification fails (400).
Always use the payment_requirements returned by createIntent() when building the payload — do not hardcode chain IDs or contract addresses.
Choosing the Signing Method
The signing method is a property of the payer chain, not of the target chain. Cross402 communicates the method via payment_requirements returned by createIntent(). Always inspect the returned object rather than hardcoding per chain:
function chooseSigningMethod(paymentRequirements: PaymentRequirements): string {
const network = paymentRequirements.network;
// Solana chains
if (network.startsWith('solana:')) {
return 'solana';
}
// EVM chains — the Permit2 + EIP-2612 path is opt-in per chain
if (paymentRequirements.extra?.assetTransferMethod === 'permit2') {
return 'permit2';
}
return 'eip3009';
}
The signing method is independent from the target_chain you picked. Whether the merchant receives on Base, Ethereum, Polygon, or Solana, the payer-side signing path is whatever payment_requirements tells you.
2a. EIP-3009 path — TransferWithAuthorization
TypeScript Example (ethers):
2b. Permit2 path — PermitWitnessTransferFrom + EIP-2612 Gas Sponsoring
When payment_requirements.extra.assetTransferMethod === "permit2" the payer chain requires the Permit2 flow (BSC / Monad / MegaETH USDC; Polygon USDT / USDT0; the exact set is whatever the backend flags). This flow requires two signatures:
Polygon USDT / USDT0 note: When
extra.domainType === "salted"is also present, the EIP-2612Permitmust be signed against the Polygon-PoS salted EIP-712 domain —EIP712Domain(name, version, verifyingContract, bytes32 salt)wheresalt = bytes32(chainID)— instead of the standardchainId-in-domain layout. The Permit2PermitWitnessTransferFromdomain is unchanged. See USDT Signing — Polygon.
- Permit2 PermitWitnessTransferFrom — authorizes the X402 proxy to transfer USDC via Permit2
- EIP-2612 Permit — approves the canonical Permit2 contract to spend your USDC (gas sponsoring: the backend submits the tx, so the payer pays no gas)
Constants (same on all EVM chains):
const PERMIT2_ADDRESS = '0x000000000022D473030F116dDEE9F6B43aC78BA3';
const X402_EXACT_PERMIT2_PROXY = '0x402085c248EeA27D92E8b30b2C58ed07f9E20001';
TypeScript Example (ethers):
import { Wallet, Contract } from 'ethers';
import * as crypto from 'crypto';
function randomNonce256Hex(): string {
return '0x' + crypto.randomBytes(32).toString('hex');
}
function randomNonce256Decimal(): string {
const bytes = crypto.randomBytes(32);
let value = 0n;
for (const b of bytes) {
value = (value << 8n) | BigInt(b);
}
return value.toString();
}
async function buildPermit2SettleProof(
paymentRequirements: PaymentRequirements,
payerAddress: string,
privateKey: string,
rpcUrl: string
): Promise<string> {
// Parse chainId from CAIP-2 (eip155:42161 -> 42161)
const network = paymentRequirements.network;
const chainIdMatch = network.match(/eip155:(\d+)/);
if (!chainIdMatch) {
throw new Error(`Invalid network format: ${network}`);
}
const chainId = parseInt(chainIdMatch[1], 10);
const amount = paymentRequirements.amount;
const payTo = paymentRequirements.payTo;
const asset = paymentRequirements.asset;
const extra = paymentRequirements.extra || {};
const maxTimeout = paymentRequirements.maxTimeoutSeconds || 600;
const now = Math.floor(Date.now() / 1000);
const validAfter = (now - 600).toString();
const deadline = (now + maxTimeout).toString();
const wallet = new Wallet(privateKey);
// --- Signature 1: Permit2 PermitWitnessTransferFrom ---
const permit2Nonce = randomNonce256Decimal();
const permit2Domain = {
name: 'Permit2',
chainId: chainId,
verifyingContract: PERMIT2_ADDRESS,
};
const permit2Types = {
PermitWitnessTransferFrom: [
{ name: 'permitted', type: 'TokenPermissions' },
{ name: 'spender', type: 'address' },
{ name: 'nonce', type: 'uint256' },
{ name: 'deadline', type: 'uint256' },
{ name: 'witness', type: 'Witness' },
],
TokenPermissions: [
{ name: 'token', type: 'address' },
{ name: 'amount', type: 'uint256' },
],
Witness: [
{ name: 'to', type: 'address' },
{ name: 'validAfter', type: 'uint256' },
],
};
const permit2Message = {
permitted: { token: asset, amount },
spender: X402_EXACT_PERMIT2_PROXY,
nonce: permit2Nonce,
deadline,
witness: { to: payTo, validAfter },
};
const permit2Signature = await wallet.signTypedData(
permit2Domain,
permit2Types,
permit2Message
);
// --- Signature 2: EIP-2612 Permit (approve Permit2 to spend tokens) ---
// Read current nonce from USDC contract
const { JsonRpcProvider } = await import('ethers');
const provider = new JsonRpcProvider(rpcUrl);
const usdcContract = new Contract(
asset,
['function nonces(address owner) view returns (uint256)'],
provider
);
const eip2612Nonce = await usdcContract.nonces(payerAddress);
const permitDomain = {
name: extra.name || 'USD Coin',
version: extra.version || '2',
chainId,
verifyingContract: asset,
};
const permitTypes = {
Permit: [
{ name: 'owner', type: 'address' },
{ name: 'spender', type: 'address' },
{ name: 'value', type: 'uint256' },
{ name: 'nonce', type: 'uint256' },
{ name: 'deadline', type: 'uint256' },
],
};
const permitMessage = {
owner: payerAddress,
spender: PERMIT2_ADDRESS,
value: amount,
nonce: eip2612Nonce.toString(),
deadline,
};
const permitSignature = await wallet.signTypedData(
permitDomain,
permitTypes,
permitMessage
);
// --- Build X402 v2 payload with extensions ---
const payload = {
x402Version: 2,
resource: {
url: paymentRequirements.resource || '/api/intents',
description: paymentRequirements.description || 'X402 payment',
mimeType: 'application/json',
},
accepted: {
scheme: paymentRequirements.scheme || 'exact',
network,
amount,
asset,
payTo,
maxTimeoutSeconds: maxTimeout,
extra: paymentRequirements.extra || {},
},
payload: {
signature: permit2Signature,
permit2Authorization: {
permitted: { token: asset, amount },
from: payerAddress,
spender: X402_EXACT_PERMIT2_PROXY,
nonce: permit2Nonce,
deadline,
witness: { to: payTo, validAfter },
},
},
extensions: {
eip2612GasSponsoring: {
info: {
from: payerAddress,
asset,
spender: PERMIT2_ADDRESS,
amount,
nonce: eip2612Nonce.toString(),
deadline,
signature: permitSignature,
version: '1',
},
},
},
};
return Buffer.from(JSON.stringify(payload)).toString('base64');
}
Key differences from EIP-3009 (Section 2a):
2c. Solana — Three Instructions + VersionedTransaction v0
Partial Sign Required: In Solana X402 payments, the
feePayeris the server's gas-sponsoring address — you do not have its private key. You must use partial signing (sign only with your own keypair) and leave the feePayer's signature slot as zero bytes. The server will add the feePayer's signature after receiving your transaction. Do NOT calltransaction.sign()— usetransaction.addSignature()instead (TypeScript) or manually place your signature at the correct index (Go).
TypeScript Example (@solana/web3.js):
import {
Keypair,
PublicKey,
TransactionMessage,
VersionedTransaction,
SystemProgram,
} from '@solana/web3.js';
import {
createSetComputeUnitLimitInstruction,
createSetComputeUnitPriceInstruction,
createTransferCheckedInstruction,
getAssociatedTokenAddress,
} from '@solana/spl-token';
import nacl from 'tweetnacl';
interface SolanaPaymentRequirements {
scheme: string;
network: string;
amount: string;
asset: string;
payTo: string;
maxTimeoutSeconds: number;
extra?: {
feePayer?: string;
decimals?: number;
};
}
async function buildSolanasettleProof(
paymentRequirements: SolanaPaymentRequirements,
payerKeypair: Keypair
): Promise<string> {
const payTo = new PublicKey(paymentRequirements.payTo);
const asset = new PublicKey(paymentRequirements.asset);
const amount = BigInt(paymentRequirements.amount);
const extra = paymentRequirements.extra || {};
const decimals = extra.decimals || 6;
// IMPORTANT: feePayer is the server's gas-sponsoring address.
// The user does NOT have the feePayer's private key.
// The server will add the feePayer signature after receiving this transaction.
const feePayer = extra.feePayer ? new PublicKey(extra.feePayer) : payTo;
// Get associated token address
const sourceATA = await getAssociatedTokenAddress(asset, payerKeypair.publicKey);
const destATA = await getAssociatedTokenAddress(asset, payTo);
// Build instructions
const instructions = [
// 1. SetComputeUnitLimit
createSetComputeUnitLimitInstruction({ units: 200000 }),
// 2. SetComputeUnitPrice
createSetComputeUnitPriceInstruction({ microLamports: 1 }),
// 3. TransferChecked
createTransferCheckedInstruction(
sourceATA,
asset,
destATA,
payerKeypair.publicKey,
amount,
decimals
),
];
// Build transaction message
const messageV0 = new TransactionMessage({
payerKey: feePayer,
recentBlockhash: '11111111111111111111111111111111', // Will be replaced by backend
instructions,
}).compileToV0Message();
// Create versioned transaction
const transaction = new VersionedTransaction(messageV0);
// CRITICAL: Use partial sign — only sign with YOUR keypair.
// Do NOT use transaction.sign([payerKeypair]) — that would fail because
// sign() expects ALL signers (including feePayer) to be provided.
// The feePayer is the server's address; we don't have its private key.
// addSignature() lets us sign only our part, leaving the feePayer's
// signature slot as zero bytes for the server to fill in later.
transaction.addSignature(payerKeypair.publicKey,
nacl.sign.detached(transaction.message.serialize(), payerKeypair.secretKey)
);
// Serialize and encode
const transactionBytes = transaction.serialize();
const transactionBase64 = Buffer.from(transactionBytes).toString('base64');
// Build X402 v2 payload
const payload = {
x402Version: 2,
resource: {
url: paymentRequirements.resource || '/api/intents',
description: paymentRequirements.description || 'X402 payment',
mimeType: 'application/json',
},
accepted: {
scheme: paymentRequirements.scheme || 'exact',
network: paymentRequirements.network,
amount: paymentRequirements.amount,
asset: paymentRequirements.asset,
payTo: paymentRequirements.payTo,
maxTimeoutSeconds: paymentRequirements.maxTimeoutSeconds,
extra: paymentRequirements.extra || {},
},
payload: {
transaction: transactionBase64,
},
};
// Encode as base64
return Buffer.from(JSON.stringify(payload)).toString('base64');
}
Go Example (github.com/gagliardetto/solana-go):
package main
import (
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"github.com/gagliardetto/solana-go"
)
// buildSolanaSettleProof constructs a Solana X402 settle proof using partial signing.
//
// CRITICAL CONCEPTS:
// - feePayer is the SERVER's gas-sponsoring address. You do NOT have its private key.
// - You must use partial signing: sign only with your own keypair.
// - The Signatures slice must be pre-allocated with the correct length
// (one slot per signer in the compiled message). NewTransaction() does NOT
// do this automatically — you must allocate it yourself.
// - The feePayer is always index 0 in the Signatures slice. Leave it as
// zero bytes (64 bytes of 0x00). The server will fill it in.
// - Your signature goes at the index matching your public key's position
// in the message's account keys.
func buildSolanaSettleProof(
paymentRequirements map[string]interface{},
payerPrivateKey ed25519.PrivateKey,
) (string, error) {
extra, _ := paymentRequirements["extra"].(map[string]interface{})
feePayerStr, _ := extra["feePayer"].(string)
feePayer := solana.MustPublicKeyFromBase58(feePayerStr)
payTo := solana.MustPublicKeyFromBase58(paymentRequirements["payTo"].(string))
asset := solana.MustPublicKeyFromBase58(paymentRequirements["asset"].(string))
_ = payTo
_ = asset
// ... (build instructions: SetComputeUnitLimit, SetComputeUnitPrice, TransferChecked)
// ... (compile to MessageV0 with payerKey = feePayer)
// After compiling the message to messageBytes:
var messageBytes []byte // = compiled message bytes
// Pre-allocate the Signatures slice.
// The number of required signatures is in messageBytes[0] (the first byte
// of a Solana v0 message header). Each signature is 64 bytes of zeros initially.
numSigners := int(messageBytes[0])
signatures := make([]solana.Signature, numSigners)
// All slots are initialized to zero bytes — the feePayer slot (index 0)
// stays as zeros for the server to fill in later.
// Find YOUR public key's index in the account keys and sign at that index.
payerPubkey := solana.PublicKeyFromBytes(payerPrivateKey.Public().(ed25519.PublicKey))
// The feePayer is index 0. Your key will typically be index 1 if you are
// the second signer. Loop through the message's account keys to find it.
myIndex := 1 // Typically 1 when feePayer is index 0 and you are the only other signer
sig := ed25519.Sign(payerPrivateKey, messageBytes)
copy(signatures[myIndex][:], sig)
// Serialize: [compact-array of signatures] + [message bytes]
// Then base64-encode the whole thing for the X402 payload.
_ = feePayer
_ = signatures
// Build X402 v2 payload (same structure as TypeScript example)
payload := map[string]interface{}{
"x402Version": 2,
"payload": map[string]interface{}{
"transaction": base64.StdEncoding.EncodeToString(messageBytes), // replace with full serialized tx
},
// ... include resource, accepted fields
}
payloadJSON, err := json.Marshal(payload)
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(payloadJSON), nil
}
Common Pitfalls (Solana Partial Sign):
Step 3: Complete Payment Flow with SDK
TypeScript/JavaScript Complete Example
import { PublicPayClient } from '@cross402/usdc';
import { Wallet } from 'ethers';
import { buildEVMsettleProof } from './x402-signing';
import { buildPermit2SettleProof } from './x402-permit2-signing';
async function completeX402PaymentFlow(
recipient: string,
amount: string,
payerChain: string
) {
// Initialize SDK client (public mode, no auth required)
const client = new PublicPayClient({
baseUrl: 'https://api-pay.agent.tech',
});
// Step 1: Generate or load wallet
const wallet = Wallet.createRandom();
const payerAddress = wallet.address;
const privateKey = wallet.privateKey;
console.log(`Using payer wallet: ${payerAddress}`);
// Step 2: Create intent
const intent = await client.createIntent({
recipient,
amount,
payerChain,
});
console.log(`Intent created: ${intent.intentId}`);
console.log(`Status: ${intent.status}`);
console.log(`Expires at: ${intent.expiresAt}`);
// Step 3: Sign X402 proof locally (choose method based on payment_requirements)
let settleProof: string;
const pr = intent.paymentRequirements;
if (pr.extra?.assetTransferMethod === 'permit2') {
// Permit2 + EIP-2612 path (BSC/Monad/MegaETH USDC; Polygon USDT/USDT0)
// When extra.domainType === "salted", buildPermit2SettleProof must use the
// salted EIP-712 domain for the EIP-2612 Permit (Polygon-PoS layout).
settleProof = await buildPermit2SettleProof(pr, payerAddress, privateKey, rpcUrl);
} else {
// EIP-3009 TransferWithAuthorization path (Base, Ethereum, Polygon USDC, HyperEVM, Arbitrum, SKALE Base)
settleProof = buildEVMsettleProof(pr, payerAddress, privateKey);
}
console.log('X402 proof signed locally');
// Step 4: Submit proof
const submitResult = await client.submitProof(intent.intentId, settleProof);
console.log(`Proof submitted. Status: ${submitResult.status}`);
// Step 5: Poll until completion
let currentIntent = submitResult;
const maxAttempts = 60; // 10 minutes max (60 * 10 seconds)
let attempts = 0;
while (
currentIntent.status !== 'TARGET_SETTLED' &&
currentIntent.status !== 'PARTIAL_SETTLEMENT' &&
currentIntent.status !== 'EXPIRED' &&
currentIntent.status !== 'VERIFICATION_FAILED' &&
attempts < maxAttempts
) {
await new Promise(resolve => setTimeout(resolve, 10000)); // Poll every 10 seconds
attempts++;
try {
currentIntent = await client.getIntent(intent.intentId);
console.log(`[${attempts}] Status: ${currentIntent.status}`);
} catch (error) {
console.error(`Error polling status: ${error}`);
// Continue polling on transient errors
}
}
// Final status check
if (currentIntent.status === 'TARGET_SETTLED') {
console.log('✅ Payment complete!');
console.log(`Transaction hash: ${currentIntent.targetPayment?.txHash}`);
return {
success: true,
intentId: currentIntent.intentId,
status: currentIntent.status,
txHash: currentIntent.targetPayment?.txHash,
};
} else {
console.error(`❌ Payment failed: ${currentIntent.status}`);
return {
success: false,
intentId: currentIntent.intentId,
status: currentIntent.status,
};
}
}
// Usage — payer pays on Base, merchant receives on Base (same-chain).
completeX402PaymentFlow(
'0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb',
'10.50',
'base'
).then(result => {
console.log('Final result:', result);
});
Secondary example — EVM payer, Solana merchant (email recipient)
The same flow handles a payer on Base paying a merchant identified by email who should receive on Solana. Two things to notice:
targetChain: "solana"tells Cross402 to pay out on Solana — the proxy wallet will sign a Solana VersionedTransaction on the target side. The payer still signs EIP-3009 on Base (payer-side signing followspayer_chain, nottarget_chain).- Because the target chain is Solana, Privy resolves the email to the merchant's Solana wallet, not their EVM wallet. The same email would resolve to an EVM wallet if you'd passed an EVM target.
// 1. Create intent with email + Solana target
const intent = await client.createIntent({
email: 'merchant@example.com', // Privy resolves to Solana wallet because target_chain=solana
amount: '10.50',
payerChain: 'base',
targetChain: 'solana',
});
// 2. Sign locally as usual — signing follows payer_chain (Base → EIP-3009).
const settleProof = buildEVMsettleProof(
intent.paymentRequirements,
payerAddress,
privateKey,
);
// 3. Submit and poll until TARGET_SETTLED.
await client.submitProof(intent.intentId, settleProof);
Go Complete Example
package main
import (
"context"
"fmt"
"log"
"time"
"github.com/cross402/usdc-sdk-go"
"github.com/ethereum/go-ethereum/crypto"
)
func completeX402PaymentFlow(
ctx context.Context,
recipient string,
amount string,
payerChain string,
) error {
// Initialize SDK client (public mode, no auth required)
client, err := pay.NewClient("https://api-pay.agent.tech")
if err != nil {
return err
}
// Step 1: Generate wallet
privateKey, err := crypto.GenerateKey()
if err != nil {
return err
}
payerAddress := crypto.PubkeyToAddress(privateKey.PublicKey).Hex()
privateKeyHex := fmt.Sprintf("%x", crypto.FromECDSA(privateKey))
log.Printf("Using payer wallet: %s", payerAddress)
// Step 2: Create intent
resp, err := client.CreateIntent(ctx, &pay.CreateIntentRequest{
Recipient: recipient,
Amount: amount,
PayerChain: payerChain,
})
if err != nil {
return err
}
log.Printf("Intent created: %s", resp.IntentID)
log.Printf("Status: %s", resp.Status)
// Step 3: Sign X402 proof locally
settleProof, err := buildEVMsettleProof(
resp.PaymentRequirements,
payerAddress,
privateKeyHex,
)
if err != nil {
return err
}
log.Println("X402 proof signed locally")
// Step 4: Submit proof
proofResult, err := client.SubmitProof(ctx, resp.IntentID, settleProof)
if err != nil {
return err
}
log.Printf("Proof submitted. Status: %s", proofResult.Status)
// Step 5: Poll until completion
maxAttempts := 60
attempts := 0
currentIntent := proofResult
for attempts < maxAttempts {
if currentIntent.Status == pay.StatusTargetSettled ||
currentIntent.Status == pay.StatusPartialSettlement ||
currentIntent.Status == pay.StatusExpired ||
currentIntent.Status == pay.StatusVerificationFailed {
break
}
time.Sleep(10 * time.Second)
attempts++
intent, err := client.GetIntent(ctx, resp.IntentID)
if err != nil {
log.Printf("Error polling status: %v", err)
continue
}
currentIntent = intent
log.Printf("[%d] Status: %s", attempts, currentIntent.Status)
}
// Final status check
if currentIntent.Status == pay.StatusTargetSettled {
log.Println("✅ Payment complete!")
log.Printf("Transaction hash: %s", currentIntent.TargetPayment.TxHash)
return nil
} else {
return fmt.Errorf("payment failed: %s", currentIntent.Status)
}
}
Payment Flow and Status
After you submit settle_proof, the backend verifies it and settles on the payer chain via the X402 facilitator, then executes the transfer on target_chain. Poll getIntent(intentId) until a terminal status.
Status values:
Valid payer_chain: See Supported Chains for the full list.
stateDiagram-v2
[*] --> AWAITING_PAYMENT
AWAITING_PAYMENT --> PENDING: submitProof()
AWAITING_PAYMENT --> EXPIRED: timeout
PENDING --> SOURCE_SETTLED: verify + settle
PENDING --> VERIFICATION_FAILED: invalid
SOURCE_SETTLED --> TARGET_SETTLING: target transfer
TARGET_SETTLING --> TARGET_SETTLED: done
TARGET_SETTLING --> PARTIAL_SETTLEMENT: target not completed
Error Handling
Common Errors
Error Handling Example
import { PublicPayClient, PayApiError } from '@cross402/usdc';
async function handlePaymentWithRetry(
client: PublicPayClient,
intentId: string,
settleProof: string
) {
let retries = 0;
const maxRetries = 3;
while (retries < maxRetries) {
try {
return await client.submitProof(intentId, settleProof);
} catch (error) {
if (error instanceof PayApiError) {
// Don't retry on client errors
if (error.statusCode === 400 || error.statusCode === 404) {
throw error;
}
// Retry on server errors
if (error.statusCode === 503 || error.statusCode === 500) {
retries++;
if (retries >= maxRetries) {
throw error;
}
await new Promise(resolve => setTimeout(resolve, 1000 * retries));
continue;
}
}
throw error;
}
}
}
Security and Notes
- Private keys are generated and used only locally; never send them to the API or SDK.
- Payer and target chains are independent. The payer can pay from any chain in
GET /api/chains; the merchant receives on whichevertarget_chainyou pass (defaults to"base"if omitted). The signing method (EIP-3009 / Permit2 / Solana) depends on the payer chain, not the target. - Email → wallet resolution is per target chain. When the merchant is identified by email, Privy resolves the email to a wallet on the target chain: passing
targetChain: "solana"returns a Solana address; passing an EVM target returns the merchant's EVM wallet. The email itself does not change; Cross402 picks the right wallet family for the chosen target. - SDK Benefits: The SDK handles HTTP requests, error handling, and response parsing automatically.
- If your AI is the receiver, you can present your receiving addresses (or QR codes) to your human owner so they can pay you via this SDK.
Summary Checklist
- Generate EVM and/or Solana wallets locally; save to
~/.config/x402pay/wallets.json - Initialize SDK client:
new PublicPayClient()(TypeScript) orpay.NewClient()(Go) - Create intent:
client.createIntent()(email or recipient, amount, payer_chain) - Check
payment_requirements.extra.assetTransferMethodto choose signing method - Sign X402 using API-returned payment_requirements:
- EVM payer, EIP-3009 path: TransferWithAuthorization (1 signature)
- EVM payer, Permit2 path: Permit2 + EIP-2612 (2 signatures, needs on-chain nonce read)
- Solana: 3-instruction VersionedTransaction v0
- Build X402 v2 payload and set settle_proof = Base64(JSON.stringify(payload))
- Submit:
client.submitProof(intentId, settleProof) - Poll
client.getIntent(intentId)untilTARGET_SETTLED(success) or a terminal failure (EXPIRED,VERIFICATION_FAILED,PARTIAL_SETTLEMENT) - Handle errors with retry logic for transient failures
Related Links
- Create Intent
- Submit Proof
- Query Intent Status
- Payment Polling
- Error Handling
- API Documentation: Intents