402 LAB · machine payments learning page
powered by run402 // kychee.com

HTTP 402, by hand.

This page is a live demo of machine-to-machine payments over HTTP. Click PAY $0.03 and watch a real micropayment settle on-chain. Change lanes and feel the tradeoffs.

Two interoperable protocols implement the same HTTP 402 handshake: x402 settles stablecoins (USDC) on Base, Ethereum, etc. MPP settles on Tempo (a purpose-built payments L1) and, via Stripe, fiat cards. Same 402 status code, two very different rails under the hood.

Same endpoint accepts both: POST api.run402.com/generate-image/v1. The 402 response advertises both rails simultaneously; the client picks by which header it returns (X-Payment for x402 / Authorization: Payment for MPP).

1 · Identity — where does your "wallet" live?

Four paths. They differ on: install friction, who holds the private key, whether it's an EOA or a smart account, and what rails it can pay on. Pick one to activate the lab.

play instantly EOA

Generates a secp256k1 private key in this browser via generatePrivateKey(). Stored in localStorage. Funds via Base Sepolia faucet (0.25 USDC). No install, no popup, silent signs.

good for: the 99% first-time visitor. zero gate.
bad for: testnet only — clearing site data deletes the key, losing any funds. No biometric, no recovery, no cross-device.
rails: x402 on Base. (MPP needs Tempo, this wallet has no Tempo funds.)

extension wallet EOA

MetaMask / Rabby / Rainbow / Coinbase Wallet extension. Discovered via EIP-6963 (modern wallet announce protocol). You sign every payment yourself with a real popup.

good for: crypto-natives who already have a wallet and want control.
bad for: the 90% of users who don't.
rails: x402 on Base. (MPP needs Tempo — most extensions don't support Tempo chain yet.)

Tempo passkey · webAuthn() PASSKEYSMART

A domain-bound passkey lives in your OS keychain (Face ID / Touch ID / Windows Hello), tied to this origin. Passkey signs on behalf of a Tempo smart account. No iframe, no popup — pure in-page WebAuthn.

First visit: Face ID prompt → "create passkey for run402 lab". Returning visit: Face ID prompt → "use existing passkey". The lab tries existing first; if none exists, it falls through to create.

good for: slickest onboarding — one tap, biometric, pure same-origin.
bad for: passkey is tied to this domain only. Useless on other 402 sites.
rails: MPP on Tempo Moderato (native smart account), pays pathUSD.

Tempo passkey · dialog() PASSKEYSMART

The same Tempo smart account, but the passkey lives at wallet.tempo.xyz (cross-origin). Your site opens a popup/iframe to wallet.tempo.xyz for create + every sign. This IS Tempo Wallet.

First visit: popup opens wallet.tempo.xyz → create passkey there (tied to that domain, portable across any site using Tempo). Returning visit: popup reuses the existing passkey silently.

good for: one passkey works across every 402 site that uses Tempo. The network-effect answer.
bad for: cross-origin popup on every sign. Slightly slower UX, popup-blocker prone.
rails: MPP on Tempo Moderato. Same as webAuthn but portable identity.

All four create real crypto accounts. No mocks. All four can sign the 402 handshake for real. Pick any — you can reset to switch later.

2 · Lane — rail · mode · network

Pick every lane manually. Nothing defaults — this is a learning page, you should see what you're choosing.

rail x402 · Base MPP · Tempo
mode explicit silent · soon
network Base Sepolia Base Mainnet · soon Tempo Moderato

What goes with what? Burner + extension → x402 · Base Sepolia. Tempo passkeys (either adapter) → MPP · Tempo Moderato. Pill combinations that don't match your identity will fail on PAY click with a clear error.

3 · Pay — the actual handshake

clicks: 0 spent: $0.00 avg:
image renders after payment

4 · Deep dives — what actually happens

The 402 response · both rails advertised at once

Hit POST /generate-image/v1 without any payment header and the server returns HTTP 402 Payment Required. The response carries two challenges, one per rail, so any compliant client can satisfy either:

HTTP/2 402
access-control-allow-origin: *
payment-required: <base64-json>    # x402 challenge
www-authenticate: Payment id="...",
    method="tempo", intent="charge",
    request="<base64-json>",
    expires="2026-04-17T18:25:27Z"    # MPP challenge
content-type: application/json

Try it: curl -i -X POST https://api.run402.com/generate-image/v1 -d '{"prompt":"x"}' -H "Content-Type: application/json"

Decoded x402 challenge (payment-required header → base64 JSON) includes an accepts array, one entry per supported network:

{
  "x402Version": 2,
  "accepts": [
    { "scheme": "exact",
      "network": "eip155:8453",       // Base mainnet
      "amount": "30000",              // = $0.03 USDC (6 dp)
      "asset": "0x8335…2913",         // USDC contract
      "payTo": "0x059D…5945" },
    { "scheme": "exact",
      "network": "eip155:84532",      // Base Sepolia
      "amount": "30000",
      "asset": "0x036C…CF7e",
      "payTo": "0x059D…5945" }
  ]
}

Decoded MPP challenge (request param of www-authenticate, also base64 JSON):

{
  "amount": "30000",
  "currency": "0x20c0…0000",         // pathUSD on Tempo
  "methodDetails": { "chainId": 42431 },  // Moderato
  "recipient": "0x059D…5945"
}
x402 · how the signature actually works (EIP-3009)

x402's default "exact" scheme rides EIP-3009 transferWithAuthorization — a USDC function that lets someone else broadcast your transfer if you signed an authorization. The client signs EIP-712 typed data; the facilitator broadcasts.

The typed data the client signs:

domain = {
  name: "USDC",
  version: "2",
  chainId: 84532,
  verifyingContract: <USDC address>
}

types = {
  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 = { from, to: payTo, value, validAfter: 0,
            validBefore: now + 300, nonce: random32 }

MetaMask (or any EOA) signs this via eth_signTypedData_v4. The client base64-encodes the signature + authorization into the X-Payment header:

X-Payment: <base64(JSON.stringify({
  x402Version: 2,
  scheme: "exact",
  network: "eip155:84532",
  payload: { signature, authorization }
}))>

The server's facilitator validates the signature via ecrecover, then broadcasts USDC.transferWithAuthorization(authorization, v, r, s) on-chain. The facilitator pays gas. The buyer pays only USDC.

Why this matters: smart wallets (Coinbase Smart Wallet, Tempo smart accounts) sign via ERC-1271, not ecrecover. USDC's transferWithAuthorization doesn't accept ERC-1271, so smart wallets can't pay the x402 "exact" scheme. They need either (a) a different x402 scheme (permit2, direct-transfer), or (b) the MPP rail — which is what Tempo passkeys use here.

MPP · how Tempo settles (and why passkeys work natively)

Tempo is an EVM-compatible L1 incubated by Paradigm + Stripe, purpose-built for payments. Moderato is its testnet (chain id 42431, RPC https://rpc.moderato.tempo.xyz).

The MPP "charge" intent on Tempo uses TIP-20 token transfers (Tempo's equivalent of ERC-20). Instead of the gasless authorization pattern, the client's account simply broadcasts a transfer directly — on Tempo, smart-contract accounts are first-class citizens and passkey-backed smart accounts are the norm. Gas is either paid natively or sponsored by a fee-payer service.

From mppx's tempo.charge() on the client:

// Pseudocode of what mppx does under the hood
const transferCall = Actions.token.transfer.call({
  amount: BigInt(challenge.amount),
  to: challenge.recipient,
  token: challenge.currency,
  memo: Attribution.encode({ serverId, clientId }),
});
const { receipts } = await sendCallsSync(client, {
  account,  // passkey-backed smart account
  calls: [transferCall],
});
const hash = receipts[0].transactionHash;

// Client then serializes the credential:
Credential.serialize({
  challenge,
  payload: { hash, type: "hash" },
  source: `did:pkh:eip155:${chainId}:${account.address}`,
});

The credential is then sent back as Authorization: Payment id="...", payload="<base64>". The server's mppx/server verifies the tx hash on-chain, confirms the amount + recipient + memo match the challenge, and serves the resource.

Why passkeys work here: the account is a smart contract that accepts P-256 / WebAuthn signatures via ERC-1271. Every transfer needs a fresh biometric unless you authorize an access key or session.

Spec references: draft-tempo-charge-00, mpp.dev.

Passkey adapters · webAuthn() vs dialog()

Both come from the accounts npm package. Both create a smart account on Tempo backed by a passkey. The only difference is where the passkey lives.

webAuthn()dialog() · Tempo Wallet
Passkey domainnoir.run402.comwallet.tempo.xyz
UI on signInline Face ID, no popupPopup or iframe to wallet.tempo.xyz
Portable identityNo — tied to this originYes — one wallet, any 402 site
First-load weightLighter+ popup roundtrip
Popup blocker riskNoneReal — must be triggered by click
StorageIndexedDB (idb-keyval)Same, plus cross-origin via postMessage

Integration is literally one line different:

import { Provider, webAuthn, dialog } from 'accounts';

// webAuthn flavor:
const p1 = Provider.create({ testnet: true, mpp: true, adapter: webAuthn() });

// dialog flavor (= Tempo Wallet):
const p2 = Provider.create({ testnet: true, mpp: true, adapter: dialog() });

Everything downstream — wallet_connect, provider.getAccount(), mppx.fetch() — is identical.

The Tempo faucet · tempo_fundAddress RPC

Unlike Base Sepolia where faucets are out-of-band HTTP services, Tempo Moderato has a faucet built into the chain's JSON-RPC. Any address can call:

POST https://rpc.moderato.tempo.xyz/
Content-Type: application/json

{"jsonrpc":"2.0","method":"tempo_fundAddress","params":["0xYOUR_ADDRESS"],"id":1}

Response returns an array of confirmed tx hashes. Funds (pathUSD + enough gas token) are delivered instantly. This is what run402 init mpp uses under the hood, and it's what the "faucet" button on this page calls when the identity is a Tempo passkey.

Via viem you can also use the tempo decorator: client.faucet.fundSync({ account }).

Which onboarding should a real product ship?

Depends on audience:

  • Crypto-first audience: extension wallet + burner fallback. Users already have MetaMask; the 90% who don't get auto-onboarded into a burner and graduate later.
  • Mainstream audience (like the noir demo): Tempo passkey via dialog(). One tap, biometric, portable across 402 sites. The UX closest to Apple Pay — because that's what MPP + Tempo is designed to feel like.
  • Maximum simplicity, no third-party window: Tempo passkey via webAuthn(). Inline, domain-bound, no popup. Less network-effect but smoother first impression.
  • Dev mode: burner + extension (what this lab defaults to today for x402 paths).

You'll notice the only one that's never the answer is the burner for a real product — keys in localStorage are a support ticket waiting to happen.

Libraries used on this page
packagerole
viemEVM wallet/public clients, EIP-712 signing, tempo chain helpers
accounts (tempoxyz/accounts)EIP-1193 provider with webAuthn() + dialog() adapters + first-class MPP support
mppx/clientMachine Payment Protocol client — payment-aware fetch, tempo charge method
EIP-6963 (browser-native)Extension wallet discovery via eip6963:announceProvider events
WebAuthn (browser-native)Passkey create/get — Face ID / Touch ID prompts

All loaded from esm.sh on page load — no build step, no bundler, view-source readable.

live console — real HTTP · real signing