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:

ComponentFileResponsibility
Configsrc/lib/x402/config.tsParse X402_BASE_FEE environment variable
Typessrc/lib/x402/types.tsTypeScript interfaces for all payment data structures
Executorsrc/lib/a2a/a2a-executor.tsPayment flow logic — request, verify, settle, execute
src/lib/
├── x402/
│   ├── config.ts      ← Payment configuration
│   └── types.ts       ← Data structure definitions
└── a2a/
    └── a2a-executor.ts ← Payment flow implementation

Environment 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"}]
FieldDescriptionExample
networkBlockchain network"base-sepolia"
assetToken contract address"0x036CbD..." (USDC on Base Sepolia)
payToYour wallet address to receive payments"0x1234..."
amountPrice in minimum units (1000 = 0.001 USDC)"1000"
eip712NameEIP-712 token name"USDC"
eip712VersionEIP-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

You can accept payments on multiple networks by adding multiple entries to the JSON array. Clients choose their preferred option from the 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

Publish the task event before the status update. This ensures the task is persisted in the Task Store, enabling 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

ScenarioError CodeHandling
Missing or malformed payloadINVALID_PAYLOADFail immediately — don't attempt verification
Network/scheme not acceptedNETWORK_MISMATCHFail with supported options in error message
payTo address mismatchINVALID_PAY_TOFail — possible tampering
Amount exceeds maximumAMOUNT_EXCEEDEDFail — client sent too much
Signature verification failedVERIFY_FAILEDFail — invalid signature
On-chain settlement failedSETTLE_FAILEDFail — transaction reverted

Important

Always include x402.payment.receipts in failure responses (with success: false). This gives clients a complete audit trail of the payment attempt.

Complete Payment Flow Summary