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)¶
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)¶
-
Settle. Agent's wallet sends
amountUSDC (token contract0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913) torecipienton Base (chain_id=8453). Base L2, average settlement < 200 ms, 1 confirm. -
Edge verify. POST
tx_hash + quote_id + agent_idto/x402/verify. The Cloudflare Pages function: - calls
eth_getTransactionReceipt(3x retry, 1.2s timeout each), - confirms
receipt.status == 0x1, - parses the ERC20 Transfer log: token contract matches USDC,
topics[1]==payer,topics[2]==recipient,data >= amount_micro, - binds the log to the parent receipt's
(blockHash, txHash)so a malicious RPC cannot splice in a Transfer log from a different tx, - rejects
removed: trueorphan-branch logs, -
records the
tx_hashin KV with a 25h TTL replay window. -
Origin double-check. This route accepts the
signed_proofif 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). -
Audit row. Origin appends to
am_x402_payment_logwithendpoint_path = /api/packet/{slug}/full. UNIQUE ontxn_hashmakes replay idempotent — the same envelope returns the samepayment_idand 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:
- POST
/v1/billing/checkout→ Stripe Checkout URL. - User completes Stripe Checkout, gets an
Authorization: Bearer jc_...key. - Retry
GET /api/packet/{slug}/fullwith the bearer — the route skips the x402 dance entirely and mints the SAME shape of unlock JWT withsettled_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_ADDRESS—0x...operator wallet on Base mainnet. Mirrors theJPCITE_X402_ADDRESSPages secret used byfunctions/x402_handler.ts.JPCITE_PACKET_UNLOCK_JWT_SECRET— ≥32 random bytes. HMAC-SHA256 signing key for the unlock JWT. Independent ofJPCITE_X402_QUOTE_SECRETso a key rotation on one does not invalidate the other.- Optional:
JPCITE_X402_MOCK_PROOF_ENABLED=1ANDJPCITE_ENV=dev|testin CI/local for the deterministic mock-proof path. Production boot refuses to start with this combination (see_assert_x402_mock_proof_disabled_in_productioninapi/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).