DEV Community

Tuf Ti
Tuf Ti

Posted on

I spent 3 sessions debugging x402 payment signing. Here's the shortcut.

Three weeks ago I started building a payment proxy for AI agents. The idea was simple: agents deposit USDC once, and Cinderwright handles all the x402 signing so they don't have to.

What I didn't expect was spending three full sessions just getting the payment signature to work against real services.

Here's everything I learned, and the shortcut that makes all of it unnecessary.

The x402 Payment Flow (What the Docs Say)

On paper, it's elegant:

  1. Agent calls a service → gets HTTP 402 with payment-required header
  2. Agent decodes the base64 header, reads the payment requirements
  3. Agent signs an EIP-3009 authorization
  4. Agent retries with payment-signature header
  5. Service verifies via facilitator, returns data

The official docs show this working in a few lines. What they don't show: the four different ways it can silently fail.

Problem 1: The accepted Field Nobody Mentions

The PayAI facilitator (used by Bazaar, scoutgate, and others) requires a specific field in your payment payload called accepted. This is the payment requirement object you selected from the accepts array.

Without it:

{"isValid": false, "invalidReason": "invalid_payload", "invalidMessage": "accepted: Invalid input"}
Enter fullscreen mode Exit fullscreen mode

With it:

const rawPayload = await scheme.createPaymentPayload(x402Version, requirement, null);
const fullPayload = {
  ...rawPayload,
  accepted: requirement  // ← This. Without this, nothing works.
};
Enter fullscreen mode Exit fullscreen mode

Problem 2: The Network String Mismatch

Services send "eip155:8453" in their 402 challenge.

The x402/schemes package (the newer one) only understands "base".

The @x402/evm package (the older one) understands "eip155:8453".

These are different packages. If you import the wrong one, you get:

Error: Unsupported network: eip155:8453
Enter fullscreen mode Exit fullscreen mode

The fix: use @x402/evm for services that send eip155:8453 in their challenge. Or normalize manually before signing and restore the original string before sending.

Problem 3: The Wrong Header Name

I tried X-PAYMENT. Then PAYMENT-SIGNATURE. Then x-payment.

The correct header is payment-signature (lowercase). This is what @x402/express actually reads on the server side:

const header = adapter.getHeader("payment-signature") || adapter.getHeader("x-payment");
Enter fullscreen mode Exit fullscreen mode

Both work. But X-PAYMENT alone does not.

Problem 4: The Facilitator Doesn't Support Mainnet

x402.org's facilitator (the free one) only supports testnet. For mainnet services (Bazaar, ScoutGate, etc.), you need the PayAI facilitator at https://facilitator.payai.network.

Our server was configured to use https://facilitator.payai.network but the verify endpoint expects the full payment requirements object, not just the single accepts entry.

What Actually Works (Full Working Example)

import { ExactEvmScheme, toClientEvmSigner } from '@x402/evm';
import { privateKeyToAccount } from 'viem/accounts';
import { createWalletClient, createPublicClient, http } from 'viem';
import { base } from 'viem/chains';

const account = privateKeyToAccount(process.env.PRIVATE_KEY);
const wc = createWalletClient({ account, chain: base, transport: http() });
const pc = createPublicClient({ chain: base, transport: http() });
wc.address = account.address;

const signer = toClientEvmSigner(wc, pc);
const scheme = new ExactEvmScheme(signer);

// Step 1: Get 402
const r1 = await fetch(serviceUrl);
const payReqs = JSON.parse(Buffer.from(r1.headers.get('payment-required'), 'base64').toString());
const requirement = payReqs.accepts[0];

// Step 2: Sign
const rawPayload = await scheme.createPaymentPayload(payReqs.x402Version || 2, requirement, null);
const fullPayload = {
  ...rawPayload,
  accepted: requirement  // ← The missing field
};
const encoded = Buffer.from(
  JSON.stringify(fullPayload, (k, v) => typeof v === 'bigint' ? v.toString() : v)
).toString('base64');

// Step 3: Retry
const r2 = await fetch(serviceUrl, {
  headers: { 'payment-signature': encoded }  // ← lowercase
});
// Done. Data arrives.
Enter fullscreen mode Exit fullscreen mode

That's the full working implementation. About 25 lines.

The Shortcut

If you don't want to deal with any of this, there's a proxy:

# 1. Get a key (one time)
curl -X POST https://api.ideafactorylab.org/proxy/setup \
  -H "Content-Type: application/json" \
  -d '{"wallet": "0xYourBaseWallet"}'

# 2. Deposit some USDC to the address it returns

# 3. Call any x402 service
curl "https://api.ideafactorylab.org/proxy?url=https://bazaar-gateway.vercel.app/api/weather" \
  -H "X-CW-Key: sk_cw_yourkey"
Enter fullscreen mode Exit fullscreen mode

That's it. The proxy handles signing, the accepted field, the facilitator format, the header name, and auto-failover if a service is down. You just get the data.

The proxy currently covers 1,686 x402 services. It charges a 10% markup. No gas management required.

Discovery

The same project also indexes all x402 services (plus MPP and Lightning) so you can find them:

# Free, no payment needed
curl "https://api.ideafactorylab.org/discover?q=weather"
curl "https://api.ideafactorylab.org/quality"  # A-F grades on 2,771 services
Enter fullscreen mode Exit fullscreen mode

MCP server for Claude Desktop:

npx -y cinderwright-mcp-server
Enter fullscreen mode Exit fullscreen mode

If you've hit any of these issues yourself, drop a comment — curious whether the accepted field problem was widespread or just a few services.

GitHub: https://github.com/cinderwright-ai/cinderwright-api

Proxy docs: https://api.ideafactorylab.org/proxy

Top comments (0)