how parasoldex handles x402 payments automatically
from the outside, automatic x402 payment handling looks like magic. your agent calls parasol.fetch(), money moves, data arrives. one line of code.
from the inside, there are seven distinct steps, each with its own failure modes, optimizations, and design decisions. this article walks through all of them.
understanding the internals isn't required to use parasoldex. but if you're debugging a payment failure, optimizing latency, or deciding whether to trust the sdk with your agent's funds, knowing what happens under the hood helps.
---
the seven steps
when parasol.fetch() encounters an x402 payment requirement, the resolution engine executes this sequence:
``
request → 402 detected → headers parsed → balance checked
→ swap routed (if needed) → payment sent → request retried
`
each step is independent and can fail independently. the engine handles failures at each step with specific recovery strategies.
let's walk through each one.
---
step 1: detect the 402 response
the first step is the simplest. parasoldex wraps the standard fetch() api with a response interceptor that checks for HTTP 402 status codes.
`typescript
async fetch(url: string, options: FetchOptions = {}): Promise
const response = await globalThis.fetch(url, options);
if (response.status === 402 && this.config.x402.enabled) {
return this.resolvePayment(response, url, options);
}
return this.wrapResponse(response);
}
`
the check is simple: is the status 402, and is x402 handling enabled? if both are true, the response is handed off to the payment resolution engine. if not, the response passes through unmodified.
why this matters
parasoldex doesn't interfere with non-402 responses. a 200, a 404, a 500 — they all pass through exactly as if you'd used standard fetch(). the x402 handling is purely additive. your existing error handling, response parsing, and retry logic continue to work.
edge case: 402 without payment headers
some servers return 402 for reasons unrelated to x402 (legacy apis, payment walls that expect credit cards). parasoldex validates the presence of required x402 headers before attempting payment. if the headers are missing, the 402 passes through as a normal response.
`typescript
private isX402Response(response: Response): boolean {
return (
response.status === 402 &&
response.headers.get("x-payment-required") === "true" &&
response.headers.has("x-payment-amount") &&
response.headers.has("x-payment-token") &&
response.headers.has("x-payment-recipient")
);
}
`
---
step 2: parse payment headers
once an x402 response is identified, the engine extracts and validates the payment details.
`typescript
interface PaymentRequest {
amount: number;
token: string;
tokenSymbol: string | null;
tokenDecimals: number | null;
recipient: string;
network: string;
chain: string | null;
memo: string | null;
expires: number | null;
}
private parsePaymentHeaders(response: Response): PaymentRequest {
const headers = response.headers;
return {
amount: parseInt(headers.get("x-payment-amount"), 10),
token: headers.get("x-payment-token"),
tokenSymbol: headers.get("x-payment-token-symbol"),
tokenDecimals: headers.get("x-payment-token-decimals")
? parseInt(headers.get("x-payment-token-decimals"), 10)
: null,
recipient: headers.get("x-payment-recipient"),
network: headers.get("x-payment-network") || "solana",
chain: headers.get("x-payment-chain"),
memo: headers.get("x-payment-memo"),
expires: headers.get("x-payment-expires")
? parseInt(headers.get("x-payment-expires"), 10)
: null,
};
}
`
validation
after parsing, the engine runs validation checks:
`typescript
private validatePaymentRequest(payment: PaymentRequest): void {
if (isNaN(payment.amount) || payment.amount <= 0) {
throw new InvalidPaymentError("invalid payment amount");
}
if (payment.amount > this.config.x402.maxPaymentAmount) {
throw new SpendingLimitExceeded(
payment ${payment.amount} exceeds limit ${this.config.x402.maxPaymentAmount}
);
}
if (!isValidSolanaAddress(payment.recipient)) {
throw new InvalidPaymentError("invalid recipient address");
}
if (payment.network !== "solana") {
throw new UnsupportedNetworkError(
network ${payment.network} is not supported
);
}
if (payment.expires && payment.expires < Date.now() / 1000) {
throw new PaymentExpiredError("payment offer has expired");
}
if (
this.config.x402.allowedTokens &&
!this.config.x402.allowedTokens.includes(payment.tokenSymbol || payment.token)
) {
throw new TokenNotAllowedError(
token ${payment.tokenSymbol || payment.token} is not in allowlist
);
}
}
`
these checks run before any money moves. if any check fails, the payment is aborted and an error is thrown to the caller. no transaction is constructed, no balance is spent.
daily and hourly limit tracking
beyond the per-payment limit, the engine tracks cumulative spending:
`typescript
private async checkSpendingLimits(amount: number): Promise
const now = Date.now();
const hourAgo = now - 60 60 1000;
const dayAgo = now - 24 60 60 * 1000;
const hourlySpend = this.paymentLog
.filter((p) => p.timestamp > hourAgo)
.reduce((sum, p) => sum + p.normalizedAmount, 0);
const dailySpend = this.paymentLog
.filter((p) => p.timestamp > dayAgo)
.reduce((sum, p) => sum + p.normalizedAmount, 0);
if (hourlySpend + amount > this.config.limits.maxHourlySpend) {
throw new SpendingLimitExceeded("hourly spending limit reached");
}
if (dailySpend + amount > this.config.limits.maxDailySpend) {
throw new SpendingLimitExceeded("daily spending limit reached");
}
}
`
amounts are normalized to a common denomination (USDC equivalent) for comparison. a payment in SOL is converted at the current rate before checking against USDC-denominated limits.
---
step 3: check wallet balance
before constructing any transaction, the engine checks if the wallet has enough of the requested token.
`typescript
private async checkBalance(
payment: PaymentRequest
): Promise<{ sufficient: boolean; balance: number; deficit: number }> {
const balance = await this.getTokenBalance(payment.token);
return {
sufficient: balance >= payment.amount,
balance,
deficit: Math.max(0, payment.amount - balance),
};
}
`
this is a straightforward rpc call to get the token account balance. the result determines the next step.
three possible outcomes
.`typescript
const balanceCheck = await this.checkBalance(payment);
if (balanceCheck.sufficient) {
return this.executePayment(payment);
}
if (!this.config.x402.autoSwap) {
throw new InsufficientBalanceError({
token: payment.tokenSymbol || payment.token,
required: payment.amount,
available: balanceCheck.balance,
});
}
return this.swapAndPay(payment, balanceCheck.deficit);
`
---
step 4: find the best swap route
this is where parasoldex's dex aggregation layer earns its keep. the agent holds token A, the server wants token B. we need to convert.
route discovery
parasoldex uses jupiter's routing engine for route discovery. jupiter aggregates liquidity from every major dex on solana — raydium, orca, meteora, lifinity, phoenix, openbook, and more.
the routing request includes:
`typescript
private async findSwapRoute(
outputMint: string,
outputAmount: number,
): Promise
const walletBalances = await this.getAllBalances();
const slippageBuffer = 1 + (this.config.swap.slippageBps / 10000);
const targetAmount = Math.ceil(outputAmount * slippageBuffer);
const candidates = walletBalances
.filter((b) => b.mint !== outputMint && b.amount > 0)
.sort((a, b) => b.usdValue - a.usdValue);
for (const source of candidates) {
try {
const quote = await this.jupiter.getQuote({
inputMint: source.mint,
outputMint,
amount: targetAmount,
slippageBps: this.config.swap.slippageBps,
swapMode: "ExactOut",
});
if (quote && quote.inAmount <= source.amount) {
return {
inputMint: source.mint,
inputAmount: quote.inAmount,
outputMint,
outputAmount: targetAmount,
route: quote,
priceImpact: quote.priceImpactPct,
};
}
} catch {
continue;
}
}
throw new NoSwapRouteError(
no viable swap route to ${outputMint} with sufficient balance
);
}
`
source token selection
the engine scans all token balances in the wallet, sorted by USD value (highest first). it tries the largest balance first — this minimizes the chance of insufficient funds and typically provides the best routing.
if the largest balance can't cover the swap (maybe the route doesn't exist, or price impact is too high), it moves to the next largest. this continues until a viable route is found or all options are exhausted.
exact-out mode
note the swapMode: "ExactOut" parameter. most swap interfaces use exact-in: "i want to spend exactly X of token A, give me whatever that gets me of token B." for payments, we need exact-out: "i need exactly Y of token B, take whatever that costs from token A."
this ensures the payment amount is precise. the server asked for 1,000,000 USDC (1 USDC). the agent pays exactly 1,000,000 USDC, not 999,847 USDC after slippage.
price impact protection
before executing a swap, the engine checks price impact:
`typescript
private validatePriceImpact(route: SwapRoute): void {
const maxImpact = this.config.swap.maxPriceImpactPct || 1.0;
if (route.priceImpact > maxImpact) {
throw new ExcessivePriceImpactError(
price impact ${route.priceImpact}% exceeds maximum ${maxImpact}%
);
}
}
`
if the swap would cause significant price impact (default threshold: 1%), the engine aborts. paying 1 USDC shouldn't cost 1.05 USDC in slippage. if it does, something is wrong — low liquidity, a stale route, or an unusually large payment relative to pool depth.
---
step 5: execute the swap (if needed)
when a swap is required, the engine executes it before making the payment. these are two separate transactions — the swap and the payment.
`typescript
private async executeSwap(route: SwapRoute): Promise
const { swapTransaction } = await this.jupiter.getSwapTransaction({
route: route.route,
userPublicKey: this.wallet.publicKey.toString(),
wrapAndUnwrapSol: true,
computeUnitPriceMicroLamports: this.config.swap.priorityFee === "auto"
? await this.estimatePriorityFee()
: this.config.swap.priorityFee,
});
const transaction = VersionedTransaction.deserialize(
Buffer.from(swapTransaction, "base64")
);
transaction.sign([this.wallet]);
const txId = await this.connection.sendTransaction(transaction, {
skipPreflight: false,
maxRetries: 3,
});
const confirmation = await this.connection.confirmTransaction(
txId,
"confirmed"
);
if (confirmation.value.err) {
throw new SwapError(swap transaction failed: ${confirmation.value.err});
}
this.emit("swap", {
inputAmount: route.inputAmount,
inputToken: route.inputMint,
outputAmount: route.outputAmount,
outputToken: route.outputMint,
txId,
priceImpact: route.priceImpact,
});
return { txId, confirmed: true };
}
`
priority fees
solana transactions include a priority fee that determines their position in the block. higher fees = faster inclusion. the engine can auto-detect the current fee market:
`typescript
private async estimatePriorityFee(): Promise
const recentFees = await this.connection.getRecentPrioritizationFees();
const sorted = recentFees.sort(
(a, b) => a.prioritizationFee - b.prioritizationFee
);
// use the 75th percentile — fast but not overpaying
const index = Math.floor(sorted.length * 0.75);
return sorted[index]?.prioritizationFee || 1000;
}
`
for x402 payments, speed matters. the agent is blocked waiting for the payment to confirm before retrying the original request. a stingy priority fee that saves 0.00001 SOL but delays confirmation by 5 seconds is a bad trade.
swap failure recovery
if the swap fails:
the engine will not retry indefinitely. after configured retry attempts (default: 3), it gives up and reports the failure.
---
step 6: make the payment
with the right token in the wallet (either already there or freshly swapped), the engine constructs and sends the payment transaction.
`typescript
private async executePayment(
payment: PaymentRequest
): Promise
// idempotency check
if (payment.memo) {
const existing = this.paymentCache.get(payment.memo);
if (existing) {
const status = await this.connection.getSignatureStatus(existing);
if (status?.value?.confirmationStatus === "confirmed") {
return { txId: existing, alreadyPaid: true };
}
}
}
const transaction = new Transaction();
if (payment.token === NATIVE_SOL_MINT) {
transaction.add(
SystemProgram.transfer({
fromPubkey: this.wallet.publicKey,
toPubkey: new PublicKey(payment.recipient),
lamports: payment.amount,
})
);
} else {
const senderAta = await getAssociatedTokenAddress(
new PublicKey(payment.token),
this.wallet.publicKey
);
const recipientAta = await getAssociatedTokenAddress(
new PublicKey(payment.token),
new PublicKey(payment.recipient)
);
const recipientAtaInfo = await this.connection.getAccountInfo(recipientAta);
if (!recipientAtaInfo) {
transaction.add(
createAssociatedTokenAccountInstruction(
this.wallet.publicKey,
recipientAta,
new PublicKey(payment.recipient),
new PublicKey(payment.token)
)
);
}
transaction.add(
createTransferInstruction(
senderAta,
recipientAta,
this.wallet.publicKey,
payment.amount
)
);
}
if (payment.memo) {
transaction.add(
new TransactionInstruction({
keys: [],
programId: MEMO_PROGRAM_ID,
data: Buffer.from(payment.memo),
})
);
}
transaction.recentBlockhash = (
await this.connection.getLatestBlockhash()
).blockhash;
transaction.feePayer = this.wallet.publicKey;
transaction.sign(this.wallet);
const txId = await this.connection.sendTransaction(transaction, {
skipPreflight: false,
maxRetries: 3,
});
if (payment.memo) {
this.paymentCache.set(payment.memo, txId);
}
await this.connection.confirmTransaction(txId, "confirmed");
this.paymentLog.push({
timestamp: Date.now(),
amount: payment.amount,
normalizedAmount: await this.normalizeToUsdc(
payment.amount,
payment.token
),
token: payment.token,
recipient: payment.recipient,
txId,
memo: payment.memo,
});
this.emit("payment", {
amount: payment.amount,
token: payment.tokenSymbol || payment.token,
recipient: payment.recipient,
txId,
url: this.currentUrl,
latencyMs: Date.now() - this.paymentStartTime,
swapRequired: this.swapWasRequired,
});
return { txId, alreadyPaid: false };
}
`
key implementation details
idempotency — before sending a payment, the engine checks if it has already sent one for the same memo. this prevents double-payments when retrying after transient failures.
token account creation — if the recipient doesn't have a token account for the requested token, the engine creates one. the rent cost (~0.002 SOL) comes from the agent's wallet. this is a one-time cost per recipient per token.
memo program — the payment memo is attached via solana's memo program. this on-chain memo lets the server match the payment to the original request. without it, the server can't verify which request the payment satisfies.
payment logging — every payment is logged internally for spending limit tracking and externally via the event emitter for monitoring.
---
step 7: retry the original request
the final step: send the original request again, this time with proof of payment.
`typescript
private async retryWithPayment(
url: string,
options: FetchOptions,
paymentResult: PaymentResult
): Promise
const retryOptions = {
...options,
headers: {
...options.headers,
"X-Payment-Tx": paymentResult.txId,
"X-Payment-Memo": this.currentPayment.memo,
},
};
let lastError: Error;
for (let attempt = 1; attempt <= this.config.x402.retryAttempts; attempt++) {
try {
const response = await globalThis.fetch(url, retryOptions);
if (response.ok) {
return this.wrapResponse(response, {
paymentMade: true,
paymentAmount: this.currentPayment.amount,
paymentToken: this.currentPayment.tokenSymbol,
paymentTxId: paymentResult.txId,
});
}
if (response.status === 402) {
throw new PaymentNotRecognizedError(
"server returned 402 after payment — transaction may not be confirmed yet"
);
}
throw new Error(unexpected status ${response.status} after payment);
} catch (error) {
lastError = error;
if (attempt < this.config.x402.retryAttempts) {
const delay = this.config.x402.retryDelay * Math.pow(2, attempt - 1);
await new Promise((r) => setTimeout(r, delay));
}
}
}
throw new RetryExhaustedError(
failed to get data after payment. tx: ${paymentResult.txId}. +
last error: ${lastError.message}
);
}
`
why retries are needed
the payment transaction is confirmed on the solana blockchain. but the server needs to verify that confirmation. depending on the server's implementation, there may be a brief delay between the transaction confirming on-chain and the server recognizing it.
the retry logic accounts for this:
the transaction id is always included in the error. even if the retry fails, the payment is on-chain. the caller can contact the server with the transaction id for manual resolution.
---
the full flow — timing breakdown
here's a typical timing profile for a complete x402 payment resolution with auto-swap:
| step | duration | cumulative |
|---|---|---|
| detect 402 response | <1ms | ~0ms |
| parse and validate headers | <1ms | ~1ms |
| check wallet balance | ~100ms | ~100ms |
| find swap route (jupiter) | ~200ms | ~300ms |
| execute swap transaction | ~1200ms | ~1500ms |
| check post-swap balance | ~100ms | ~1600ms |
| execute payment transaction | ~800ms | ~2400ms |
| retry original request | ~100ms | ~2500ms |
total: approximately 2.5 seconds for a full swap-and-pay flow. without the swap step (wallet already holds the right token), it's closer to 1.2 seconds.
for comparison, the same flow on ethereum L1 would take 12-15 minutes. on an L2 like arbitrum, 2-5 minutes. solana's fast finality is what makes automatic x402 payments feel synchronous.
---
error recovery architecture
the resolution engine uses a state machine internally. each step transitions to the next on success, or to a recovery path on failure.
`
detect → parse → validate → balance_check
↓
[sufficient] → payment → retry → done
[insufficient] → swap_route → swap_execute
↓
payment → retry → done
`
each failure path:
| failure point | recovery |
|---|---|
| parse fails | throw InvalidPaymentError, no money moves |
| validation fails | throw specific error, no money moves |
| balance check rpc fails | retry rpc call, then throw on persistent failure |
| no swap route found | throw NoSwapRouteError, no money moves |
| swap fails (slippage) | retry with higher slippage, then throw |
| swap fails (other) | retry with backoff, then throw |
| payment fails | retry with backoff, then throw |
| retry gets 402 again | wait and retry (server may not have seen tx yet) |
| retry gets other error | throw with payment txid for manual resolution |
the design principle: fail safe. if anything goes wrong, stop and report. never leave the system in an ambiguous state where money moved but no one knows.
---
observability
the engine emits events at every significant step. these aren't just for debugging — they're the foundation of a monitoring system.
`typescript
// events emitted during resolution
parasol.on("x402:detected", (e) => {}); // 402 response found
parasol.on("x402:validated", (e) => {}); // payment request validated
parasol.on("x402:swap_needed", (e) => {}); // auto-swap required
parasol.on("swap", (e) => {}); // swap executed
parasol.on("payment", (e) => {}); // payment sent
parasol.on("x402:resolved", (e) => {}); // full flow completed
parasol.on("payment:failed", (e) => {}); // payment failed
parasol.on("x402:failed", (e) => {}); // resolution failed
``
wire these to your logging and metrics infrastructure. in production, you want dashboards showing: resolution success rate, average latency per step, swap frequency, payment amounts over time, and failure breakdowns by error type.
---
design decisions
a few decisions worth explaining.
why two separate transactions (swap + payment)?
atomic swap-and-pay in a single transaction would be ideal — either both happen or neither does. we evaluated this and decided against it for pragmatic reasons:
the trade-off is a brief window between swap confirmation and payment submission where the agent holds the target token but hasn't paid yet. if the agent crashes in this window, the token sits in the wallet — not lost, just not yet paid. the idempotency system catches this on restart.
why client-side signing?
the alternative — sending the private key or unsigned transactions to a server for signing — is simpler to implement but fundamentally insecure. we chose client-side signing because:
the cost is slightly higher integration complexity (the sdk needs access to the wallet), but the security gain is non-negotiable.
why jupiter for routing?
jupiter has the deepest liquidity aggregation on solana. building our own router would take months and still produce worse routes. using jupiter's engine means parasoldex gets the same pricing as any other jupiter integration — which is the best available on solana.
we add value on top of the routing: payment detection, auto-swap orchestration, safety limits, and event logging. that's where parasoldex's differentiation lives, not in swap routing.
---
further reading
---
get access
if you're building agents on solana that need to pay for services automatically, parasoldex was built for exactly that.
request early access at parasol.so/access. no wallet required.
---
parasol — it sees what you can't.