Back to blog
Agentic Infrastructure21 min read

How to Build an AI Agent That Pays x402 APIs on Its Own

Most agents fail when an API returns HTTP 402. Step-by-step: detect the 402, pay in USDC over x402, and retry, with spend limits set using @abstraxn/agent-kit.

Pankaj Kumar
How to Build an AI Agent That Pays x402 APIs on Its Own

An AI agent can pay for an API on its own by catching the HTTP 402 response, reading the x402 payment requirements, paying in USDC from a wallet it controls, and retrying the request with proof of payment. This guide builds that loop with @abstraxn/agent-kit and puts a per-transaction spend limit on it so the agent cannot overspend.

Most developers handle a 402 in an agentic context one of two ways. They catch it, log it, and surface it to a human, which defeats the point of an autonomous agent. Or they swallow it silently, the task fails halfway through, and nobody notices until the output is wrong. This post builds the third option: a research agent that calls a paywalled market data feed, handles the 402 without interruption, and is capped by a hard per-transaction spend limit so it can't go rogue.

New to the status code itself? Start with what HTTP 402 Payment Required means.

Key takeaways

  • The loop is detect, pay, retry. On a 402, decode the PAYMENT-REQUIRED header, sign an EIP-3009 USDC TransferWithAuthorization with EIP-712, and retry with a PAYMENT-SIGNATURE header. That is the x402 v2 transport most production APIs use; the older v1 flow (JSON body plus an X-PAYMENT header) is not interchangeable with it.
  • wallet.accessKey cannot be retrieved after creation. Encrypt it at rest immediately. Treat it exactly like a private key.
  • Per-transaction caps, allowlists and blocklists are enforced server-side on MCP tool calls. hardBlock: true makes them a hard stop. Daily and monthly budgets for paid MCP tool fees are configurable with updateSpendPolicy and stored server-side; enforcement on the hosted Agent Kit is in active development. Raw fetch() payments are not covered by any Agent Kit policy, so Steps 3 and 5 add their own checks, and the daily budget in your own service is what stops payments in this tutorial.
  • Zero USDC = second 402. Fund the wallet before testing. The payment signature will be constructed successfully, but the facilitator will reject an authorisation with no balance to back it.

What the Agent Needs to Handle 402 Autonomously

Before any code, it's worth being clear about what "autonomous payment" actually requires. It's not just a wallet.

A wallet gives the agent an address to hold funds. But to pay a 402 autonomously, the agent also needs:

A signing key it controls. The wallet address is public. The signing key is what authorises transactions from it. Abstraxn provisions a P-256 access key pair when you create a server wallet, and the agent uses this key to sign every payment. It never leaves your backend.

A funded balance. The agent pays in USDC on Base Sepolia by default, and on Base mainnet (or whichever chain you configure) in production. USDC is a stablecoin where $1 of USDC is $1, no price volatility to worry about. You fund the wallet address once; the agent draws down from it as it pays APIs.

Spend limits. This is the part most implementations skip, and it's the part that matters most. An agent with a wallet and no limits will keep paying until the wallet is empty, or until a bug in your prompt sends it into a loop. A per-transaction limit with hardBlock: true means any single payment above the cap is refused. Not advisory. Hard stop.

On-chain identity (recommended). x402 itself doesn't require it: any client that can sign the payment can pay. ERC-8004 identity is an optional, separate layer that lets counterparties verify which registered, accountable agent they are dealing with, rather than an anonymous address. Identity registration is a separate step after wallet funding: the SDK's registerAgentIdentity() call handles it, but it requires the wallet to hold gas on the target chain first. Step 2 covers what to fund and when. For the registration steps themselves, see how to register an agent under ERC-8004.

The first three are what the payment needs. Identity is what makes the agent accountable to the people it pays. Together they make autonomous 402 handling safe to deploy, and the SDK wires them up in sequence. Checking each payment against what a human actually authorized, not just against a limit, is the job of Know Your Intent (KYI).


Prerequisites

  • Node.js 18 or higher. The examples use Node's built-in fetch and node:crypto.
  • Packages: @abstraxn/agent-kit@1.4.2 is the only package you install, and the version this tutorial is pinned to. Everything else in the code ships with Node.
  • An application API key from the Abstraxn Dashboard (Agentic Stack, Overview). This is not the same thing as the wallet access key.
  • A backend database to store each agent's encrypted access key. Never send it to a browser or mobile client.
  • Network: the default x402 network is Base Sepolia (eip155:84532). Switch it with the X402_NETWORK environment variable. Build and test on Base Sepolia (CHAIN_ID=84532) and move to Base mainnet (CHAIN_ID=8453) for production, with RPC_URL pointing at an endpoint for the same chain.
  • USDC addresses: Base Sepolia 0x036CbD53842c54266358eD9371b46bbb3b985d0e, Base mainnet 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913.
  • A funded testnet wallet: after Step 2, send test USDC and a small amount of native gas from a Base Sepolia testnet faucet to the agent's wallet.evmAddress. A wallet with no USDC gets a second 402 instead of data.

The policy examples in Steps 3, 4 and 6 use the chain name base and the Base mainnet USDC address, the values you deploy with. While you test on Base Sepolia, use the Base Sepolia USDC address above.


Step 1: Install and Initialise

npm install @abstraxn/agent-kit@1.4.2

Use the application API key from the prerequisites.

import { AgentKitClient } from '@abstraxn/agent-kit';
 
const agentKit = new AgentKitClient({
  apiKey: process.env.ABSTRAXN_API_KEY!,
  wallet: 'server', // auto-provisions an Abstraxn server wallet
});

The wallet: 'server' option tells the SDK to provision a server wallet, where Abstraxn generates the key pair, creates the smart account, and returns the EVM address and access key. You don't manage key generation. You store the result.


Step 2: Create the Agent and Provision Its Wallet

This runs once per agent, typically at user onboarding or agent setup time. Not on every request.

const { agent, wallet } = await agentKit.createAgent({
  name: 'Market Data Research Agent',
  description: 'Fetches paywalled financial data feeds autonomously',
  userIdentity: 'user-123@example.com',
  userEmail: 'user@example.com',
});
 
// Store all of this in your database immediately.
// wallet.accessKey CANNOT be retrieved again after this call.
await saveToDatabase({
  agentId: agent.id,
  accessKey: wallet.accessKey!,   // encrypt at rest as this signs every transaction
  evmAddress: wallet.evmAddress,
  organizationId: wallet.organizationId,
});
 
console.log('Agent wallet address:', wallet.evmAddress);
// Fund this address with USDC on your target chain before the agent calls any x402 API

The wallet.accessKey warning is worth taking seriously. This is the only way to sign transactions for this wallet, and there is no way to reveal it again later. If you lose it, you can't sign for that wallet: the documented fix is to create a new agent or set up a key-rotation flow with your Abstraxn contact. Encrypt it at rest with AES-256, KMS, whatever your stack already uses for secrets, and treat it like a private key.

After this call, send some USDC to wallet.evmAddress on your target chain. $20 to $50 is enough to start with for a development agent. The per-transaction limit you set in the next step caps each payment, and the balance you fund caps the total, so keep a development wallet modestly funded.


Step 3: Set the Spend Policy, a Per-Transaction Limit and a Daily Budget

This is the step most tutorials skip. Don't skip it. Three things go on the agent here: a spend policy that budgets paid MCP tool fees, a per-transaction cap, and a daily budget in your own service. The last one is what stops payments in this tutorial.

await agentKit.updateSpendPolicy(agent.id, {
  enabled: true,
  budgetUsd: '5.00',  // $5 per day of paid MCP tool fees
  period: 'daily',    // or 'monthly'
  hardBlock: true,    // stored with the policy; hosted enforcement in active development
});

This configures a budget for paid MCP tool fees: tools that carry a listed price, such as get_token_price. The policy is stored server-side and can be read back, and hardBlock: true is stored with it. Enforcement on the hosted Agent Kit is in active development, so this call does not stop any payment today. It also never counts trades, transfers or direct x402 payments, which have no tool price.

For this tutorial that settles it. The agent in Step 5 pays a third-party x402 API with a direct fetch(), which the spend policy does not count. So the per-transaction cap below and the daily budget in your own service are what stop a payment.

await agentKit.createInteractionPolicy(agent.id, {
  name: 'research-agent-spend-limits',
  enabled: true,
  hardBlock: true,    // hard stop and not advisory
  rules: {
    // Cap any single USDC payment at 1 USDC
    tokenAmountLimits: [{
      chain: 'base',
      token: 'USDC',
      max: '1',
    }],
    // Cap individual native token transfers at 0.01 ETH
    nativeAmountLimits: [{
      chain: 'base',
      max: '0.01',
    }],
  },
});

hardBlock: true means a payment above the cap is refused before it runs. Agent Kit returns an "Interaction policy denied" error (JSON-RPC code -32404) that lists the violated rule, for example token_amount_above_max. This is enforced server-side so the agent cannot bypass it, even if the prompt instructs it to.

One scope note worth knowing: Agent Kit evaluates interaction policies before MCP tool calls. If your agent makes raw fetch() calls to external x402 APIs directly from your application code (as in Step 5 below), this policy does not see those calls. For that path, Step 5 applies the same per-transaction cap in code before anything is signed.

hardBlock: false is advisory only so you'd get a warning in the logs but the agent keeps spending. That's useful for shadow testing during development. For production, use true.

The 1 USDC figure is illustrative. Set it to the most you'd be comfortable losing on a single bad call, and fund the wallet with no more than you'd accept losing in total. The daily total is bounded by the application-level budget below. For how to design limits like these, see a mandate that cannot be overspent.

Adding a daily budget in your agent service

This budget is what stops payments in this tutorial. It is the enforcement mechanism, not an optional extra: the spend policy only budgets paid MCP tool fees, its hosted enforcement is in active development, and it never counts the direct x402 payments the agent makes here. A per-transaction limit stops one oversized payment. It does not stop a loop of small ones: an agent paying 0.01 USDC a thousand times stays under every per-payment cap. For that you need a daily budget, enforced in your own service on the same raw fetch() path Step 5 guards.

The pattern has four parts. First, track cumulative spend per agent per UTC day, keyed by the agent ID and the date from toISOString(). Second, before signing, add the price from accepts[] to today's spend and compare that projected total with the budget. Third, if the next payment would exceed the budget, refuse it and log the agent, the amount and the running total, so whoever owns the agent can see why it stopped. Fourth, reset at the day boundary: because the date is part of the key, the first payment after midnight UTC starts a fresh total.

Record spend only after the paid retry succeeds, so a payment the facilitator rejected doesn't eat into the budget. Amounts stay in USDC base units as bigint, the same units as MAX_USDC_PER_PAYMENT_BASE_UNITS.

The in-memory Map below is fine for a demo, but it resets on restart and isn't shared between instances. In production, keep the totals in Redis or your database. Separate check and record steps also let two concurrent payments both pass, so reserve the amount atomically there (for example with INCRBY) and roll it back if the payment fails.

// Daily budget per agent in USDC base units (6 decimals): 5000000n = 5 USDC
const DAILY_BUDGET_BASE_UNITS = BigInt(process.env.DAILY_BUDGET_USDC_BASE_UNITS ?? '5000000');
 
class DailyBudget {
  // Demo only: in memory, lost on restart, not shared across instances.
  // Production: Redis or your DB. With Redis, INCRBY a key such as
  // `spend:${agentId}:${day}` and EXPIREAT it at the next UTC midnight.
  private spent = new Map<string, bigint>();
 
  constructor(private readonly limit: bigint) {}
 
  // UTC calendar day, e.g. '2026-11-12'. A new day means a new key,
  // so the total resets at midnight UTC.
  private key(agentId: string): string {
    const day = new Date().toISOString().slice(0, 10);
    return `${agentId}:${day}`;
  }
 
  // Call before signing. Refuses and logs if this payment would exceed today's budget.
  canSpend(agentId: string, amount: bigint): boolean {
    const spentToday = this.spent.get(this.key(agentId)) ?? 0n;
    const projected = spentToday + amount;
    if (projected > this.limit) {
      console.warn(
        `Daily budget refused for agent ${agentId}: ` +
        `${spentToday} spent + ${amount} requested > ${this.limit} (USDC base units)`
      );
      return false;
    }
    return true;
  }
 
  // Call only after the paid retry succeeds.
  record(agentId: string, amount: bigint): void {
    const k = this.key(agentId);
    this.spent.set(k, (this.spent.get(k) ?? 0n) + amount);
  }
}
 
export const dailyBudget = new DailyBudget(DAILY_BUDGET_BASE_UNITS);
 
// In fetchMarketData (Step 5), right after the per-transaction check:
//   const amount = BigInt(accept.amount);
//   if (!dailyBudget.canSpend(agentRecord.agentId, amount)) {
//     throw new Error(`Daily budget reached for agent ${agentRecord.agentId}. Not paying.`);
//   }
// ...and once retryResponse.ok is true:
//   dailyBudget.record(agentRecord.agentId, amount);

Call canSpend() in Step 5 right after the per-transaction check, and record() once retryResponse.ok is true.

Keep this budget for every payment your code makes with a direct fetch(). The spend policy will not take over that job: it only budgets paid MCP tool fees.


Step 4: Allowlist What the Agent Can Touch

The per-transaction limit controls how large a single payment can be. Allowlists and blocklists control where money can go. For a research agent that should only be reading data and never sending tokens to arbitrary addresses, allowlist the USDC contract:

await agentKit.createInteractionPolicy(agent.id, {
  name: 'research-agent-guardrails',
  enabled: true,
  hardBlock: true,
  rules: {
    // Only allow USDC contract interactions
    contractWhitelist: [{
      chain: 'base',
      addresses: ['0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'], // USDC on Base
    }],
  },
});

A research agent shouldn't be interacting with arbitrary contracts. Allowlisting USDC on Base means it can pay data providers and nothing else. If a compromised prompt tries to redirect funds elsewhere through an MCP tool call, the interaction policy blocks it server-side before the transaction is broadcast. To refuse specific addresses instead, add a recipientBlacklist rule with the same per-chain shape. When an agent has more than one enabled policy, every one of them must pass.

Scope note: Like the limit in Step 3, this policy applies to MCP tool calls, and it doesn't intercept raw fetch() calls your application code makes directly. For application-level x402 payments (Step 5), your own code is the guardrail. The pattern in Step 5 includes explicit payment validation for that reason.


Step 5: The Payment Flow (x402 v2)

Most production x402 APIs today use the v2 HTTP transport. It's different from what many early tutorials describe, and the difference matters. If you implement the wrong flow, you'll get a 402 on retry with a confusing error, because the API is looking for a header that doesn't exist in the simplified version.

Here's how x402 v2 actually works:

  1. GET the API → receive 402 + PAYMENT-REQUIRED header (base64-encoded JSON; some providers send it lowercase as payment-required)
  2. Decode the header → find the accepts[] array, pick the entry matching your chain (e.g. eip155:8453 for Base)
  3. Sign an EIP-3009 TransferWithAuthorization for USDC using EIP-712 typed data where the agent signs, a facilitator broadcasts
  4. Retry the original request with a PAYMENT-SIGNATURE header containing the full signed payload (base64-encoded)
  5. API verifies via facilitator → returns 200 with data

The key distinction from the simplified flow: the agent signs a USDC transfer authorisation, it doesn't broadcast a raw transaction. The facilitator handles broadcast. Your wallet still needs USDC, but gas is often covered by the facilitator depending on the provider.

Two details that will catch you if you get them wrong, both fixed in the code below: the signature must be EIP-712 signTypedData against USDC's TransferWithAuthorization domain (not a plain signMessage over JSON), and the nonce must be a 32-byte hex value (not a UUID). The PAYMENT-SIGNATURE payload also has to carry x402Version, the resource, and the full accepted entry because a facilitator will reject a payload missing any of them.

Legacy note: Some older or Abstraxn-simplified integrations use X-Payment (not PAYMENT-REQUIRED) and expect an on-chain tx hash on retry via X-Payment-Transaction. If you're working with a specific provider, check their docs for which header format they use. The code below targets x402 v2.

Before running this against a live API, make sure wallet.evmAddress holds USDC on your target chain. A wallet with 0 USDC will get a valid 402 on the first call, construct the payment signature, and then fail on the facilitator side, meaning the retry will still return 402. Fund first, then test.

For a real x402 v2 endpoint to test against, the x402-gateway crypto price API returns live market data behind a per-request USDC payment. Hit it once with a plain GET and you'll see the 402 and the payment-required header it expects you to respond to.

import { AgentKitClient, AgentKitError } from '@abstraxn/agent-kit';
import { randomBytes } from 'node:crypto';
 
const agentKit = new AgentKitClient({
  apiKey: process.env.ABSTRAXN_API_KEY!,
});
 
// x402 v2 payment requirement shape (decoded from the PAYMENT-REQUIRED header)
interface X402Accept {
  scheme: string;       // 'exact'
  network: string;      // 'eip155:8453' (Base), 'eip155:84532' (Base Sepolia), etc.
  payTo: string;        // recipient address
  amount: string;       // amount in token base units (USDC has 6 decimals)
  asset: string;        // USDC contract address which is used as the EIP-712 verifyingContract
  maxTimeoutSeconds: number;
  resource?: string;    // the resource being paid for
  extra?: {             // chain/token-specific EIP-712 domain overrides
    name?: string;
    version?: string;
    assetTransferMethod?: string;
  };
}
 
interface X402PaymentRequired {
  x402Version: number;
  accepts: X402Accept[];
  resource?: { url: string; [key: string]: unknown };
}
 
async function fetchMarketData(
  apiUrl: string,
  agentRecord: {
    agentId: string;
    accessKey: string;
    evmAddress: string;
    organizationId: string;
    userIdentity: string;
  }
) {
  // ── First attempt with no payment header ──
  const response = await fetch(apiUrl);
 
  if (response.status !== 402) {
    return response.json();
  }
 
  // ── 402 received so decode the payment requirement ──
  // Header case varies by provider: some send PAYMENT-REQUIRED, some payment-required
  const rawHeader =
    response.headers.get('PAYMENT-REQUIRED') ??
    response.headers.get('payment-required');
 
  if (!rawHeader) {
    // Legacy fallback for older integrations that use X-Payment + on-chain tx hash retry
    const legacyHeader =
      response.headers.get('X-Payment') ?? response.headers.get('x-payment');
    if (!legacyHeader) {
      throw new Error(
        '402 received but no PAYMENT-REQUIRED or X-Payment header found. ' +
        'Check the API provider docs for their x402 header format.'
      );
    }
    throw new Error('Legacy X-Payment flow detected, see your provider docs.');
  }
 
  const paymentRequired: X402PaymentRequired = JSON.parse(
    Buffer.from(rawHeader, 'base64').toString('utf-8')
  );
 
  // Pick the payment option matching your chain
  const targetNetwork = `eip155:${process.env.CHAIN_ID}`; // e.g. 'eip155:8453' for Base
  const accept = paymentRequired.accepts.find((a) => a.network === targetNetwork);
 
  if (!accept) {
    throw new Error(
      `No payment option for network ${targetNetwork}. ` +
      `API accepts: ${paymentRequired.accepts.map((a) => a.network).join(', ')}`
    );
  }
 
  // ── Per-transaction spend limit (application-level guardrail) ──
  // Raw fetch() payments bypass Agent Kit's MCP policies, so enforce the cap here,
  // before anything is signed. Assumes accept.asset is USDC (6 decimals):
  // 1000000 base units = 1 USDC.
  const maxPerPayment = BigInt(process.env.MAX_USDC_PER_PAYMENT_BASE_UNITS ?? '1000000');
  if (BigInt(accept.amount) > maxPerPayment) {
    throw new Error(
      `Payment of ${accept.amount} base units exceeds the per-transaction limit ` +
      `of ${maxPerPayment}. Not paying.`
    );
  }
 
  // ── Authenticate the server signer ──
  const signer = agentKit.getServerSigner();
  await signer.authenticate({
    userIdentity: agentRecord.userIdentity,
    accessKey: agentRecord.accessKey,
  });
 
  const client = signer.createPublicClient({
    rpcUrl: process.env.RPC_URL!,
    chainId: Number(process.env.CHAIN_ID!),
    organizationId: agentRecord.organizationId,
    fromAddress: agentRecord.evmAddress as `0x${string}`,
  });
 
  // ── Build the EIP-3009 TransferWithAuthorization message ──
  const validAfter = Math.floor(Date.now() / 1000);
  const validBefore = validAfter + accept.maxTimeoutSeconds;
  // EIP-3009 requires a bytes32 nonce and NOT a UUID string
  const nonce = `0x${randomBytes(32).toString('hex')}` as `0x${string}`;
 
  // Values are strings, not BigInt since the signer and JSON transport both expect strings
  const authorization = {
    from: agentRecord.evmAddress as `0x${string}`,
    to: accept.payTo as `0x${string}`,
    value: accept.amount,              // e.g. "1000" (USDC base units), already a string
    validAfter: validAfter.toString(),
    validBefore: validBefore.toString(),
    nonce,
  };
 
  // ── Sign with EIP-712 typed data for the USDC TransferWithAuthorization domain ──
  // This is the critical part. USDC payments require signTypedData against the
  // token's EIP-712 domain, not a plain signMessage over JSON.
  // The domain name/version can be overridden per-chain via accept.extra.
  const signature = await client.signTypedData({
    typedData: {
      domain: {
        name: accept.extra?.name ?? 'USD Coin',
        version: accept.extra?.version ?? '2',
        chainId: Number(process.env.CHAIN_ID!),
        verifyingContract: accept.asset as `0x${string}`, // USDC contract address
      },
      primaryType: 'TransferWithAuthorization',
      types: {
        EIP712Domain: [
          { name: 'name', type: 'string' },
          { name: 'version', type: 'string' },
          { name: 'chainId', type: 'uint256' },
          { name: 'verifyingContract', type: 'address' },
        ],
        TransferWithAuthorization: [
          { name: 'from', type: 'address' },
          { name: 'to', type: 'address' },
          { name: 'value', type: 'uint256' },
          { name: 'validAfter', type: 'uint256' },
          { name: 'validBefore', type: 'uint256' },
          { name: 'nonce', type: 'bytes32' },
        ],
      },
      message: authorization,
    },
  });
 
  // ── Build the PAYMENT-SIGNATURE payload (full x402 v2 shape) ──
  // The facilitator rejects payloads missing x402Version, resource, or accepted.
  const paymentSignaturePayload = Buffer.from(
    JSON.stringify({
      x402Version: paymentRequired.x402Version ?? 2,
      resource: paymentRequired.resource ?? { url: apiUrl },
      accepted: accept, // the full accept entry you selected, echoed back
      payload: {
        signature,
        authorization, // already string-encoded with no BigInt conversion needed
      },
    })
  ).toString('base64');
 
  // ── Retry with payment proof ──
  const retryResponse = await fetch(apiUrl, {
    headers: {
      'PAYMENT-SIGNATURE': paymentSignaturePayload,
    },
  });
 
  if (!retryResponse.ok) {
    throw new Error(
      `Payment retry failed: ${retryResponse.status}, ` +
      `check USDC balance at ${agentRecord.evmAddress} and verify chain matches ${targetNetwork}`
    );
  }
 
  return retryResponse.json();
}

The function is the same shape as before from the caller's perspective which is pass a URL, get data back. The 402 handling is internal, and a price above your per-transaction cap throws before anything is signed, so nothing is paid. The daily budget from Step 3 plugs in here: its canSpend() check goes right after the per-transaction check, and record() goes after a successful retry. The two places most developers trip up: forgetting to fund the wallet with USDC before the first call (zero balance resulting in facilitator rejecting and second 402), and mismatching the CHAIN_ID env var with what the API's accepts[] array actually lists.

One more EIP-712 gotcha: the USDC domain defaults to name: 'USD Coin' and version: '2', which is correct for native USDC on Base and most chains. Bridged USDC (USDC.e) or other chains can use a different name or version. The code above reads accept.extra?.name and accept.extra?.version first and only falls back to the defaults, so a well-formed x402 v2 response carries the right domain values for you. If a provider omits extra and you're on a non-standard token, a domain mismatch produces a signature the facilitator silently rejects, so verify against the token contract's EIP712Domain if a funded, correct-chain retry still fails.

Set CHAIN_ID=8453 for Base mainnet, CHAIN_ID=84532 for Base Sepolia (testnet). Set RPC_URL to a Base RPC endpoint like Alchemy, Infura, or the public https://mainnet.base.org.

That's the full loop. The caller doesn't need to know a payment happened: it just gets the data.


Step 6: Putting It Together as a Full Agent Service

Here's what the complete backend service looks like when you wire all the steps together:

import { AgentKitClient, UnauthorizedError, AgentKitError } from '@abstraxn/agent-kit';
 
const agentKit = new AgentKitClient({
  apiKey: process.env.ABSTRAXN_API_KEY!,
});
 
// ── Run once at setup ──
 
async function setupResearchAgent(userEmail: string) {
  const { agent, wallet } = await agentKit.createAgent({
    name: 'Market Data Research Agent',
    description: 'Fetches paywalled financial data autonomously',
    userIdentity: userEmail,
    userEmail,
  });
 
  // Budget for paid MCP tool fees; hosted enforcement in active development.
  // Payments in this service are stopped by dailyBudget from Step 3.
  await agentKit.updateSpendPolicy(agent.id, {
    enabled: true,
    budgetUsd: '5.00',
    period: 'daily',
    hardBlock: true,
  });
 
  // Per-transaction cap of 1 USDC, and USDC on Base as the only allowed contract
  await agentKit.createInteractionPolicy(agent.id, {
    name: 'research-guardrails',
    enabled: true,
    hardBlock: true,
    rules: {
      tokenAmountLimits: [{
        chain: 'base',
        token: 'USDC',
        max: '1',
      }],
      contractWhitelist: [{
        chain: 'base',
        addresses: ['0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'],
      }],
    },
  });
 
  // Store securely since accessKey cannot be retrieved again
  return {
    agentId: agent.id,
    accessKey: wallet.accessKey!,
    evmAddress: wallet.evmAddress,
    organizationId: wallet.organizationId,
  };
}
 
// ── Runs on every research task ──
 
async function runResearchTask(
  agentRecord: Awaited<ReturnType<typeof setupResearchAgent>> & { userIdentity: string },
  dataFeedUrl: string,
) {
  try {
    const data = await fetchMarketData(dataFeedUrl, agentRecord);
    return { success: true, data };
  } catch (error) {
    if (error instanceof UnauthorizedError) {
      // 401 from Agent Kit: the application API key is invalid or missing
      console.error('Agent Kit rejected the API key, check ABSTRAXN_API_KEY');
    } else if (error instanceof AgentKitError) {
      console.error(`[${error.code}] ${error.message}`);
    } else {
      // Plain errors from fetchMarketData: no matching payment option,
      // a price over the per-transaction limit, or a rejected paid retry
      console.error('Research task failed:', (error as Error).message);
    }
    throw error;
  }
}

Over-limit payments are worth handling explicitly. On the raw fetch() path, the per-transaction check in Step 5 throws before anything is signed. On MCP-routed calls, Agent Kit returns the -32404 interaction policy error with the violated rule. Either way, catch it, log it, and alert whoever owns the agent without the task silently failing or the agent trying to route around the limit.


Troubleshooting

The agent retries without paying (402 not detected). The code only pays when the status is exactly 402 and a PAYMENT-REQUIRED header (in either casing) is present; otherwise it throws the "no PAYMENT-REQUIRED or X-Payment header found" error. That usually means the provider uses the older v1 flow or a provider-specific format, so check their docs. If you route through Agent Kit's MCP paid_fetch tool instead, the first call returns a payment-required result (JSON-RPC -32402), which is the signal to sign and retry, not a failure.

The payment fails because of insufficient USDC or gas. A wallet with no USDC still produces a valid signature, but the facilitator rejects it and the retry returns 402 again, which the code reports as "Payment retry failed". Check the USDC balance at wallet.evmAddress on the chain set in CHAIN_ID. Abstraxn's docs also recommend funding that address with native gas on the payment network before the first payment.

The payment is rejected by a spending limit (expected behavior). A price above the per-transaction cap makes the Step 5 check throw before signing, and an MCP-routed call returns the -32404 "Interaction policy denied" error with a reason such as token_amount_above_max. Nothing is paid. Raise a limit only if the price is legitimate (updateInteractionPolicy for the per-transaction cap), or set hardBlock: false while testing to log violations without blocking.

The server rejects the payment proof. If a funded retry on the right chain still fails, check that CHAIN_ID matches the network you picked from accepts[] and that the EIP-712 domain name and version match the token contract (bridged USDC can differ). Also confirm the PAYMENT-SIGNATURE payload carries x402Version, resource and the full accepted entry, because a facilitator rejects a payload missing any of them.


What this agent can and cannot do

It calls a paywalled market data API. When the API returns 402, the agent decodes the payment requirement, signs a USDC TransferWithAuthorization using its server-side signing key, and retries with the signature attached in the same request cycle. A facilitator verifies and settles the payment on-chain. The calling code sees a data response. It never sees the 402.

Its limits come from the controls Agent Kit enforces today, plus the checks in your own code:

  • Per-transaction limits: no single payment above the cap (1 USDC in this example). An over-cap payment is blocked, not just logged.
  • Allowlisting: USDC on Base is the only contract it may interact with, so a compromised prompt can't redirect funds through Abstraxn's MCP layer.
  • Blocklisting: specific recipients can be refused with a recipientBlacklist rule.
  • A daily budget for direct x402 calls: the application-level budget from Step 3, which refuses and logs a payment that would take the day's total over budget.

What it cannot do:

  • Rely on the spend policy to stop payments. The $5 a day updateSpendPolicy budget covers paid MCP tool fees only, is stored server-side, and its enforcement on the hosted Agent Kit is in active development. It never counts the direct x402 payments this agent makes, so the daily total holds only because your service's budget from Step 3 enforces it.
  • Lean on Agent Kit policies for raw fetch() payments. Those payments are checked only by the code in Step 5, so that check has to stay.
  • Recover a lost access key. It is shown once, at creation.
  • Tell whether a payment matches what a human intended. Limits check amounts and counterparties, not intent. That is the separate check described in Know Your Intent, linked above.

Conclusion

An agent that handles 402 by surfacing it to a human isn't autonomous. It's a chatbot with extra steps. The pattern here is different: provision the wallet once, set the limits, and let the agent treat paywalled APIs the same way it treats any other dependency.

An autonomous agent that can't pay for the resources it needs will always require human babysitting. One that can pay, within enforced limits, is infrastructure you can actually trust to run overnight.

This is what "agentic economy" means in practice, not agents that browse the web and write emails, but agents that operate as economic actors with their own resources, their own spending authority, and hard constraints on how far that authority extends.

Start building: Get your API key from the Abstraxn Dashboard and install @abstraxn/agent-kit. The setup flow above takes under 30 minutes to run end to end.


Frequently asked questions

Do I need to understand blockchain to use this?

Not deeply. The essentials: USDC is a stablecoin (1 USDC = $1, no price volatility), Base is an Ethereum Layer 2 with low fees, and an EVM address is where you send funds. Your agent's wallet needs USDC to pay APIs because zero USDC means the payment signature will be constructed but the facilitator will reject it, and you'll get a second 402. In facilitated x402 flows (the v2 default), the facilitator often covers gas, but confirm with your API provider. Abstraxn's docs also recommend funding the agent's address with a little native gas on the payment network before the first payment.

What happens if the agent's wallet runs out of USDC?

In x402 v2 facilitated flows, your code will still construct a valid EIP-3009 signature, but the facilitator rejects it because there is no USDC to transfer. The retry returns 402 again. Treat a second 402 after signing as a likely balance problem. Monitor USDC at your wallet address and set a low-balance alert before you go to production.

Can I use a wallet I already have instead of a server wallet?

Yes. Pass wallet: 'external' to AgentKitClient, or on a single createAgent call, and give createAgent your own evmAddress. The SDK won't generate keys or return an access key, so you sign with your own stack, including the x402 payment signature. Agent Kit's autonomous x402 signing and the getServerSigner flow in this guide need a server wallet.

What chains does this work on?

Any chain that Abstraxn has configured for your account. The default x402 network is Base Sepolia (eip155:84532), and you switch it with the X402_NETWORK environment variable, for example to Base mainnet (eip155:8453) for production. Check your dashboard for the full list.

What does the API provider need to support x402?

Most x402 APIs today use the v2 transport: they return 402 with a PAYMENT-REQUIRED header (base64 JSON containing an accepts[] array), and expect a PAYMENT-SIGNATURE header on retry with the signed EIP-3009 payload. Older v1 integrations return the payment requirements in a JSON body and expect an X-PAYMENT request header, and some provider-specific integrations accept an on-chain tx hash instead. Check your provider's docs for which format they use, because they are not interchangeable.

Is the access key the same as a private key?

Functionally yes, as it's the credential that authorises transactions from the agent's wallet. The difference is that it's a P-256 key pair rather than secp256k1 (Ethereum's standard curve), and signing happens server-side through the Abstraxn server wallet signer rather than directly in your code.

About the Author

Pankaj Kumar

Pankaj Kumar

Software Engineer

Pankaj Kumar is a software engineer at Abstraxn, where he works on the infrastructure that lets AI agents authenticate, pay, and transact without human intervention. Before Abstraxn, he spent five years building payment systems and developer tooling. He writes about account abstraction, on-chain payments, and what it actually takes to make autonomous systems work reliably.