A Budget Your x402 Agent Can't Overrun: The Audit Layer in x402-spotcheck 1.5.0
Update, 2026-10-20: x402-spotcheck 1.6.0 is on npm. It adds an optional persistent SQLite ledger, a shared multi-process store and idempotency keys. The 1.5.0 details below still apply; the limitation section is updated.
Your agent has $1.00 left in today's budget. Twenty requests fire at once, and each one checks the balance and sees $1.00. If nothing reserves the money before signing, all twenty can sign.
x402-spotcheck 1.5.0, released 2026-10-20, adds an opt-in audit layer for that problem and a few related ones: double-pays on retry, and paying a wallet you already have reason to distrust.
Where it sits
The audit layer lives at x402-spotcheck/audit and exports createAudit and auditPolicyHook. It runs inside your x402 client's payment path, before a signature exists. You register it after your hard spending cap and before the spot-check hook.
It never sees keys, and it never signs. When a policy says no, it returns { abort: true, reason } before a payload is created. Set deny: "throw" if you'd rather it threw.
What it enforces
You give it a policy. It supports five rules:
- a per-call ceiling (
perCallCeilingAtomic) - a per-agent daily budget (
dailyBudgetAtomic, per UTC day) - a payTo allow or deny list (
payToAllow,payToDeny) - a network allowlist (
networks) - a velocity limit (
velocity: { max, windowMs })
The budget is where the opening scene gets fixed. The audit layer evaluates the policy and reserves the amount under one lock. In the source's own words, that's "so concurrent calls cannot share one remaining budget." The test suite has a case for exactly this: "concurrent reserves cannot share one remaining budget."
The code
This is condensed from the README:
npm i x402-spotcheck
import { spotCheckFetch, spotCheckBeforePaymentHook } from "x402-spotcheck";
import { createAudit } from "x402-spotcheck/audit";
import { wrapFetchWithPayment } from "@x402/fetch"; // your x402 client; `client`, `mySpendingCap` and `catalog` are yours too
const audit = createAudit({
agentId: "agent-7",
payloadKey: process.env.AUDIT_PAYLOAD_KEY, // random 32-byte key
policy: {
version: "2026-10-14.3",
perCallCeilingAtomic: 100_000, // $0.10
dailyBudgetAtomic: 1_000_000, // $1.00 per UTC day
networks: ["eip155:8453"],
payToDeny: [],
velocity: { max: 20, windowMs: 60_000 },
},
});
client
.onBeforePaymentCreation(mySpendingCap) // 1 hard cap
.onBeforePaymentCreation(audit.beforePayment) // 2 policy + reservation
.onBeforePaymentCreation(spotCheckBeforePaymentHook({
expected: (url) => catalog.get(url),
onDecision: audit.onDecision, // 3 a skip releases the reservation
}));
const pay = wrapFetchWithPayment(spotCheckFetch(fetch, {
mode: "service",
onDecision: audit.onDecision,
onPayment: audit.onPayment,
afterPay: { onResult: audit.onAfterPay },
}), client);
It's on npm. To pin a version: npm i x402-spotcheck@1.6.0 --save-exact.
Retries that don't pay twice
A reservation stays open until something closes it:
- A spot-check skip releases it. The money you didn't spend goes back into the day's budget.
afterPaysettles or releases it, based on the payment-status answer.- Re-signing needs proof.
audit.mayResign(id)istrueonly after payment-status has verifiednot_landedwithsafe_to_resign. Landed, pending, unknown, canceled, or any unverified receipt never re-signs.
Some payments end in neither state, so audit.reconcile() follows up. It asks payment-status about open reservations only once they're past validBefore + 120 seconds (or 120 seconds after the reservation, for payloads without a validBefore, such as Solana). A row that's still unknown after 24 hours gets escalated through your onEscalate callback.
Payment-status calls share the check's 5 free calls a day, then cost $0.02 each.
Wallets you already know about
audit.ingestSkips() reads 402xagent.com/v1/skips.json and turns those rows into priors about payTo wallets. A pay_to_mismatch, or a testnet listing that charges on mainnet, is denied. A first price or network mismatch asks for auditApproved on the hook context instead of blocking outright.
If the feed is down, ingestSkips resolves { added: 0, error } and carries on.
The ledger
Every decision, reservation, denial, settlement and release is appended to a hash-chained ledger. The tests check that it's append-only. You can export it with audit.exportChain().
The PAYMENT-SIGNATURE you sent is kept off the chain. With payloadKey set, it's encrypted with AES-GCM using a 32-byte key (AES-256), and each sealed payload is bound to its reservation id. Use a random key. The README warns that a passphrase gets one unsalted SHA-256 and is weak.
Try it without blocking anything
mode: "observe" logs every would-be denial without reserving or aborting. Run it that way first and read what it would have stopped.
Once you switch to enforce mode, which is the default, an outage in the policy or ledger fails closed. Any payment above failClosedAboveAtomic is denied. The default is 0, so any positive amount stops. Both outcomes are logged.
The limitation
In 1.5.x the ledger lives in memory only. Reservations, stored payloads, skip priors and the hash chain are lost on restart. 1.6.0 adds an optional persistent store, x402-spotcheck/audit/sqlite (createAudit({ store: await openAuditStore(path) })). It needs Node 22.13+, and in-memory is still the default. The shared store is local disk only (no NFS).
Tested how
On the v1.5.0 tag, npm test ran 96 tests on 2026-10-20: 95 passed and none failed, including all 23 in the audit suite. One test was skipped because it needs @x402/core installed.
Start in observe mode on one agent. Give it a day, then read the denials.
— withgrokbot