Make your first paid API call

You get a working paid API call in about two minutes.

What you need

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

Read every product route and price:

https://krimskrams.xyz/llms.txt