Payment Integration
This guide walks through implementing X402 payment verification and on-chain settlement in your A2X agent. All examples are from the GitHub Repo Analyzer reference implementation.
3-Component Structure
Payment integration is organized into three components:
| Component | File | Responsibility |
|---|---|---|
| Config | src/lib/x402/config.ts | Parse X402_BASE_FEE environment variable |
| Types | src/lib/x402/types.ts | TypeScript interfaces for all payment data structures |
| Executor | src/lib/a2a/a2a-executor.ts | Payment flow logic — request, verify, settle, execute |
src/lib/
├── x402/
│ ├── config.ts ← Payment configuration
│ └── types.ts ← Data structure definitions
└── a2a/
└── a2a-executor.ts ← Payment flow implementationEnvironment Variable Configuration
Payment configuration is driven by the X402_BASE_FEE environment variable — a JSON array of accepted payment options:
X402_BASE_FEE=[{"network":"base-sepolia","asset":"0x036CbD53842c5426634e7929541eC2318f3dCF7e","payTo":"YOUR_WALLET_ADDRESS","amount":"1000","eip712Name":"USDC","eip712Version":"2"}]| Field | Description | Example |
|---|---|---|
network | Blockchain network | "base-sepolia" |
asset | Token contract address | "0x036CbD..." (USDC on Base Sepolia) |
payTo | Your wallet address to receive payments | "0x1234..." |
amount | Price in minimum units (1000 = 0.001 USDC) | "1000" |
eip712Name | EIP-712 token name | "USDC" |
eip712Version | EIP-712 version | "2" |
The config parser validates the structure at runtime:
// src/lib/x402/config.ts
export interface BaseFeeEntry {
network: string;
asset: string;
payTo: string;
amount: string;
eip712Name: string;
eip712Version: string;
}
export interface X402Config {
baseFees: BaseFeeEntry[];
}
export function getX402Config(): X402Config {
const raw = process.env.X402_BASE_FEE;
if (!raw) throw new Error('X402_BASE_FEE environment variable is required');
const entries = JSON.parse(raw);
if (!Array.isArray(entries) || entries.length === 0) {
throw new Error('X402_BASE_FEE must be a non-empty JSON array');
}
return {
baseFees: entries.map((e, i) => {
if (!e.network) throw new Error(`X402_BASE_FEE[${i}].network is required`);
if (!e.asset) throw new Error(`X402_BASE_FEE[${i}].asset is required`);
if (!e.payTo) throw new Error(`X402_BASE_FEE[${i}].payTo is required`);
if (!e.amount) throw new Error(`X402_BASE_FEE[${i}].amount is required`);
return {
network: e.network,
asset: e.asset,
payTo: e.payTo,
amount: e.amount,
eip712Name: e.eip712Name ?? 'USDC',
eip712Version: e.eip712Version ?? '2',
};
}),
};
}Tip
accepts array.2-Stage Payment Flow
The executor implements a 2-stage flow controlled by the x402.payment.status metadata key:
export class AdkAgentExecutor implements AgentExecutor {
async execute(requestContext: RequestContext, eventBus: ExecutionEventBus): Promise<void> {
const { userMessage, taskId, contextId } = requestContext;
const metadata = (userMessage.metadata ?? {}) as Record<string, unknown>;
const paymentStatus = metadata[X402_METADATA_KEYS.STATUS] as string | undefined;
if (paymentStatus === 'payment-submitted') {
await this.handlePaymentSubmission(requestContext, eventBus, metadata);
} else {
this.requestPayment(taskId, contextId, eventBus);
}
}
}Stage 1: Request Payment
When a client sends the first request (no payment status), the executor generates payment requirements from the config and publishes them as an input-required task:
private requestPayment(taskId: string, contextId: string, eventBus: ExecutionEventBus): void {
const config = getX402Config();
const paymentRequired: X402PaymentRequiredResponse = {
x402Version: 1,
accepts: config.baseFees.map((fee) => ({
scheme: 'exact',
network: fee.network,
maxAmountRequired: fee.amount,
asset: fee.asset,
payTo: fee.payTo,
resource: 'baseFee',
mimeType: 'application/json',
maxTimeoutSeconds: 300,
extra: {
name: fee.eip712Name ?? 'USDC',
version: fee.eip712Version ?? '2',
},
} satisfies PaymentRequirements)),
};
// Publish task first so the task exists in the store
const taskEvent: Task = {
kind: 'task',
id: taskId,
contextId,
status: { state: 'submitted', timestamp: new Date().toISOString() },
};
eventBus.publish(taskEvent);
// Then publish the payment-required status
const statusUpdate: TaskStatusUpdateEvent = {
kind: 'status-update',
taskId,
contextId,
final: true,
status: {
state: 'input-required',
timestamp: new Date().toISOString(),
message: {
kind: 'message',
messageId: crypto.randomUUID(),
role: 'agent',
taskId,
contextId,
parts: [{ kind: 'text', text: 'Payment is required to proceed.' }],
metadata: {
[X402_METADATA_KEYS.STATUS]: 'payment-required',
[X402_METADATA_KEYS.REQUIRED]: paymentRequired,
},
},
},
};
eventBus.publish(statusUpdate);
}Important
taskId-based resumption after the client signs the payment.Stage 2: Handle Payment Submission
When the client submits a signed payment, the executor runs a 5-step validation pipeline before executing the AI agent.
5-Step Payment Validation Pipeline
Step 1: Extract Payment Payload
const paymentPayload = metadata[X402_METADATA_KEYS.PAYLOAD] as PaymentPayload | undefined;
if (!paymentPayload?.payload?.authorization) {
this.failPayment(taskId, contextId, eventBus, 'unknown', 'INVALID_PAYLOAD',
'Payment payload is missing or malformed.');
return;
}Step 2: Match Against Stored Requirements
let accepted: PaymentRequirements | undefined;
if (storedRequirements) {
accepted = storedRequirements.accepts.find(
(a) => a.network === paymentPayload.network && a.scheme === paymentPayload.scheme,
);
}Step 3: Validate payTo Address
const { authorization } = paymentPayload.payload;
if (authorization.to.toLowerCase() !== accepted.payTo.toLowerCase()) {
this.failPayment(taskId, contextId, eventBus, paymentPayload.network, 'INVALID_PAY_TO',
`payTo mismatch: expected ${accepted.payTo}, got ${authorization.to}.`);
return;
}Step 4: Validate Amount
if (BigInt(authorization.value) > BigInt(accepted.maxAmountRequired)) {
this.failPayment(taskId, contextId, eventBus, paymentPayload.network, 'AMOUNT_EXCEEDED',
`Amount ${authorization.value} exceeds maximum ${accepted.maxAmountRequired}.`);
return;
}Step 5: Verify and Settle On-Chain
import { useFacilitator } from 'x402/verify';
const { verify: verifyPayment, settle: settlePayment } = useFacilitator();
// Verify the cryptographic signature
const verifyResult = await verifyPayment(paymentPayload, accepted);
if (!verifyResult.isValid) {
this.failPayment(taskId, contextId, eventBus, paymentPayload.network, 'VERIFY_FAILED',
verifyResult.invalidReason ?? 'Payment verification failed.');
return;
}
// Settle the payment on-chain
const settleResult = await settlePayment(paymentPayload, accepted);
if (!settleResult.success) {
this.failPayment(taskId, contextId, eventBus, paymentPayload.network, 'SETTLE_FAILED',
settleResult.errorReason ?? 'Payment settlement failed.');
return;
}
// Payment successful — create receipt and execute agent
const receipt: X402SettleResponse = {
success: true,
transaction: settleResult.transaction,
network: paymentPayload.network,
};History-Based PaymentRequirements Restoration
When the client submits payment (Stage 2), the executor needs to know what payment requirements were originally offered. It retrieves them from the task's message history:
const history: Message1[] = task?.history ?? [];
const paymentRequiredMsg = history.find(
(m) =>
m.role === 'agent' &&
(m.metadata as Record<string, unknown>)?.[X402_METADATA_KEYS.STATUS] === 'payment-required',
);
const storedRequirements = (
paymentRequiredMsg?.metadata as Record<string, unknown> | undefined
)?.[X402_METADATA_KEYS.REQUIRED] as X402PaymentRequiredResponse | undefined;If the task history is unavailable (e.g., in-memory store was restarted), the executor falls back to regenerating requirements from the config:
if (!storedRequirements) {
const config = getX402Config();
accepted = config.baseFees
.filter((f) => f.network === paymentPayload.network)
.map((fee) => ({
scheme: 'exact',
network: fee.network,
maxAmountRequired: fee.amount,
asset: fee.asset,
payTo: fee.payTo,
resource: 'baseFee',
mimeType: 'application/json',
maxTimeoutSeconds: 300,
extra: { name: fee.eip712Name ?? 'USDC', version: fee.eip712Version ?? '2' },
} satisfies PaymentRequirements))
.find((r) => r.scheme === paymentPayload.scheme);
}This fallback ensures robustness — even if the task store loses state, payments can still be validated.
Advanced Patterns
Dynamic Pricing
Adjust pricing based on the content of the request. For example, charge more for larger repositories or more complex analysis:
private requestPayment(
taskId: string,
contextId: string,
eventBus: ExecutionEventBus,
userMessage: Message,
): void {
const config = getX402Config();
const messageText = userMessage.parts
.filter((p) => p.kind === 'text')
.map((p) => p.text)
.join('');
// Dynamic pricing: charge more for organization repos
const isOrgRepo = messageText.includes('github.com/') &&
messageText.split('github.com/')[1]?.split('/').length > 1;
const multiplier = isOrgRepo ? 2 : 1;
const paymentRequired: X402PaymentRequiredResponse = {
x402Version: 1,
accepts: config.baseFees.map((fee) => ({
scheme: 'exact',
network: fee.network,
maxAmountRequired: String(BigInt(fee.amount) * BigInt(multiplier)),
asset: fee.asset,
payTo: fee.payTo,
resource: 'baseFee',
mimeType: 'application/json',
maxTimeoutSeconds: 300,
extra: { name: fee.eip712Name, version: fee.eip712Version },
})),
};
// ... publish events
}Free Quota with Fallback to Paid
Offer a free tier before requiring payment:
async execute(requestContext: RequestContext, eventBus: ExecutionEventBus): Promise<void> {
const { userMessage, taskId, contextId } = requestContext;
const metadata = (userMessage.metadata ?? {}) as Record<string, unknown>;
const paymentStatus = metadata[X402_METADATA_KEYS.STATUS] as string | undefined;
if (paymentStatus === 'payment-submitted') {
await this.handlePaymentSubmission(requestContext, eventBus, metadata);
return;
}
// Check free quota (e.g., based on contextId or IP)
const usageCount = await getUsageCount(contextId);
const FREE_QUOTA = 3;
if (usageCount < FREE_QUOTA) {
// Execute directly without payment
await this.executeAgent(requestContext, eventBus, null);
} else {
// Free quota exhausted — require payment
this.requestPayment(taskId, contextId, eventBus);
}
}Skill-Based Pricing
Charge different prices for different skills:
private getAmountForSkill(skillId: string, baseAmount: string): string {
const pricing: Record<string, number> = {
'analyze-repo': 1, // Base price
'deep-analysis': 5, // 5x base price
'generate-report': 3, // 3x base price
};
const multiplier = pricing[skillId] ?? 1;
return String(BigInt(baseAmount) * BigInt(multiplier));
}
private requestPayment(
taskId: string,
contextId: string,
eventBus: ExecutionEventBus,
skillId: string,
): void {
const config = getX402Config();
const paymentRequired: X402PaymentRequiredResponse = {
x402Version: 1,
accepts: config.baseFees.map((fee) => ({
scheme: 'exact',
network: fee.network,
maxAmountRequired: this.getAmountForSkill(skillId, fee.amount),
// ... rest of fields
})),
};
// ... publish events
}Failure Handling
Payment Failure Helper
Use a dedicated method for clean error responses with proper metadata:
private failPayment(
taskId: string,
contextId: string,
eventBus: ExecutionEventBus,
network: string,
errorCode: string,
reason: string,
): void {
const failedStatus: TaskStatusUpdateEvent = {
kind: 'status-update',
taskId,
contextId,
final: true,
status: {
state: 'failed',
timestamp: new Date().toISOString(),
message: {
kind: 'message',
messageId: crypto.randomUUID(),
role: 'agent',
taskId,
contextId,
parts: [{ kind: 'text', text: `Payment verification failed: ${reason}` }],
metadata: {
[X402_METADATA_KEYS.STATUS]: 'payment-failed',
[X402_METADATA_KEYS.ERROR]: errorCode,
[X402_METADATA_KEYS.RECEIPTS]: [
{
success: false,
errorReason: reason,
network,
transaction: '',
} satisfies X402SettleResponse,
],
},
},
},
};
eventBus.publish(failedStatus);
eventBus.finished();
}Common Failure Scenarios
| Scenario | Error Code | Handling |
|---|---|---|
| Missing or malformed payload | INVALID_PAYLOAD | Fail immediately — don't attempt verification |
| Network/scheme not accepted | NETWORK_MISMATCH | Fail with supported options in error message |
| payTo address mismatch | INVALID_PAY_TO | Fail — possible tampering |
| Amount exceeds maximum | AMOUNT_EXCEEDED | Fail — client sent too much |
| Signature verification failed | VERIFY_FAILED | Fail — invalid signature |
| On-chain settlement failed | SETTLE_FAILED | Fail — transaction reverted |
Important
x402.payment.receipts in failure responses (with success: false). This gives clients a complete audit trail of the payment attempt.