コンテンツにスキップ

F5 — x402 USDC native packet purchase (Base mainnet)

Last edit: 2026-05-18. Surface: GET /api/packet/{slug}/full. Per-packet price: ¥30 ≈ $0.20 USDC on Base. No KYC. No API key. Self-serve agent-native — a CLI agent (Cursor / Claude Code / CodeX) with a USDC wallet on Base can settle and unlock a packet in < 2 sec.

TL;DR

agent ──GET /api/packet/{slug}/full──┐
                                HTTP 402
                     { amount: 0.20, currency: USDC, network: base,
                       recipient: 0x..., nonce, expires }
        agent ──pay 0.20 USDC on Base (sub-second)──┐
                            edge verify via functions/x402_handler.ts
                                  signed_proof attested in header
agent ──GET /api/packet/{slug}/full ──┐
        X-Payment: base64url({usdc_tx_hash, signed_proof, nonce})
        X-Payment-Payer: 0x...
                                              HTTP 200
                              { unlock_jwt, expires_at, packet: {...} }

The unlock_jwt is a 24h HMAC-signed token that the static packet renderer (F2 paywall) can verify offline — no second round trip is needed once the JWT is in hand.

Endpoint contract

Request — first call (challenge)

GET /api/packet/houjin360-sample/full HTTP/1.1
Host: api.jpcite.com
X-Payment-Required: x402

Note: X-Payment-Required is advisory only. Any unauthenticated request to this route without an X-Payment envelope receives a 402.

Response — 402 challenge

HTTP/1.1 402 Payment Required
Content-Type: application/json
X-Payment-Required: x402
X-Payment-Challenge-Nonce: TFv3OXC2-tIp_kIH_yWg9w

{
  "error": "payment_required",
  "packet_slug": "houjin360-sample",
  "amount": 0.20,
  "amount_yen_equiv": 30,
  "currency": "USDC",
  "network": "base",
  "recipient": "0xJpciteRecipientAddress...",
  "nonce": "TFv3OXC2-tIp_kIH_yWg9w",
  "expires": 1747570283,
  "proof_format": "X-Payment: base64url(JSON{usdc_tx_hash, signed_proof})",
  "fallback_stripe_path": "/v1/billing/checkout"
}

Settle on Base

# Agent-side pseudo. Use viem / web3.py / ethers / whatever your agent runtime
# already has. We do NOT supply a settlement SDK.
tx_hash = base_l2_client.send_usdc(
    to=challenge["recipient"],
    amount_usdc=challenge["amount"],   # 0.20
    memo=challenge["nonce"],
)
# Wait for 1 block confirmation (Base finalises in ~200 ms).

Verify at the Cloudflare Pages edge

POST to /x402/verify (the existing Wave 48 edge handler) with {tx_hash, quote_id, agent_id}. The edge runs eth_getTransactionReceipt and returns a signed_proof opaque token bound to the receipt blockHash. Pre-computed wrappers live in docs/cookbook/r26_x402_wallet_payment.md.

Request — second call (retry with payment)

GET /api/packet/houjin360-sample/full HTTP/1.1
Host: api.jpcite.com
X-Payment-Payer: 0xAgentPayerAddress...
X-Payment: eyJub25jZSI6IlRGdjNPWEMyLXRJcF9rSUhfeVdnOXciLCJzaWduZWRfcHJvb2YiOiIweGFiYy4uLiIsInVzZGNfdHhfaGFzaCI6IjB4ZGVmLi4uIn0

The X-Payment header is base64url(JSON{usdc_tx_hash, signed_proof, nonce}). URL-safe base64 with padding stripped. No newlines.

Response — 200 with unlock JWT + packet body

HTTP/1.1 200 OK
Content-Type: application/json

{
  "packet_slug": "houjin360-sample",
  "unlock_jwt": "eyJleHAiOjE3NDc2NTY2NjUsImtpbmQiOiJwYWNrZXRfdW5sb2NrIiwicGF5ZXIiOiIweGFnZW50In0.7f3e9d4a2b1c5e6f80123abc4def5678",
  "expires_at": 1747656665,
  "settled_via": "x402_usdc_base",
  "payment_id": 4231,
  "payer_address": "0xagentpayer...",
  "txn_hash": "0xabc...",
  "packet": {
    "packet_id": "houjin360-sample",
    "kind": "houjin360",
    ...
  }
}

x402 challenge schema (JSON)

Field Type Notes
error string Always "payment_required" on 402.
packet_slug string Echo of the path slug.
amount number 0.20. USDC, 6-decimal.
amount_yen_equiv integer 30. Display-only.
currency string "USDC".
network string "base". Base mainnet (chain id 8453).
recipient string 0x... operator wallet. From JPCITE_X402_RECIPIENT_ADDRESS secret.
nonce string 22-char URL-safe random. Caller echoes back in X-Payment.nonce.
expires integer Unix seconds. 600s window. After expiry the agent must re-fetch.
proof_format string Hint string for the agent.
fallback_stripe_path string /v1/billing/checkout. For agents without USDC.

USDC verify steps (Base mainnet)

  1. Settle. Agent's wallet sends amount USDC (token contract 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913) to recipient on Base (chain_id=8453). Base L2, average settlement < 200 ms, 1 confirm.

  2. Edge verify. POST tx_hash + quote_id + agent_id to /x402/verify. The Cloudflare Pages function:

  3. calls eth_getTransactionReceipt (3x retry, 1.2s timeout each),
  4. confirms receipt.status == 0x1,
  5. parses the ERC20 Transfer log: token contract matches USDC, topics[1]==payer, topics[2]==recipient, data >= amount_micro,
  6. binds the log to the parent receipt's (blockHash, txHash) so a malicious RPC cannot splice in a Transfer log from a different tx,
  7. rejects removed: true orphan-branch logs,
  8. records the tx_hash in KV with a 25h TTL replay window.

  9. Origin double-check. This route accepts the signed_proof if it is non-empty AND (a) the dev/test mock-proof flag is OFF — origin trusts the edge attestation, or (b) the flag is ON AND the proof matches the deterministic sha256 (dev path only).

  10. Audit row. Origin appends to am_x402_payment_log with endpoint_path = /api/packet/{slug}/full. UNIQUE on txn_hash makes replay idempotent — the same envelope returns the same payment_id and re-mints the JWT.

Fallback to Stripe (if x402 not configured)

If the operator has not set both JPCITE_X402_RECIPIENT_ADDRESS and JPCITE_PACKET_UNLOCK_JWT_SECRET, the route fails closed with 503:

{
  "error": "x402_packet_unavailable",
  "hint": "Operator has not configured x402 recipient + unlock secret.",
  "fallback_stripe_path": "/v1/billing/checkout"
}

Agents (or human users) follow the Stripe path:

  1. POST /v1/billing/checkout → Stripe Checkout URL.
  2. User completes Stripe Checkout, gets an Authorization: Bearer jc_... key.
  3. Retry GET /api/packet/{slug}/full with the bearer — the route skips the x402 dance entirely and mints the SAME shape of unlock JWT with settled_via: stripe_metered.

Either path produces an unlock JWT compatible with the F2 paywall verifier, so the static renderer only handles one signature format.

Example — Cursor / Claude Code agent (Python, requests + viem)

# tools/offline/example_packet_x402.py
# NOTE: this lives under tools/offline/ because it speaks to a wallet.
# Do NOT place under src/, scripts/cron/, scripts/etl/, tests/.

import base64
import json
import os
import requests
# viem / web3.py / ethers — whichever your runtime ships.
from your_wallet import sign_and_send_usdc, agent_address

API = "https://api.jpcite.com"
SLUG = "houjin360-sample"
PAYER = agent_address()

# 1. Challenge
resp = requests.get(f"{API}/api/packet/{SLUG}/full")
assert resp.status_code == 402, resp.text
ch = resp.json()

# 2. Settle on Base
tx_hash = sign_and_send_usdc(
    to=ch["recipient"],
    amount_usdc=ch["amount"],
)

# 3. Edge verify (returns signed_proof opaque token)
verify = requests.post(
    f"{API}/x402/verify",
    json={"tx_hash": tx_hash, "quote_id": ch["nonce"], "agent_id": PAYER},
).json()
signed_proof = verify["signed_proof"]

# 4. Retry
envelope = base64.urlsafe_b64encode(
    json.dumps({"usdc_tx_hash": tx_hash, "signed_proof": signed_proof, "nonce": ch["nonce"]}).encode()
).rstrip(b"=").decode()

resp2 = requests.get(
    f"{API}/api/packet/{SLUG}/full",
    headers={"X-Payment": envelope, "X-Payment-Payer": PAYER},
)
assert resp2.status_code == 200
unlocked = resp2.json()
print("unlock_jwt:", unlocked["unlock_jwt"])
print("packet:", unlocked["packet"])

MCP call sequence (Streamable HTTP)

The MCP transport reuses the same headers as REST. Wrap the dance as a single tool call in your agent runtime:

{
  "tool": "jpcite_unlock_packet",
  "arguments": {
    "slug": "houjin360-sample",
    "payment_rail": "x402_usdc_base",
    "payer_address": "0xagent..."
  }
}

The MCP server hits /api/packet/{slug}/full, drives the challenge / verify dance, returns the unlock JWT + packet body. Implementation sketch lives at src/jpintel_mcp/mcp/jpcite_tools/packet_unlock_tool.py (landing under F6).

Compatibility matrix

Agent Wallet Path
Claude Code + Anthropic Tool use None Stripe Checkout
Claude Code + Anthropic Tool use USDC on Base x402 (this route)
Cursor agent + Cline USDC on Base x402 (this route)
CodeX CLI USDC on Base x402 (this route)
Continue / browser Stripe Customer Stripe Checkout

Either path mints the same unlock JWT shape; the static F2 paywall renderer only verifies the HMAC. Memory anchor: feedback_jpcite_customer_is_cli_agent — the CLI agent is the primary customer, not the browser user.

Operator setup

Required environment variables (Fly secret + GitHub Actions secret mirror per feedback_secret_store_separation):

  • JPCITE_X402_RECIPIENT_ADDRESS0x... operator wallet on Base mainnet. Mirrors the JPCITE_X402_ADDRESS Pages secret used by functions/x402_handler.ts.
  • JPCITE_PACKET_UNLOCK_JWT_SECRET — ≥32 random bytes. HMAC-SHA256 signing key for the unlock JWT. Independent of JPCITE_X402_QUOTE_SECRET so a key rotation on one does not invalidate the other.
  • Optional: JPCITE_X402_MOCK_PROOF_ENABLED=1 AND JPCITE_ENV=dev|test in CI/local for the deterministic mock-proof path. Production boot refuses to start with this combination (see _assert_x402_mock_proof_disabled_in_production in api/main.py).

Anchors

  • Memory: feedback_agent_x402_protocol, feedback_jpcite_customer_is_cli_agent, feedback_agent_monetization_3_payment_rails, feedback_secret_store_separation.
  • Wave 48 contract: src/jpintel_mcp/api/x402_payment.py, migration 282.
  • Edge: functions/x402_handler.ts.
  • Stripe parity: src/jpintel_mcp/api/billing.py.
  • Tests: tests/api/test_x402_packet_2026_05_18.py (22 scenarios).