Building a web service where humans generate basic raster QR codes for free while autonomous AI agents pay microtransactions for vector SVGs exposes an immediate architectural challenge: standard web stacks expect session cookies or OAuth tokens, whereas AI agents require frictionless, programmatic micropayments. The x402 protocol solves this by bringing native 402 Payment Required HTTP headers to HTTP APIs and Model Context Protocol (MCP) endpoints.

Transitioning from a testnet sandbox to Base Mainnet using the Coinbase CDP Facilitator reveals strict cryptographic constraints, unparsed key formats, and on-chain contract signature expectations that bypass traditional documentation. This guide details how to configure Cloudflare Worker Edge runtimes, bypass V8 cryptographic limitations, enforce Permit2 transfers, and implement dual-layer payment interception for AI agents.

The monetization layer protects premium endpoints by requiring micropayments settleable on Base Mainnet or Solana. When an unauthenticated request arrives at the protected endpoint, the server halts execution, computes the payment requirements, and returns a 402 Payment Required challenge.

x402 Protocol Sequence Diagram: AI Agent, Cloudflare Worker Server, and Coinbase CDP Facilitator on Base Mainnet
Sequence Diagram — End-to-end execution flow of the x402 protocol, from the initial HTTP 402 challenge through client-side Permit2 signing, facilitator verification, and on-chain settlement.

Cloudflare Workers execute inside V8 isolates rather than standard Node.js runtimes. Cryptographic primitives within @x402/evm and @solana/keys rely on Node.js built-ins (crypto, events, Buffer). You must enable Node.js compatibility flags in your wrangler.toml configuration:

compatibility_date = "2024-09-23"
compatibility_flags = [ "nodejs_compat" ]

Configure the following variables in the Cloudflare Dashboard or secrets store:

  • X402_NETWORK_MODE: mainnet
  • X402_PAYTO_EVM: Your seller wallet address receiving USDC on Base (e.g., 0x...).
  • X402_PAYTO_SOL: Your Solana seller wallet address.
  • CDP_API_KEY_NAME: The identifier string from your Coinbase CDP API Key.
  • CDP_API_KEY_PRIVATE_KEY: The raw 64-byte base64 private key string from cdp_api_key.json.

The @x402/evm client defaults to older EIP-3009 (TransferWithAuthorization) signature formats. When submitted to the Coinbase CDP Facilitator, EIP-3009 signatures fail during on-chain settlement because Coinbase executes settlements directly through the Permit2 smart contract (0x000000000022D473030F116dDEE9F6B43aC78BA3) on Base Mainnet.

An EIP-3009 payload targets the USDC token contract domain separator directly, using TransferWithAuthorization as its primary type. When Permit2 processes this mismatched data structure, EVM address recovery returns an invalid signer, causing the contract to execute a REVERT opcode. Coinbase surfaces this as an invalid_payload: contract call failed: execution reverted error.

To prevent this, you must explicitly declare assetTransferMethod: "permit2" within the accepts.extra configuration block of your server's payment requirements:

export interface X402Env {
  X402_NETWORK_MODE?: string;
  X402_PAYTO_EVM: string;
  X402_PAYTO_SOL: string;
  CDP_API_KEY_NAME: string;
  CDP_API_KEY_PRIVATE_KEY: string;
}

export function getPaymentRequirements(env: X402Env) {
  const isProd = env.X402_NETWORK_MODE === "mainnet";
  const evmAddr = env.X402_PAYTO_EVM || "0x0000000000000000000000000000000000000000";
  const solAddr = env.X402_PAYTO_SOL || "11111111111111111111111111111111";

  // Base Mainnet vs Sepolia
  const evmNetwork = isProd ? "eip155:8453" : "eip155:84532";
  const solNetwork = isProd
    ? "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"
    : "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1";

  // Base USDC
  const evmAsset = isProd
    ? "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
    : "0x036CbD53842c5426634e7929541eC2318f3dCF7e";

  return {
    x402Version: 2,
    accepts: [
      {
        scheme: "exact",
        price: "$0.001",
        amount: "1000", // 0.001 USDC (6 decimals)
        network: evmNetwork,
        payTo: evmAddr,
        asset: evmAsset,
        maxTimeoutSeconds: 300,
        extra: {
          name: "USDC",
          version: "2",
          assetTransferMethod: "permit2",
        },
      },
      {
        scheme: "exact",
        price: "$0.001",
        amount: "1000",
        network: solNetwork,
        payTo: solAddr,
        maxTimeoutSeconds: 300,
      },
    ],
    description: "Premium Vector SVG QR Generation & Custom Styling",
    mimeType: "application/json",
  };
}

Authenticating with Coinbase CDP requires sending a signed Ed25519 JSON Web Token (JWT). Standard cryptographic engines like node:crypto expect private keys wrapped in standard ASN.1 structures (PEM or DER) containing headers like -----BEGIN PRIVATE KEY-----.

Coinbase CDP provides API keys as naked 64-byte base64 strings. Passing this naked string into node:crypto.createPrivateKey() triggers low-level parser failures. The jose library circumvents ASN.1 parsing entirely by constructing an explicit JSON Web Key (JWK) directly from raw byte arrays:

import { importJWK, SignJWT } from "jose";

export async function generateCdpJwt(
  keyName: string,
  privateKeyBase64: string,
  requestPath: string
): Promise<string> {
  const now = Math.floor(Date.now() / 1000);
  const nonce = crypto.randomUUID().replace(/-/g, "");
  const decoded = Buffer.from((privateKeyBase64 || "").trim(), "base64");
  const seed = decoded.subarray(0, 32);
  const publicKey = decoded.subarray(32);

  const jwk = {
    kty: "OKP",
    crv: "Ed25519",
    d: seed.toString("base64url"),
    x: publicKey.toString("base64url"),
  };

  const key = await importJWK(jwk, "EdDSA");
  const uri = `POST api.cdp.coinbase.com/platform/v2/x402/${requestPath}`;

  return await new SignJWT({
    iss: "cdp",
    sub: keyName,
    uris: [uri],
  })
    .setProtectedHeader({ alg: "EdDSA", kid: keyName, typ: "JWT", nonce })
    .setIssuedAt(now)
    .setNotBefore(now)
    .setExpirationTime(now + 120)
    .sign(key);
}

Protected endpoints evaluate incoming requests for the payment-signature header. If absent, the server emits the 402 Payment Required challenge. When present, the server verifies the signature, settles the payment on-chain via Coinbase CDP, and executes the core handler:

app.post("/generate", async (c) => {
  const env = c.env;
  const paymentSig = c.req.header("payment-signature");
  const reqs = getPaymentRequirements(env);

  if (!paymentSig) {
    c.header("PAYMENT-REQUIRED", getEncodedPaymentRequiredHeader(env));
    return c.json({ error: "Payment Required" }, 402);
  }

  const payload = decodePaymentSignatureHeader(paymentSig);
  const server = createResourceServer(env);
  const matchingReq =
    server.findMatchingRequirements(reqs.accepts, payload) || reqs.accepts[0];

  const verification = await server.verifyPayment(payload, matchingReq);
  if (!verification.isValid) {
    c.header("PAYMENT-REQUIRED", getEncodedPaymentRequiredHeader(env));
    return c.json(
      { error: `Verification Failed: ${verification.invalidReason}` },
      402
    );
  }

  const settlement = await server.settlePayment(payload, matchingReq);
  c.header("PAYMENT-RESPONSE", encodePaymentResponseHeader(settlement));

  const body = await c.req.json();
  const svgOutput = await generateVectorQR(body.text, body.styleOptions);
  return c.json({ success: true, result: svgOutput });
});

An MCP endpoint serving AI agents must handle payment challenges across varying client architectures. The endpoint supports both transport-level headers and structured JSON-RPC error bodies concurrently:

app.post("/mcp", async (c) => {
  const env = c.env;
  const paymentSig = c.req.header("payment-signature");
  const body = await c.req.json();

  const isPaidRequest =
    body.method === "tools/call" &&
    body.params?.name === "generate_qr_code" &&
    body.params?.arguments?.format === "svg";

  if (isPaidRequest && !paymentSig) {
    const encodedHeader = getEncodedPaymentRequiredHeader(env);
    const rawReqs = getPaymentRequirements(env);
    c.header("PAYMENT-REQUIRED", encodedHeader);

    return c.json(
      {
        jsonrpc: "2.0",
        id: body.id || "1",
        error: {
          code: 402,
          message:
            "Payment Required ($0.001 USD via x402 for vector SVG export)",
          data: { x402: rawReqs },
        },
      },
      402
    );
  }

  return handleMcpExecution(c, body, paymentSig);
});

Validating local test setups introduces operational edge cases that can halt settlement:

  • Self-Sending Prevention: The buyer wallet generating payment signatures cannot match the seller destination address (X402_PAYTO_EVM). Supplying matching addresses causes Coinbase CDP to reject settlement with a self_send_not_allowed error.
  • Permit2 Approval Requirement: The buyer wallet must execute an on-chain ERC-20 approve transaction authorizing the Permit2 contract (0x000000000022D473030F116dDEE9F6B43aC78BA3) to spend its USDC. Skipping this step causes the contract to revert during settlement:
const hash = await client.writeContract({
  address: BASE_USDC,
  abi: parseAbi(["function approve(address spender, uint256 amount) returns (bool)"]),
  functionName: "approve",
  args: [PERMIT2_CONTRACT, MAX_UINT256],
});

Automatic Indexing by the Coinbase x402 Bazaar

To enable automatic indexing by the Coinbase x402 Bazaar, your server must expose a strict schema. The Bazaar indexer looks for this schema in two places:

  1. Your global /.well-known/x402 directory.
  2. The HTTP 402 Payment Required response of the actual paid endpoint (crucial for auto-discovery when AI Agents make transactions).

First, define your schema in a central file so it can be shared:

export const BAZAAR_EXTENSIONS = {
  bazaar: {
    discoverable: true,
    info: {
      input: {
        type: "http",
        method: "POST",
        bodyType: "json",
        body: { myParameter: "example_value" },
      },
    },
    schema: {
      $schema: "https://json-schema.org/draft/2020-12/schema",
      type: "object",
      properties: {
        input: {
          type: "object",
          properties: {
            body: {
              type: "object",
              properties: {
                myParameter: {
                  type: "string",
                  description: "A description of this parameter",
                },
              },
              additionalProperties: true,
              required: ["myParameter"],
            },
          },
        },
      },
    },
  },
};

Next, update your /.well-known/x402 endpoint to use the valid resources and extensions fields:

import { BAZAAR_EXTENSIONS } from "../utils/x402";

app.get("/.well-known/x402", (c) => {
  const env = c.env;
  const baseRequirements = getPaymentRequirements(env);

  return c.json({
    x402Version: 2,
    name: "my x402 project",
    description: "my service description",
    homepage: "https://example.com",
    // Note: It MUST be 'resources', not 'services'
    resources: [
      {
        url: "https://example.com/do_something",
        type: "http",
        description: "do something",
        accepts: baseRequirements.accepts,
        // Include the Bazaar schema here
        extensions: BAZAAR_EXTENSIONS,
      },
    ],
  });
});

Finally, ensure your API endpoint includes this exact same extensions block when returning a 402 Payment Required challenge. If this is missing from the 402 response, the Coinbase Facilitator will not index your endpoint when AI agents pay for it:

export function getPaymentRequirements(env: Env, resourceUrl?: string) {
  return {
    x402Version: 2,
    accepts: [
      /* ... */
    ],
    description: "my service description",
    mimeType: "application/json",
    // Critical for auto-discovery
    extensions: BAZAAR_EXTENSIONS,
    ...(resourceUrl ? { resource: { url: resourceUrl } } : {}),
  };
}