Make your first paid API call
You get a working paid API call in about two minutes.
What you need
- For x402, use a wallet with about 0.05 USDC on Base.
- For MPP, use a wallet with about 0.05 USDC.e on Tempo.
- Keep each private key in an environment variable. Do not put a key in a file that you commit.
Lesson A: x402 on Base
1. Read the free discovery document
Run this command:
curl -fsS 'https://grant-search.krimskrams.xyz/.well-known/x402?src=learn'
Real output, shortened. Run time: 2026-08-20 22:50:32 UTC.
{"x402Version":2,"accepts":[{"scheme":"exact","network":"eip155:8453","amount":"20000"}]}
The free request returned HTTP 200. The src=learn parameter works.
2. Read the 402 challenge
Run this command:
curl -i -sS 'https://grant-search.krimskrams.xyz/v1/search?q=education&src=learn'
Real output, shortened. Run time: 2026-08-20 22:45:18 UTC.
HTTP/2 402
payment-required: eyJ4NDAyVmVyc2lvbiI6Mi...
{"x402Version":2,"error":"Payment required","accepts":[{"scheme":"exact","network":"eip155:8453","amount":"20000","maxAmountRequired":"20000"}]}
Read amount as atomic USDC units. 20000 is 0.02 USDC because USDC has 6 decimal places.
Make sure that network is eip155:8453. This value identifies Base mainnet for a version-2 client.
This 402 body is hybrid. It keeps version-1 field names, but the route serves network: "eip155:8453".
A version-1 client needs network: "base". One key has two incompatible values, so use a version-2 client here.
Install trap: x402-fetch@1.2.0 requests x402@^1.2.1, which is not on the registry.
If your package includes that legacy client, add "overrides": {"x402": "1.2.0"}.
3. Make the payment call
Save this package.json:
{
"name": "learn-x402",
"private": true,
"type": "module",
"dependencies": {
"@x402/evm": "2.23.0",
"@x402/fetch": "2.23.0",
"viem": "2.55.19",
"x402-fetch": "1.2.0"
},
"overrides": {"x402": "1.2.0"}
}
Save this as pay.mjs:
import { ExactEvmScheme } from "@x402/evm";
import { decodePaymentResponseHeader, wrapFetchWithPaymentFromConfig } from "@x402/fetch";
import { privateKeyToAccount } from "viem/accounts";
const url = "https://grant-search.krimskrams.xyz/v1/search?q=education&src=learn";
const key = process.env.X402_PRIVATE_KEY;
if (!key) throw new Error("Set X402_PRIVATE_KEY.");
const account = privateKeyToAccount(key.startsWith("0x") ? key : `0x${key}`);
const select = (_version, accepts) => {
const item = accepts.find((entry) =>
entry.scheme === "exact" &&
entry.network === "eip155:8453" &&
entry.asset.toLowerCase() === "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913" &&
entry.payTo.toLowerCase() === "0x276efa09388fb1578a6c415b8f90d26fdfce0cf2" &&
entry.amount === "20000"
);
if (!item) throw new Error("The route did not offer the approved $0.02 payment.");
return item;
};
const paidFetch = wrapFetchWithPaymentFromConfig(fetch, {
schemes: [{network: "eip155:8453", client: new ExactEvmScheme(account)}],
paymentRequirementsSelector: select,
});
const response = await paidFetch(url);
const data = await response.json();
const header = response.headers.get("payment-response");
const settlement = header ? decodePaymentResponseHeader(header) : null;
console.log(JSON.stringify({
status: response.status,
result_count: data.result_count,
first_title: data.results?.[0]?.title,
transaction: settlement?.transaction,
}, null, 2));
Set X402_PRIVATE_KEY to the private key for your funded Base wallet. Then run:
npm install
X402_PRIVATE_KEY="$X402_PRIVATE_KEY" node pay.mjs
4. Read the answer
Real output, shortened. Run time: 2026-08-20 22:46:49 UTC.
{
"status": 200,
"result_count": 10,
"first_title": "BJA FY 2025/2026 Project Safe Neighborhoods Formula Grant Program",
"transaction": "0x0c3c17f4aadfe8118e6a9752eb3c52c3d0ee1c6583923850f19165f84b556ea8"
}
The paid request returned HTTP 200. The Base wallet balance decreased from 0.970000 USDC to 0.950000 USDC.
The route accepts q, but it does not use it as a search filter. Use query when you need a keyword filter.
Lesson B: MPP on Tempo
1. Read the free discovery document
Run this command:
curl -fsS 'https://grant-search.krimskrams.xyz/.well-known/mpp.json?src=learn'
Real output, shortened. Run time: 2026-08-20 22:50:32 UTC.
{"spec_version":"0.1","payment_methods":[{"method":"tempo","chain_id":4217}],"routes":[{"path":"/mpp/v1/search","price_usd_per_call":"0.02"}]}
The free request returned HTTP 200. The src=learn parameter works.
2. Read the 402 challenge
Run this command:
curl -i -sS 'https://grant-search.krimskrams.xyz/mpp/v1/search?q=education&src=learn'
Real output, shortened. Run time: 2026-08-20 22:45:18 UTC.
HTTP/2 402
www-authenticate: Payment id="...", realm="grant-search.krimskrams.xyz", method="tempo", intent="charge", request="..."
Decode the base64url request value. Make sure that amount is 20000 and methodDetails.chainId is 4217.
Make sure that currency is 0x20C000000000000000000000b9537d11c60E8b50. This contract is USDC.e on Tempo.
3. Make the payment call
The proven client is pympp==0.10.1. The payment code pins the amount, currency, receiver, and Tempo chain.
Create an environment and install the tested client:
python3 -m venv .venv
.venv/bin/pip install 'pympp[tempo]==0.10.1'
Save this as pay_mpp.py. It copies the payment path from the tested source.
import asyncio
import json
import os
from mpp import Challenge, Credential, Receipt
from mpp.client import Client
from mpp.methods.tempo import ChargeIntent, TempoAccount, tempo
URL = "https://grant-search.krimskrams.xyz/mpp/v1/search?q=education&src=learn"
CHAIN_ID = 4217
CURRENCY = "0x20C000000000000000000000b9537d11c60E8b50"
RECEIVER = "0x01682fbff2a4dfac6a7a341f8f8714aa6fff9675"
MAX_AMOUNT = 20_000
class CappedTempoMethod:
name = "tempo"
def __init__(self, delegate):
self.delegate = delegate
async def create_credential(self, challenge: Challenge) -> Credential:
request = challenge.request
details = request.get("methodDetails", {})
if int(request.get("amount", 0)) > MAX_AMOUNT:
raise RuntimeError("The challenge amount is too large.")
if request.get("currency", "").lower() != CURRENCY.lower():
raise RuntimeError("The challenge currency is not USDC.e.")
if request.get("recipient", "").lower() != RECEIVER.lower():
raise RuntimeError("The challenge receiver is not approved.")
if int(details.get("chainId", 0)) != CHAIN_ID:
raise RuntimeError("The challenge chain is not Tempo.")
return await self.delegate.create_credential(challenge)
async def main():
key = os.environ.get("TEMPO_PRIVATE_KEY", "")
if not key:
raise RuntimeError("Set TEMPO_PRIVATE_KEY.")
account = TempoAccount.from_key(key if key.startswith("0x") else f"0x{key}")
delegate = tempo(
intents={"charge": ChargeIntent()},
account=account,
chain_id=CHAIN_ID,
rpc_url="https://rpc.tempo.xyz",
currency=CURRENCY,
)
async with Client(methods=[CappedTempoMethod(delegate)]) as client:
response = await client.get(URL, timeout=60)
response.raise_for_status()
data = response.json()
receipt = Receipt.from_payment_receipt(response.headers["payment-receipt"])
print(json.dumps({
"status": response.status_code,
"result_count": data["result_count"],
"first_title": data["results"][0]["title"],
"transaction": receipt.reference,
}, indent=2))
asyncio.run(main())
Set TEMPO_PRIVATE_KEY to the private key for your funded Tempo wallet. Then run:
.venv/bin/python pay_mpp.py
4. Read the answer
No MPP payment ran for this page. The test payer has 0.009727 USDC.e, which is less than the 0.02 USDC.e price.
This is the recorded real result from 2026-08-20 22:06 UTC:
MPP paid call: HTTP 200 with one opportunity
MPP receipt header: present
Tempo USDC.e balance: 0.029748 -> 0.009727
The JSON answer has result_count and results. Each result has the title, deadline, award values, eligibility, and official Grants.gov link.
It worked. Now what
- OSHA Search:
GET /v1/searchorGET /mpp/v1/search, $0.02. - Agent API Listings:
POST /v1/packagesorPOST /mpp/v1/packages, $0.20. - Buyer-Readiness Linter:
POST /v1/lintorPOST /mpp/v1/lint, $0.10. - Friction Logs:
GET /v1/log/{slug}orGET /mpp/v1/log/{slug}, $1.00. - Payment Reconciliation:
GET /v1/reportorGET /mpp/v1/report, $0.10. - Listing Drift Watchdog:
GET /v1/driftorGET /mpp/v1/drift, $0.10.
Read every product route and price: