Buy scans as an agent

Use a wallet to pay per operation in USDC on Base. No GeoCites account or OpenRouter key is required. Start with an unsigned quote; payment only begins when your wallet signs and you submit the authorization.

Using your own OpenRouter key? The BYOK API reference describes that separate API and its bearer access requirements. Workspace credit packs use the signed-in product, not this payment flow.

Operations and prices

These are the base catalogue prices. The live 402 challenge supplies the effective amount, token, network and recipient for your request. Check it against your spending limit before signing.

Base catalogue prices per operation, paid in USDC
OperationPOST endpointUSDC
Citation scan/api/a2a/scan$0.15
Page audit/api/a2a/audit$0.02
Geographic radius/api/a2a/radius$0.50
Scan, radius and audit/api/a2a/full$0.60
Streaming scan/api/a2a/watch$0.15

Scan and watch use the community model and two prompts. Full includes the citation scan and geographic radius; its page audit is best-effort and an unreachable page appears as auditError. A scan or radius failure prevents settlement. Model coverage, queue wait and provider response times affect duration; no completion time is guaranteed.

Get a quote without paying

Read the live AgentCard for skills and protocols. A POST to a paid endpoint without a payment header returns HTTP 402 and accepts[]. It does not execute the scan or charge your wallet.

This Node.js 22+ command downloads the example and asks the real GeoCites service for a quote. It requires no dependencies, private key or signature and submits no transaction.

curl -fLo a2a-client.mjs https://geo-cites.com/examples/a2a-client.mjs
node --input-type=module -e 'import("./a2a-client.mjs").then(async ({discoverScan}) => console.log(await discoverScan()))'

For scan and full, send domain and keyword, optionally location. Audit takes url. Radius also accepts rings, an array of ring indexes. Copy the request body you intend to buy; keep it unchanged for the signed retry.

Sign the quote and buy once

The integration uses viem's real account signer in a Node.js agent. Your wallet signs EIP-712 typed data for USDC's TransferWithAuthorization: sender, recipient, exact amount, validity window and random nonce. GeoCites passes that authorization to its facilitator, which settles on-chain after successful work. Signing permits a real transfer when submitted.

Install viem@2.54.2 in your application, download the module above, and provision a wallet through your runtime with enough USDC on Base to cover the quote. No key is bundled in the example. Run purchases in your agent or backend; this API does not provide a cross-origin browser integration. The client only supports the published Base mainnet USDC contract, rejects unknown signing domains and never retries a payment automatically.

// Node.js agent integration. Install viem@2.54.2 in your application.
import { privateKeyToAccount } from "viem/accounts";
import { buyScan, readReceipts } from "./a2a-client.mjs";

// Your runtime provisions this account. No key is included in this example.
// You can instead pass your existing signer with address + signTypedData.
const key = process.env.GEOCITES_AGENT_PRIVATE_KEY;
if (!key) throw new Error("A provisioned wallet is required to opt into a purchase");
const signer = privateKeyToAccount(key);

// Calling buyScan authorizes and submits a REAL USDC payment.
const purchase = await buyScan({
  input: { domain: "example.com", keyword: "crm software" },
  signer,
  maxUsdc: "0.15", // your explicit limit, not permission to accept any quote
});
console.log(purchase.scan.scanId, purchase.payment.transaction);

// Only if you need to recover purchase history later:
// const history = await readReceipts({ signer });
Read the complete executable client
/** GeoCites x402 v1 example. Importing this module does not send requests or sign anything.
 * discoverScan() only asks for a quote. buyScan() authorizes a real USDC payment.
 * Supply your existing wallet signer; no private key is read, stored or printed here.
 */
const ORIGIN = "https://geo-cites.com";
const BASE_USDC = "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913";
const 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" },
] };

function post(path, input, fetchImpl, payment) {
  return fetchImpl(`${ORIGIN}${path}`, {
    method: "POST", redirect: "error",
    headers: { "Content-Type": "application/json", ...(payment ? { "X-Payment": payment } : {}) },
    body: JSON.stringify(input),
  });
}
async function challenge(path, input, fetchImpl) {
  const response = await post(path, input, fetchImpl);
  if (response.status !== 402) throw new Error(`Expected a 402 quote; received HTTP ${response.status}`);
  const body = await response.json();
  const requirement = body.accepts?.find((item) => item.scheme === "exact" && item.network === "base");
  if (!requirement) throw new Error("No supported Base exact-payment quote");
  return requirement;
}
export function discoverScan(input = { domain: "example.com", keyword: "crm software" }, fetchImpl = fetch) {
  return challenge("/api/a2a/scan", input, fetchImpl);
}
function atomicUsdc(amount) {
  if (typeof amount !== "string" || !/^\d+(\.\d{1,6})?$/.test(amount)) throw new Error("maxUsdc must be a decimal string with at most six decimal places");
  const [whole, fraction = ""] = amount.split(".");
  return BigInt(whole) * 1_000_000n + BigInt(fraction.padEnd(6, "0"));
}
async function authorize(requirement, signer, maxAtomic) {
  if (requirement.scheme !== "exact" || requirement.network !== "base" || requirement.asset?.toLowerCase() !== BASE_USDC
    || requirement.extra?.name !== "USD Coin" || requirement.extra?.version !== "2") {
    throw new Error("Unsupported token or signing domain; review the live AgentCard and challenge");
  }
  if (!/^0x[0-9a-fA-F]{40}$/.test(requirement.payTo) || /^0x0{40}$/i.test(requirement.payTo)) throw new Error("Invalid payment recipient");
  if (!/^\d+$/.test(requirement.maxAmountRequired)) throw new Error("Invalid quote amount");
  const amount = BigInt(requirement.maxAmountRequired);
  if (amount < 1n || amount > maxAtomic) throw new Error("Quote exceeds your explicit spending limit");
  const timeout = requirement.maxTimeoutSeconds;
  if (!Number.isSafeInteger(timeout) || timeout < 10 || timeout > 300) throw new Error("Unsupported authorization window");
  const now = Math.floor(Date.now() / 1000);
  const nonce = `0x${Array.from(crypto.getRandomValues(new Uint8Array(32)), (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
  const authorization = { from: signer.address, to: requirement.payTo, value: requirement.maxAmountRequired,
    validAfter: String(now - 5), validBefore: String(now - 5 + timeout), nonce };
  const signature = await signer.signTypedData({
    domain: { name: requirement.extra.name, version: requirement.extra.version, chainId: 8453, verifyingContract: requirement.asset },
    types, primaryType: "TransferWithAuthorization",
    message: { ...authorization, value: amount, validAfter: BigInt(authorization.validAfter), validBefore: BigInt(authorization.validBefore) },
  });
  return { authorization, header: btoa(JSON.stringify({ x402Version: 1, scheme: "exact", network: "base", payload: { signature, authorization } })) };
}

/** This call signs and submits ONE paid scan. It never automatically retries a payment. */
export async function buyScan({ input, signer, maxUsdc, fetchImpl = fetch }) {
  const requirement = await discoverScan(input, fetchImpl);
  const signed = await authorize(requirement, signer, atomicUsdc(maxUsdc));
  const recovery = { payer: signer.address, nonce: signed.authorization.nonce, operation: "scan" };
  try {
    const response = await post("/api/a2a/scan", input, fetchImpl, signed.header);
    const scan = await response.json();
    recovery.scanId = scan.scanId ?? scan.id;
    const encoded = response.headers.get("X-Payment-Response") ?? response.headers.get("PAYMENT-RESPONSE");
    const payment = encoded ? JSON.parse(atob(encoded)) : null;
    if (!response.ok || payment?.success !== true || !payment.transaction) {
      throw new Error(`Payment/result not confirmed (HTTP ${response.status}); check your receipts before buying again`);
    }
    return { scan, payment, recovery };
  } catch (cause) {
    const error = new Error("Purchase outcome is uncertain. Keep recovery details and check receipts; do not automatically buy again", { cause });
    error.recovery = recovery;
    throw error;
  }
}

/** A fresh 1-micro-USDC proof, verified by GeoCites without settlement. A purchase signature is not a receipt-lookup proof. */
export async function readReceipts({ signer, fetchImpl = fetch }) {
  const path = "/api/a2a/my-receipts";
  const requirement = await challenge(path, {}, fetchImpl);
  const signed = await authorize(requirement, signer, 1n);
  const response = await post(path, {}, fetchImpl, signed.header);
  if (!response.ok) throw new Error(`Receipt lookup failed (HTTP ${response.status}); no purchase was retried`);
  return response.json();
}
Download a2a-client.mjs

Read the result and receipt

A successful scan response contains scanId and the report. Decode the base64 JSON in X-Payment-Response or PAYMENT-RESPONSE to read success, transaction and network. No second signature is needed to read that receipt. Keep the scan ID and transaction hash with your request.

For streaming, scan_complete contains the report but does not confirm payment. Wait for the terminal payment_settled or payment_failed event.

Recover an uncertain purchase

If the connection drops, settlement fails, or the receipt is missing, do not automatically sign for another scan. Preserve the nonce, payer, scan ID if available, and transaction hash. An absent receipt is not proof that no payment occurred; settlement or receipt recovery may still be pending.

Use readReceipts({ signer }) to query POST /api/a2a/my-receipts. It requests a fresh authorization for exactly 0.000001 USDC to prove wallet ownership. GeoCites verifies this proof without settling it. The original purchase signature has a different amount and cannot replace this lookup proof.

For private retrieval, send the same valid verify-only proof to POST /api/a2a/my-scans/:id, /my-radius/:id or /my-audits/:id; the server checks that this wallet owns the result. Public GET /api/a2a/scan/:id, /radius/:id and /audit/:id links are readable by anyone who has the URL when the result is shareable.

HTTP 429 means wait for Retry-After. HTTP 503 can indicate maintenance, rate storage or provider unavailability. A failed signed request may already have consumed its nonce: resolve its outcome first. If uncertainty remains, send the identifiers above to contact@geo-cites.com.

REST, streaming and native A2A

This client uses REST and x402 v1: a base64 payment payload in X-Payment. The service also accepts x402 v2 PAYMENT-SIGNATURE and emits both receipt headers. Read the live challenge for the expected envelope; changing a header name alone does not construct a v2 payload.

Use POST /api/a2a/watch for server-sent scan events. Native A2A clients discover POST /api/a2a/jsonrpc through the AgentCard and can call message/send, message/stream, tasks/get and tasks/cancel. Missing payment is represented as a task requiring input. Paid task reads and cancellation require proof from the owning wallet; cancellation does not promise a refund of a settled purchase.