Hermoddocs

x402 machine payments

Pay per request with USDC from a wallet. No signup, no email, no card — built for bots and agents.

x402 lets a wallet buy gateway access directly. Sign one USDC transfer, get an RPC URL back, start calling. There is no account to create, no email to verify, and no card on file.

It exists for callers that cannot sign up: autonomous agents, trading bots, one-off data scrapers. If you have a browser and a credit card, the normal plans are cheaper.

Availability depends on the operator enabling it. Check GET /v1/x402/info — if it returns "enabled": false, x402 is not live on this deployment.

The endpoint speaks standard x402 v2 with the exact scheme, so any conforming client works. If you already use @x402/fetch or similar, point it at /v1/x402/deposit and skip the manual signing below.

The short version

Point any x402-aware client at the RPC endpoint and forget the rest:

import { wrapFetchWithPayment } from "@x402/fetch";
 
const fetchWithPay = wrapFetchWithPayment(fetch, wallet);
const res = await fetchWithPay("https://infra.originstake.com/monad/evm", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "eth_blockNumber", params: [] }),
});

Anonymous calls are served until they pass the free allowance. After that the endpoint answers 402 with the price, the client pays, and the same request is answered — no second endpoint, nothing to read first.

The paid response carries the key it just bought:

X-RPC-Endpoint: https://infra.originstake.com/monad/evm/originrpc_x402_…
PAYMENT-RESPONSE: <base64 receipt with the tx hash, CU granted and tier>

Use that URL for everything afterwards. It is already paid for and skips the payment path entirely, which any long-running caller should do.

Capacity is granted the moment the transfer is broadcast, not when it confirms — an RPC caller cannot sit through a block. We only broadcast after simulating, so the transfer is near-certain; if one ever reverts, the compute units are taken back.

How it works

  1. Read the menu. GET /v1/x402/info tells you the price, the minimum deposit, which chains are served, and how much capacity is still available.
  2. Sign a transfer. An EIP-3009 transferWithAuthorization for USDC, payable to the address in the challenge. You sign it; you do not send it, and you never pay gas.
  3. Post it in the PAYMENT-SIGNATURE header as base64 of the standard PaymentPayload. We verify the signature, simulate the transfer, and hand it to a facilitator to broadcast.
  4. Get your URL. Once the transfer confirms, your wallet has a balance in compute units and a gateway key. Poll the payment id until it reports confirmed.

Capacity is granted only after the money lands on-chain. Nothing is extended on credit, which is also why there is no invoice, no refund flow, and nothing to reconcile later.

Pricing

One rate for everything, charged in compute units. A cheap read costs a few CU; a wide eth_getLogs costs hundreds. The exact per-method weights are the same ones documented under rate limits.

GET /v1/x402/info returns the live rate as priceUnitsPerMcu, in USDC base units per 1,000,000 CU (1.00 USDC per 1M CU at the time of writing, about $0.000024 for a typical request).

Every wallet also gets a free allowance each calendar month (UTC), freeCuPerMonth in /info (1,000,000 CU today). Metering uses it before it draws on your paid balance.

Your deposit also decides your rate limit. Bigger deposits buy higher tiers, because a higher request rate needs a larger balance behind it — we cap what any one wallet can burn before metering catches up. The tiers array in /info lists each tier's requests-per-second and the deposit it requires.

Tiers are also capped by how much capacity is left on the chain you want. If a tier is sold out, the deposit still lands and you get the highest tier that fits. availableRps in /info tells you before you sign.

Quick start

TypeScript

import { createWalletClient, http, parseUnits } from "viem";
import { privateKeyToAccount } from "viem/accounts";
 
const GATEWAY = "https://infra.originstake.com";
const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`);
 
// 1. Get a challenge (nonce + who to pay).
const challenge = await fetch(`${GATEWAY}/v1/x402/deposit`, {
  method: "POST",
}).then((r) => r.json());
 
// 2. Sign the transfer. Never broadcast it yourself.
const now = Math.floor(Date.now() / 1000);
const value = parseUnits("1", 6); // 1 USDC
const authorization = {
  from: account.address,
  to: terms.payTo,
  value: value.toString(),
  validAfter: String(now - 60),
  validBefore: String(now + 600),
  // Conforming clients generate their own 32-byte nonce. The challenge
  // also carries a `suggestedNonce` if you would rather not.
  nonce: challenge.suggestedNonce,
};
 
// Read the domain from the challenge rather than hardcoding it. On
// Monad the token's EIP-712 name is "USDC", not "USD Coin", and a wrong
// domain does not error — every signature just verifies false.
const terms = challenge.accepts[0];
const signature = await account.signTypedData({
  domain: {
    name: terms.extra.name,
    version: terms.extra.version,
    chainId: Number(terms.network.split(":")[1]),
    verifyingContract: terms.asset,
  },
  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" },
    ],
  },
  primaryType: "TransferWithAuthorization",
  message: {
    from: authorization.from,
    to: authorization.to,
    value,
    validAfter: BigInt(authorization.validAfter),
    validBefore: BigInt(authorization.validBefore),
    nonce: authorization.nonce,
  },
});
 
// 3. Submit in the protocol header and wait for confirmation.
const paymentPayload = {
  x402Version: 2,
  accepted: terms,
  payload: { authorization, signature },
};
const submitted = await fetch(`${GATEWAY}/v1/x402/deposit`, {
  method: "POST",
  headers: {
    "PAYMENT-SIGNATURE": Buffer.from(JSON.stringify(paymentPayload)).toString("base64"),
  },
}).then((r) => r.json());
 
let payment;
do {
  await new Promise((r) => setTimeout(r, 2000));
  payment = await fetch(
    `${GATEWAY}/v1/x402/payment/${submitted.paymentId}`,
  ).then((r) => r.json());
} while (payment.state === "pending" || payment.state === "broadcast");
 
if (payment.state !== "confirmed") throw new Error(payment.error ?? payment.state);
 
// 4. That URL is a normal JSON-RPC endpoint. Keep it.
console.log(payment.rpcUrl, payment.balanceCu, payment.rpsLimit);

Python

import base64, json, os, time, requests
from eth_account import Account
from eth_account.messages import encode_typed_data
 
GATEWAY = "https://infra.originstake.com"
acct = Account.from_key(os.environ["PRIVATE_KEY"])
 
challenge = requests.post(f"{GATEWAY}/v1/x402/deposit").json()
 
now = int(time.time())
authorization = {
    "from": acct.address,
    "to": terms["payTo"],
    "value": str(1_000_000),          # 1 USDC, 6 decimals
    "validAfter": str(now - 60),
    "validBefore": str(now + 600),
    "nonce": challenge["suggestedNonce"],
}
 
terms = challenge["accepts"][0]
 
typed = {
    "types": {
        "EIP712Domain": [
            {"name": "name", "type": "string"},
            {"name": "version", "type": "string"},
            {"name": "chainId", "type": "uint256"},
            {"name": "verifyingContract", "type": "address"},
        ],
        "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"},
        ],
    },
    "primaryType": "TransferWithAuthorization",
    # Domain comes from the challenge. On Monad the EIP-712 name is
    # "USDC"; hardcoding "USD Coin" makes every signature verify false.
    "domain": {
        "name": terms["extra"]["name"],
        "version": terms["extra"]["version"],
        "chainId": int(terms["network"].split(":")[1]),
        "verifyingContract": terms["asset"],
    },
    "message": {
        "from": authorization["from"],
        "to": authorization["to"],
        "value": int(authorization["value"]),
        "validAfter": int(authorization["validAfter"]),
        "validBefore": int(authorization["validBefore"]),
        "nonce": bytes.fromhex(authorization["nonce"][2:]),
    },
}
 
signed = acct.sign_message(encode_typed_data(full_message=typed))
authorization["signature"] = signed.signature.hex()
 
payment_payload = {
    "x402Version": 2,
    "accepted": terms,
    "payload": {"authorization": authorization, "signature": authorization["signature"]},
}
submitted = requests.post(
    f"{GATEWAY}/v1/x402/deposit",
    headers={
        "PAYMENT-SIGNATURE": base64.b64encode(
            json.dumps(payment_payload).encode()
        ).decode()
    },
).json()
 
while True:
    payment = requests.get(
        f"{GATEWAY}/v1/x402/payment/{submitted['paymentId']}"
    ).json()
    if payment["state"] not in ("pending", "broadcast"):
        break
    time.sleep(2)
 
assert payment["state"] == "confirmed", payment
print(payment["rpcUrl"], payment["balanceCu"], payment["rpsLimit"])

Checking your balance

curl https://infra.originstake.com/v1/x402/account/0xYourWallet
{
  "wallet": "0x…",
  "state": "active",
  "balanceCu": "1840000",
  "estimatedCalls": 61333,
  "tier": "x402-t2",
  "rpsLimit": 100,
  "chains": ["monad"],
  "availableRpsForUpgrade": 150
}

Top up by running the deposit flow again with the same wallet. The URL does not change, the balance and tier just go up.

Attributing a wallet to one of your apps

If you run the bot yourself, you can have its traffic show up under one of your dashboard apps instead of only in your wallet's own usage. Send one of that app's API keys with the deposit:

curl -X POST https://infra.originstake.com/v1/x402/deposit \
  -H "PAYMENT-SIGNATURE: <base64 payload>" \
  -H "X-OriginRPC-Token: originrpc_live_<your app key>"

The app key already proves you own the app, so there is no second signature to make. Deposit again with a different app's key and the wallet moves with it.

Attribution only. The wallet still pays from its own prepaid balance, and none of it counts against the app owner's plan allowance.

Once attributed, the wallet shows up under x402 wallets in the dashboard: balance remaining, burn rate, which methods cost the most, and whether it is hitting its tier ceiling.

Attaching a wallet that already deposited

If the wallet has been paying already, you do not need to deposit again just to label it. Go to x402 wallets in the dashboard, paste the address and pick the app.

With a browser wallet (MetaMask, Rabby, and anything else that injects an EIP-1193 provider) press Sign message and approve — you will see the exact text before you do.

If the key lives in a script or on a hardware wallet, open Sign it yourself instead, copy the message, and sign it with anything that does personal_sign. From this repo:

X402_SMOKE_KEY=0x<wallet key> X402_APP_ID=<app uuid> \
  bun scripts/x402-sign-claim.ts

This is the one place a wallet signature is genuinely required: the deposit carried no app credential, so nothing else can prove the wallet is yours. The signature is valid for ten minutes and grants no spending power — it only attaches a label.

A wrong or expired key costs you the attribution, not the deposit — the account is simply created unattached.

Seeing where your balance went

curl "https://infra.originstake.com/v1/x402/account/0xYourWallet/usage?days=7"
{
  "wallet": "0x…",
  "balanceCu": "1840000",
  "spentUnits": "80000",
  "totals": {
    "calls": "8412",
    "cu": "160000",
    "errors": "3",
    "rateLimited": "0",
    "cacheHits": "2104"
  },
  "days": [{ "day": "2026-08-25", "cuUsed": "24000", "cuAdded": "0", "calls": "1203" }],
  "methods": [
    { "method": "eth_getLogs", "chain": "evm:143", "calls": "412", "cu": "30900", "errorRate": 0 }
  ]
}

methods is sorted by CU, heaviest first, so the top row is whatever is costing you the most. spentUnits is the same figure in USDC base units if you would rather not reason in compute units.

Two fields worth watching:

  • rateLimited climbing means you are hitting your tier's requests-per-second ceiling. Deposit more to move up a tier rather than retrying into the same wall.
  • cacheHits are requests we served without touching an upstream. They still cost compute units; they are just faster.

Every request costs at least 1 CU, even methods the weight table prices at zero. Otherwise a single minimum deposit would buy unlimited service.

Running out, and topping up by itself

A wallet that runs dry does not need anyone to notice. Requests on a drained key come back as 402 carrying fresh terms, so the same client that paid the first time pays again and carries on. With an x402-aware client that is the whole of it — no alerting, no cron, nobody woken up.

The top-up quote is what that wallet last paid, not the minimum. A bot running on 5 USDC top-ups is asked for 5 again, so it does not drain and re-negotiate every few minutes.

The 402 for a drained wallet carries an extra block a client can use to tell a top-up from a first purchase:

{
  "x402Version": 2,
  "accepts": [{ "maxAmountRequired": "5000000", "...": "..." }],
  "account": {
    "wallet": "0x…",
    "state": "drained",
    "balanceCu": "412",
    "reason": "balance exhausted — top up to restore your tier"
  }
}

Conforming clients ignore it and just pay the amount in accepts.

Underneath, the key is moved to a 1 rps budget rather than switched off, so a plain client that does not understand 402 still limps along instead of failing outright.

If you would rather top up before running dry, poll /v1/x402/account/<wallet> and deposit when balanceCu drops below whatever margin suits your workload.

An empty wallet that stops calling entirely is closed after 72 hours and its key revoked.

If your key leaks

Sign a rotation message with the same wallet:

curl -X POST https://infra.originstake.com/v1/x402/rotate \
  -H 'Content-Type: application/json' \
  -d '{"wallet":"0x…","issuedAt":1780000000,"signature":"0x…"}'

The message to sign is:

originstake x402 key rotation
wallet: <lowercase address>
issuedAt: <unix seconds>

issuedAt must be within 5 minutes of the server's clock. The old key stops working immediately.

Errors

CodeMeaning
disabledx402 is not enabled on this deployment.
not_configuredEnabled but the payment chain is not set up. Operator problem, not yours.
domain_mismatchThe authorization pays someone other than the address in the challenge.
signature_invalidSignature does not match from, or was signed for a different chain or token.
nonce_unknownReserved. Nonces are client-generated; replay is caught on-chain.
nonce_replayThe token contract has already consumed this authorization.
amount_too_smallBelow minDepositUnits. The response repeats the current minimum.
simulation_failedThe transfer would revert. Usually an under-funded wallet. Costs you nothing.
capacity_fullNo tier has room on the chains you asked for.
wallet_blockedThe operator blocked this address.

Gas

You never pay gas. The signed transfer is broadcast for you — normally by the network's x402 facilitator, which covers the gas itself, and by our own relayer if that facilitator is unreachable. Either way your wallet only ever spends the USDC in the authorization.

Limits

WebSocket endpoints are not available through x402 — connection-time billing is a different model. Use a normal API key for those.

On this page