x402 payment best practices on solana
making a single x402 payment work is straightforward. making x402 payments work reliably, safely, and at scale — that's where the details matter.
this guide covers the patterns that separate a prototype from a production system. error handling, security, monitoring, token management, testing, rate limiting, and the pitfalls that catch most teams the first time around.
none of this is theoretical. these patterns come from building and operating parasoldex, and from watching early adopters hit every edge case in the book.
---
error handling
payments fail. networks get congested. balances change between checking and spending. rpc nodes go down. servers go down. your code needs to handle all of it without losing money or getting stuck.
retry logic with exponential backoff
not all failures are permanent. rpc timeouts, network congestion, and transient server errors resolve themselves. retry them, but don't retry blindly.
``typescript
async function withRetry
fn: () => Promise
options: {
maxAttempts?: number;
baseDelayMs?: number;
maxDelayMs?: number;
retryableErrors?: string[];
} = {}
): Promise
const {
maxAttempts = 3,
baseDelayMs = 1000,
maxDelayMs = 10000,
retryableErrors = ["timeout", "network_error", "rpc_error"],
} = options;
let lastError: Error;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error;
const isRetryable = retryableErrors.some(
(e) => error.code === e || error.message?.includes(e)
);
if (!isRetryable || attempt === maxAttempts) {
throw error;
}
const delay = Math.min(
baseDelayMs Math.pow(2, attempt - 1) + Math.random() 500,
maxDelayMs
);
console.log(
attempt ${attempt}/${maxAttempts} failed: ${error.message}. +
retrying in ${Math.round(delay)}ms
);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
throw lastError;
}
`
key points:
errors you should not retry
some failures are permanent. retrying wastes time and can cause double-payments.
| error | retry? | action |
|---|---|---|
| insufficient balance | no | log, alert, wait for funding |
| invalid recipient address | no | log, alert developer |
| payment amount exceeds limit | no | log, escalate for human review |
| transaction already processed | no | payment succeeded, proceed |
| slippage exceeded | maybe | retry once with higher slippage, then abort |
| rpc timeout | yes | retry with backoff |
| network congestion | yes | retry with backoff and higher priority fee |
| 402 response expired | no | re-fetch the 402 to get a new payment offer |
handling double-payment risk
the most dangerous failure mode: a payment transaction confirms but your agent doesn't see the confirmation (rpc timeout, process crash, network partition). the agent retries, sends a second payment.
protect against this:
`typescript
const PAYMENT_CACHE = new Map
async function makePayment(paymentMemo: string, paymentDetails: PaymentDetails) {
const existingTx = PAYMENT_CACHE.get(paymentMemo);
if (existingTx) {
const status = await connection.getSignatureStatus(existingTx);
if (status?.value?.confirmationStatus === "confirmed") {
return { txId: existingTx, alreadyPaid: true };
}
}
const txId = await executePaymentTransaction(paymentDetails);
PAYMENT_CACHE.set(paymentMemo, txId);
return { txId, alreadyPaid: false };
}
`
before sending any payment, check if you've already sent one for that memo. if the previous transaction confirmed, skip the payment and proceed to the retry step.
for production systems, use a persistent store (redis, sqlite, supabase) instead of an in-memory map. if the process restarts, you need that payment history to survive.
---
security
x402 payments involve autonomous money movement. the security surface is different from a human-operated wallet. an agent can't pause and think "does this look right?" — it needs guardrails built in.
never expose private keys
this seems obvious but it happens constantly.
to .gitignore. use git-secrets or similar tools to prevent accidental commits`typescript
// wrong — key in source code
const wallet = Keypair.fromSecretKey(bs58.decode("5K1gZ..."));
// wrong — key logged in error handler
try {
await makePayment(details);
} catch (e) {
console.error("payment failed", { wallet, error: e }); // leaks wallet
}
// right — key from environment, never logged
const wallet = Keypair.fromSecretKey(
bs58.decode(process.env.SOLANA_PRIVATE_KEY)
);
`
use MPC wallets for production
raw keypairs are a single point of failure. if the key leaks, everything is gone. for production agents, use an MPC (multi-party computation) wallet like Turnkey.
with MPC wallets:
`typescript
import { Turnkey } from "@turnkey/sdk-server";
const turnkey = new Turnkey({
apiBaseUrl: "https://api.turnkey.com",
defaultOrganizationId: process.env.TURNKEY_ORG_ID,
apiPublicKey: process.env.TURNKEY_API_PUBLIC_KEY,
apiPrivateKey: process.env.TURNKEY_API_PRIVATE_KEY,
});
const parasol = new ParasolDEX({
signer: turnkey.signer({
walletId: process.env.TURNKEY_WALLET_ID,
}),
network: "mainnet-beta",
});
`
turnkey integrates directly with parasoldex. the agent operates normally — the mpc signing is transparent to the payment logic.
spending limits — defense in depth
even with secure key management, set limits. a compromised agent with no spending limits can drain a wallet in seconds.
layer your limits:
`typescript
const parasol = new ParasolDEX({
wallet,
network: "mainnet-beta",
x402: {
enabled: true,
maxPaymentAmount: 5_000_000, // max 5 USDC per payment
},
limits: {
maxDailySpend: 50_000_000, // max 50 USDC per day
maxHourlySpend: 10_000_000, // max 10 USDC per hour
maxTransactionsPerHour: 100, // rate cap
requireApprovalAbove: 10_000_000, // human approval over 10 USDC
},
});
`
when a limit is hit, the sdk throws a SpendingLimitExceeded error. your agent should log this, alert an operator, and stop attempting payments until the limit resets or is raised.
validate payment recipients
an x402 server can request payment to any address. a malicious server (or a compromised legitimate server) could redirect payments.
maintain an allowlist if possible:
`typescript
const TRUSTED_RECIPIENTS = new Set([
"7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU",
"9Wyz2bF1K3jRkQhP2Lg5vBxN4vZ8L5RnEjE8hF3qQ7kB",
]);
parasol.on("payment:before", (event) => {
if (!TRUSTED_RECIPIENTS.has(event.recipient)) {
console.warn(untrusted recipient: ${event.recipient});
event.cancel(); // abort the payment
}
});
`
if an allowlist isn't practical (your agent interacts with many dynamic services), at minimum log every payment recipient and review periodically.
---
monitoring
you can't fix what you can't see. every production x402 system needs monitoring.
what to track
| metric | why it matters |
|---|---|
| payment success rate | dropping success rate = something is broken |
| average payment latency | increasing latency = network or rpc issues |
| total spend per day | runaway spending = compromised agent or misconfigured limits |
| failed payment count | spikes = server issues, balance issues, or configuration problems |
| auto-swap frequency | high swap rate = agent holds wrong tokens, optimize funding |
| unique recipients | new recipients appearing = possible redirect attack |
| balance over time | declining faster than expected = overspending |
basic logging
at minimum, log every payment event:
`typescript
parasol.on("payment", (event) => {
const logEntry = {
timestamp: new Date().toISOString(),
type: "payment",
amount: event.amount,
token: event.token,
recipient: event.recipient,
txId: event.txId,
url: event.url,
latencyMs: event.latencyMs,
swapRequired: event.swapRequired,
};
console.log(JSON.stringify(logEntry));
// also send to your metrics system
metrics.increment("payments.total");
metrics.gauge("payments.latency", event.latencyMs);
metrics.increment(payments.token.${event.token});
});
parasol.on("payment:failed", (event) => {
console.error(JSON.stringify({
timestamp: new Date().toISOString(),
type: "payment_failed",
error: event.error.message,
errorCode: event.error.code,
url: event.url,
amount: event.amount,
token: event.token,
}));
metrics.increment("payments.failed");
metrics.increment(payments.failed.${event.error.code});
});
`
alerting
set alerts for:
use whatever alerting system your team already has. pagerduty, opsgenie, a slack webhook — anything is better than discovering problems from user complaints.
---
token management
your agent needs the right tokens at the right time. running out of tokens mid-operation is the most common production issue.
maintain minimum balances
`typescript
const MIN_BALANCES = {
SOL: 0.05 * 1e9, // 0.05 SOL for transaction fees
USDC: 10 * 1e6, // 10 USDC for payments
};
async function checkBalances(parasol: ParasolDEX) {
const balances = await parasol.getBalances();
for (const [token, minBalance] of Object.entries(MIN_BALANCES)) {
const current = balances[token] || 0;
if (current < minBalance) {
console.warn(
low balance: ${token} has ${current}, minimum is ${minBalance}
);
// trigger alert, auto-fund, or pause operations
}
}
}
// check every 5 minutes
setInterval(() => checkBalances(parasol), 5 60 1000);
`
always keep SOL in the wallet for transaction fees. a wallet with 1000 USDC but 0 SOL can't make any transactions.
auto-swap vs pre-funding
two strategies for ensuring your agent has the right tokens:
auto-swap (reactive): let parasoldex swap tokens as needed when x402 payments come in. simpler to manage. slightly higher latency per payment (swap adds ~1 second). small slippage cost on each swap.
pre-funding (proactive): analyze which tokens your agent pays most often and pre-fund those. lower payment latency. no slippage cost. requires more management and forecasting.
for most agents, start with auto-swap. it's simpler and the cost difference is negligible at low to moderate volume. switch to pre-funding when you have enough data to predict token needs and the per-swap cost becomes meaningful.
dust management
after many swaps, wallets accumulate small amounts of many tokens — "dust." these tiny balances are too small to be useful but create noise in balance checks.
periodically consolidate dust:
`typescript
async function consolidateDust(parasol: ParasolDEX, targetToken: string) {
const balances = await parasol.getBalances();
const dustThreshold = 1_000_000; // $1 equivalent
for (const [token, balance] of Object.entries(balances)) {
if (token === targetToken || token === "SOL") continue;
if (balance > 0 && balance < dustThreshold) {
try {
await parasol.executeSwap(
await parasol.getQuote({
inputMint: token,
outputMint: targetToken,
amount: balance,
slippageBps: 100,
})
);
} catch {
// dust too small to swap — ignore
}
}
}
}
`
run this weekly or when the number of token accounts gets unwieldy.
---
testing
use devnet first. always.
solana devnet is free, fast, and mirrors mainnet behavior closely enough for integration testing. there's no reason to test with real money.
`typescript
const parasol = new ParasolDEX({
wallet,
network: "devnet",
x402: { enabled: true, maxPaymentAmount: 100_000_000 },
});
// get devnet SOL from faucet.solana.com
// get devnet USDC from spl-token-faucet.com
`
devnet has limitations:
test the integration logic on devnet. test with real services on mainnet with small amounts.
build a mock x402 server
for automated testing, run a local server that returns 402 responses:
`typescript
import express from "express";
const app = express();
app.get("/api/test-endpoint", (req, res) => {
const paymentTx = req.headers["x-payment-tx"];
if (!paymentTx) {
return res.status(402).set({
"X-Payment-Required": "true",
"X-Payment-Amount": "100000",
"X-Payment-Token": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
"X-Payment-Token-Symbol": "USDC",
"X-Payment-Recipient": "test-recipient-address",
"X-Payment-Network": "solana",
"X-Payment-Chain": "devnet",
"X-Payment-Memo": test_${Date.now()},
}).json({ error: "payment_required" });
}
return res.json({ data: "success", paymentVerified: true });
});
app.listen(3001, () => console.log("mock x402 server on :3001"));
`
this lets you test the full flow without depending on external services. add failure modes (random 500s, slow responses, expired offers) to test error handling.
test failure scenarios explicitly
the happy path is easy. test the unhappy paths:
`typescript
describe("x402 payment handling", () => {
it("handles insufficient balance", async () => {
// fund wallet with 0.001 USDC, request 1 USDC payment
await expect(parasol.fetch(mockServer + "/expensive"))
.rejects.toThrow(InsufficientBalanceError);
});
it("respects spending limits", async () => {
// set maxPaymentAmount to 0.5 USDC, request 1 USDC payment
await expect(parasol.fetch(mockServer + "/over-limit"))
.rejects.toThrow(SpendingLimitExceeded);
});
it("handles expired payment offers", async () => {
// server returns x402 with past expiration
await expect(parasol.fetch(mockServer + "/expired"))
.rejects.toThrow(PaymentExpiredError);
});
it("retries on transient failures", async () => {
// server fails twice then succeeds
const result = await parasol.fetch(mockServer + "/flaky");
expect(result.data).toBeDefined();
});
it("does not double-pay on confirmation timeout", async () => {
// simulate rpc timeout after payment submission
// verify only one payment transaction exists
});
});
`
---
rate limiting
respect server rate limits
x402 servers may rate-limit even paying clients. look for Retry-After headers and 429 Too Many Requests responses.
`typescript
async function rateLimitedFetch(
parasol: ParasolDEX,
url: string,
options: RequestInit = {}
) {
const response = await parasol.fetch(url, options);
if (response.status === 429) {
const retryAfter = parseInt(
response.headers.get("retry-after") || "5",
10
);
console.log(rate limited by ${url}. waiting ${retryAfter}s);
await new Promise((r) => setTimeout(r, retryAfter * 1000));
return rateLimitedFetch(parasol, url, options);
}
return response;
}
`
self-imposed rate limits
even if a server doesn't rate-limit you, limit yourself. a runaway loop making 1000 payments per second is an expensive bug.
`typescript
import { RateLimiter } from "limiter";
const limiter = new RateLimiter({
tokensPerInterval: 10,
interval: "second",
});
async function throttledPayment(parasol: ParasolDEX, url: string) {
await limiter.removeTokens(1);
return parasol.fetch(url);
}
`
10 payments per second is aggressive for most use cases. start lower (1-2 per second) and increase based on actual needs.
---
common pitfalls
pitfall 1: ignoring sol balance for fees
every solana transaction costs ~0.000005 SOL in fees. if your agent wallet has USDC but no SOL, every transaction fails. always maintain a SOL buffer.
minimum recommendation: 0.05 SOL. enough for ~10,000 transactions.
pitfall 2: not handling token account creation
when your agent receives a new SPL token for the first time, a token account must be created. this costs ~0.002 SOL in rent. if your agent auto-swaps to many different tokens, these rent costs add up.
parasoldex handles token account creation automatically, but the SOL cost comes from your wallet. factor this into your SOL buffer.
pitfall 3: stale quotes
a swap quote is a snapshot of current prices. if you get a quote and wait 30 seconds before executing, the price may have moved and the swap will fail due to slippage.
pitfall 4: no idempotency
if your agent crashes mid-payment-flow and restarts, what happens? without idempotency tracking, it may:
track payment state persistently. use the x402 payment memo as a unique key. before making any payment, check if you've already paid for that memo.
pitfall 5: trusting the payment amount blindly
a malicious x402 server could request an unreasonable payment. always validate:
`typescript
function validatePaymentRequest(payment: PaymentRequest): boolean {
if (payment.amount > MAX_ACCEPTABLE_PAYMENT) return false;
if (payment.amount <= 0) return false;
if (!SUPPORTED_TOKENS.includes(payment.token)) return false;
if (payment.expires && payment.expires < Date.now() / 1000) return false;
return true;
}
``
pitfall 6: logging sensitive data
audit your logs. make sure you're not logging:
log transaction ids, amounts, and public addresses. that's enough for debugging.
pitfall 7: testing only on mainnet
mainnet testing with real money is expensive, stressful, and slow. use devnet for integration testing, a mock server for unit testing, and mainnet only for final validation with minimal amounts.
---
production checklist
before deploying an x402 payment agent to production, verify:
---
further reading
---
get access
building agents that pay for services on solana? request access to parasoldex at parasol.so/access.
30 seconds. no wallet required.
---
parasol — it sees what you can't.