x402 quickstart
Two sides to build: the seller (charge for an endpoint) and the buyer (pay for one). Both talk to the same facilitator, and neither needs a Kryard API key — the x402 routes are unauthenticated.
Base URL
https://api.kryard.com · network base-sepolia · asset: testnet USDC 0x036CbD53842c5426634e7929541eC2318f3dCF7e
Check what's live before you start:
curl -s https://api.kryard.com/x402/v1/supported{
"kinds": [{ "x402Version": 1, "scheme": "exact", "network": "base-sepolia" }],
"signers": { "eip155:*": ["0x433ff8253Faa9A57b6326f69Abc8Ad0bf208f5c1"] }
}signers is the relayer address that will front gas for your settlements.
Seller — charge for an endpoint
1. Answer unpaid requests with 402
Describe your price in an accepts entry. extra carries the token's EIP-712 domain, which the buyer needs to sign.
const PRICE = {
scheme: "exact",
network: "base-sepolia",
maxAmountRequired: "10000", // 0.01 USDC — USDC has 6 decimals
asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
payTo: "0xYourMerchantAddress",
resource: "https://your-api.example/report",
maxTimeoutSeconds: 120,
extra: { name: "USDC", version: "2" },
};
app.get("/report", async (c) => {
const header = c.req.header("x-payment");
if (!header) {
return c.json({ x402Version: 1, accepts: [PRICE] }, 402);
}
// …continue to step 2
});2. Verify, serve, then settle
const FACILITATOR = "https://api.kryard.com/x402/v1";
const paymentPayload = JSON.parse(atob(header));
const verify = await fetch(`${FACILITATOR}/verify`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ paymentPayload, paymentRequirements: PRICE }),
}).then((r) => r.json());
if (!verify.isValid) {
return c.json({ x402Version: 1, accepts: [PRICE], error: verify.invalidReason }, 402);
}
// Do the work FIRST — you don't want to charge for a response you can't deliver.
const report = await buildReport();
const settled = await fetch(`${FACILITATOR}/settle`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ paymentPayload, paymentRequirements: PRICE }),
}).then((r) => r.json());
if (!settled.success) {
return c.json({ x402Version: 1, accepts: [PRICE], error: settled.errorReason }, 402);
}
c.header("X-PAYMENT-RESPONSE", btoa(JSON.stringify(settled)));
return c.json(report);Settling twice is safe
The facilitator keys its ledger on (chain, asset, payer, nonce) — EIP-3009's own identity for an authorization. Replaying the same /settle returns the original transaction hash instead of broadcasting a second transfer. Retry freely on a network error.
Buyer — pay for an endpoint
You need testnet USDC on Base Sepolia. You do not need ETH.
1. Read the price
const res = await fetch("https://your-api.example/report");
if (res.status !== 402) return res;
const { accepts } = await res.json();
const req = accepts[0];2. Sign an EIP-3009 authorization
The nonce is a random 32-byte value you choose — it's scoped on-chain to (token, you), so it only has to be unique for your own address.
import { createWalletClient, custom, toHex } from "viem";
import { baseSepolia } from "viem/chains";
const wallet = createWalletClient({ chain: baseSepolia, transport: custom(window.ethereum) });
const [from] = await wallet.getAddresses();
const now = Math.floor(Date.now() / 1000);
const authorization = {
from,
to: req.payTo,
value: req.maxAmountRequired,
validAfter: String(now - 60), // clock-skew slack
validBefore: String(now + req.maxTimeoutSeconds),
nonce: toHex(crypto.getRandomValues(new Uint8Array(32))),
};
const signature = await wallet.signTypedData({
account: from,
domain: {
name: req.extra.name,
version: req.extra.version,
chainId: 84532,
verifyingContract: req.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: {
...authorization,
value: BigInt(authorization.value),
validAfter: BigInt(authorization.validAfter),
validBefore: BigInt(authorization.validBefore),
},
});Signing is free and off-chain. Nothing has moved yet.
3. Retry with X-PAYMENT
const paymentPayload = {
x402Version: 1,
scheme: "exact",
network: "base-sepolia",
payload: { signature, authorization },
};
const paid = await fetch("https://your-api.example/report", {
headers: { "X-PAYMENT": btoa(JSON.stringify(paymentPayload)) },
});If the seller settled it, X-PAYMENT-RESPONSE carries the transaction hash.
Testing without a seller
Point /verify at the facilitator directly to check a payload before wiring up an endpoint. It's read-only — it never broadcasts:
curl -s -X POST https://api.kryard.com/x402/v1/verify \
-H 'content-type: application/json' \
-d '{"paymentPayload":{…},"paymentRequirements":{…}}'{ "isValid": false, "invalidReason": "invalid_exact_evm_insufficient_balance", "payer": "0x…" }Every failure names its cause — see reason codes.
Getting testnet funds
Base Sepolia ETH from a Base faucet, then swap or use Circle's testnet USDC faucet. You only need USDC to pay; the facilitator covers gas.
Common mistakes
| Symptom | Cause |
|---|---|
invalid_exact_evm_recipient_mismatch | The signed to isn't the payTo you sent in paymentRequirements. They must match exactly. |
invalid_exact_evm_authorization_expired | validBefore already passed — often clock skew. Sign validAfter a minute in the past. |
invalid_exact_evm_signature | Wrong EIP-712 domain. name/version must come from extra, chainId must be 84532, verifyingContract the token. |
x402_amount_below_minimum | Under 0.01 USDC. Gas costs more than the payment below that floor. |
x402_asset_not_allowed | Only testnet USDC is settled today. |