On this page
Run it against the demo sellerRules that keep it workingWhat the SDK sendsThe guard objectOptionsModesErrors in codeSee a reviewSend other text for checkingReport the settlementSolanaLimitsNext stepsLanes / SDK
Add the guard to a TypeScript agent
The SDK hooks the guard into the x402 client your agent already pays with. It asks the guard before each payment is signed, so a denied payment is never signed.
It checks the payments made through that x402 client. Another client, another process, or a signature made by hand is not checked.
- Lane
- Enforced on routed payments
- You need
- An API key, an x402 client, and a wallet with test USDC
- Takes
- About 10 minutes
bun add @vulsight/guard @x402/fetch @x402/evm viemThe SDK runs on Node 20 or later, or Bun. It takes any 2.x release of @x402/core from 2.24 on, and the install line adds one copy of it.
Run it against the demo seller
These steps pay the demo seller 0.05 test USDC on Base Sepolia, then show a denied payment. Run them in bash or zsh, in one shell.
Export your API key as
VULSIGHT_API_KEY. If you have none, get an API key first.export VULSIGHT_API_KEY="<your key>"You see: no output. Keep this shell open.
Make a throwaway wallet. Never use one that holds real funds.
export EVM_PRIVATE_KEY="$( node --input-type=module -e ' import { generatePrivateKey } from "viem/accounts"; console.log(generatePrivateKey()); ' )" node --input-type=module -e ' import { privateKeyToAccount } from "viem/accounts"; const key = process.env.EVM_PRIVATE_KEY; console.log(privateKeyToAccount(key).address); 'You see: the wallet's address, 0x and 40 hex digits. The key is exported and never printed.
Paste the address into the Circle faucet and request USDC on Base Sepolia.
You see: test USDC at that address a few moments later.
Save this program as agent.ts.
import { guard } from "@vulsight/guard"; import { ExactEvmScheme } from "@x402/evm/exact/client"; import { wrapFetchWithPayment, x402Client } from "@x402/fetch"; import { privateKeyToAccount } from "viem/accounts"; const need = (name: string) => { const value = process.env[name]; if (!value) throw new Error(`Set ${name} before starting the agent.`); return value; }; const key = need("EVM_PRIVATE_KEY") as `0x${string}`; const account = privateKeyToAccount(key); const client = new x402Client().register( "eip155:*", new ExactEvmScheme(account), ); const vulsight = guard(client, { apiKey: need("VULSIGHT_API_KEY"), baseUrl: "https://vulsight-guard.vercel.app", }); // files the pages the agent reads const read = vulsight.fetch(fetch); // checks every payment before it is signed const pay = wrapFetchWithPayment(read, client); const url = "https://vulsight-guard.vercel.app/merchant/dataset"; await read(url, { headers: { accept: "text/html" } }); // 0.05 test USDC on Base Sepolia const paid = await pay(url); console.log(paid.status, await vulsight.reportSettlement(paid));Run
bun agent.ts, ornode agent.tson Node 22.18 or newer with"type": "module"in yourpackage.json.200 { decisionId: "91373fee…3907", network: "eip155:84532", txHash: "0xa216…64be", payer: "0x6C5F…0fa4", payee: "0x1111…1111", amountAtomic: "50000", asset: "0x036C…CF7e", direction: "sent", }You see: 200 and the settlement the SDK reported. The payment is on Decisions.
In agent.ts, replace the lines after
const paywith these, then run it again. They pay a payee off your allowlist.// x402's own one dollar cap would refuse 2.50 before the guard sees it. client.setSpendControls({ maxAmountPerPayment: false }); try { // The priority product costs 2.50 USDC. await pay("https://vulsight-guard.vercel.app/merchant/priority"); } catch (error) { console.log(String(error)); }Error: Failed to create payment payload: Payment creation aborted: The amount 2.50 USDC is over the per-transaction limit of 0.10 USDC. This payment of 2.50 USDC would bring today's total across all networks to 2.55 USDC, over the daily cap of 1.00 USDC. 0x894d…4807 is not on your allowlist. Auto-allow is off in your policy.You see: why the guard denied it, the rules that denied it first. Nothing is signed. The daily total counts every payment today, so yours may differ.
Rules that keep it working
- Wrap in this order,
wrapFetchWithPayment(vulsight.fetch(fetch), client). The guard's fetch sits inside, so it sees the paid request and never lets it follow a redirect. - Give each x402 client one guard. A second
guard()on the same client throws. - Call
guard()before you register anonPaymentResponsehook of your own. x402 stops at the first response hook that asks for a retry, and the SDK needs to see the receipt. - Keep x402's one dollar cap per payment, or lift it with
client.setSpendControls({ maxAmountPerPayment: false }). The cap refuses a larger payment before the guard sees it, so it never reaches Decisions. - Point the SDK at the seller's own URL, never at a proxy URL. The SDK and the proxy URL are alternatives, not layers.
- Read streams and long polls with the plain fetch. A body still arriving 5 s after its headers blocks the next payment to that site until you read it again.
- Leave the payment terms and the resource as they are once x402 picks them. The SDK blocks a payment whose terms changed after the check.
What the SDK sends
Before a payment is signed, the hook sends the guard the seller's terms. They are the payee, amount, asset, network and scheme, plus the transfer method and payment flow when the 402 declares them. It adds the URL the agent asked for and the session id.
It also cites the last page the agent read from that seller, as its SHA-256 hash and an excerpt of up to 2,000 characters. The page itself reached the guard when the agent read it.
Each payment attempt carries its own Idempotency-Key. When the SDK retries the call once after an outage, the guard decides it once. A payment you run again is a new attempt and a new decision.
Your wallet key never leaves your process. The API key is the only secret the SDK sends.
The guard object
guard(client, options) registers the SDK's hooks on the x402 client and returns this object. file and reportSettlement return promises.
| Member | Returns | What it does |
|---|---|---|
fetch(fetch) | The fetch you passed, wrapped | Wraps the fetch the agent reads with. It sends each page and each seller's 402 for checking, then returns the seller's response. |
file(text, origin?) | pass, restrict or unavailable | Sends text the agent read another way for checking. It never throws. |
reportSettlement(response) | SettlementReport | Reports the seller's receipt on the paid response and returns the report it posted. An optional second argument takes an AbortSignal. |
api | GuardClient | A typed client for the HTTP API, with decide, decision, policy and recentDecisions. |
session | string | The session id every check from this guard carries. |
reportSettlement returns the report it posted, with the fields the run above prints. The stored settlement on the HTTP API also has an id and the time it was reported.
vulsight.api.decide asks for a decision yourself, such as for a contract call before you sign it. That call is advisory, so your code acts on the answer.
Options
guard(client, options) takes these options.
| Option | Default | What it does |
|---|---|---|
apiKey | Required | Your API key. guard() throws when it is missing or blank. |
baseUrl | Required | Where the guard answers, https://vulsight-guard.vercel.app. The SDK follows no redirect, so a URL that redirects blocks the payment and names the address to set. |
session | One id per process | Groups one run's pages and payments. It must be 1 to 200 characters with no NUL character, or guard() throws. |
waitForReviewSeconds | 120 | How long a held payment waits for a person, 0 or more seconds. 0 never waits. The wait also ends when the review expires, so raise the Review timeout on Policy with it. |
mode | "enforce" | What happens when the guard does not answer in time. See Modes. |
fetch | The global fetch | The fetch the SDK uses for its own calls to the guard. |
Modes
mode changes one case only, a guard that did not answer in time. Everything else blocks in both modes.
| What happened | enforce | observe |
|---|---|---|
| The guard allowed it, or a reviewer approved it | Pays | Pays |
| The guard denied it, or it was still held when the wait ended | Blocks | Blocks |
| The guard did not answer in time: unreachable, a 408, a 5xx, or past its time budget | Blocks | Pays and logs a warning |
| The guard refused the call: a wrong API key, a rate limit, a page it refused | Blocks | Blocks |
| The SDK could not check it: an unreadable 402, an unsupported payment, a failed page read | Blocks | Blocks |
A 503 payment_screening_unavailable is an answer. The guard judged the payment and holds it, so it blocks in both modes.
observe pays without a check when the guard does not answer, on a mainnet too. Keep enforce for real money.
The agent's mode on the dashboard is separate. When the agent is in observe mode and enforce would have denied or held a payment, the payment goes ahead. The SDK logs one console.warn with that decision and its reasons.
Four denials still block in both modes. Two are a network or an asset the policy does not allow. The others are a payee the seller's 402 did not name and a payment the guard cannot judge. Reason codes shows which codes block in observe mode.
Errors in code
A blocked payment and an error from the guard reach your code in different ways.
- When the SDK blocks a payment,
wrapFetchWithPaymentthrows a plainErrorwith no code. Its message starts withFailed to create payment payload:and gives the reason. vulsight.apicalls andreportSettlementthrow aGuardApiErrorwhen the guard answers with an error. It carriesstatus,codeandmessage, andoutageis true on a 408, a 429 or a 5xx. Every code is on Errors.reportSettlementthrows a plainErrorwhen the response holds no receipt it can report. The message says why and what to check.guard()throws at startup on a missing API key, a bad session, or a second guard on one client.
import { GuardApiError } from "@vulsight/guard";
let paid: Response;
try {
paid = await pay(url);
} catch (error) {
// "Failed to create payment payload:" means the SDK blocked it and
// sent no payment, unless an onPaymentRequired hook on your client
// already sent one of its own. Any other error can come after a
// signed request left, so check Decisions before you pay again.
console.log(String(error));
process.exit(1);
}
try {
console.log(paid.status, await vulsight.reportSettlement(paid));
} catch (error) {
// An outage keeps the payment on record, so report the same
// response again later.
if (error instanceof GuardApiError && error.outage) {
console.log("Report it again later.");
} else {
console.log(String(error));
}
}Each message is written for a person, and its sentences can change between releases. Do not branch on its text. Find the decision on Decisions, or read the newest with vulsight.api.recentDecisions(1). When other agents share the account, the newest can be theirs, so confirm it on Decisions.
See a review
Hold one payment on purpose to test how your agent handles a review.
On Policy, set Hold over (USDC) to 0.01 and press Save policy.
You see: "Saved. The next decision runs under these rules."
Put back the lines from step 4, then run agent.ts again.
VulSight Guard is holding the payment for https://vulsight-guard.vercel.app/merchant/dataset for review (decision 91373fee-f5a2-4215-8133-81d8a5bf3907). Approve or deny it on the Review page at https://vulsight-guard.vercel.app/review. The SDK waits up to 120 s for the answer, or until the review expires if that comes first.You see: the SDK waits, and the payment is on Review.
Press Approve on Review while the SDK waits.
You see: 200 and the settlement, as in step 4 above.
Set Hold over (USDC) back to 0 and save.
You see: the next payment goes through without a hold.
If nobody answers in time, the call throws and nothing is signed. Reviews says how to run it again.
Send other text for checking
vulsight.fetch sends each page the agent reads for checking. A file, a search result or a tool's output reaches the guard only through vulsight.file(text, origin).
Pass the page's URL as origin when you know it, so a flag on that text holds only payments to that seller. A tool's name, or the default tool, ties the text to no seller.
await generateText({
model, tools, prompt,
onStepFinish: async ({ toolResults }) => {
for (const r of toolResults) {
const text = JSON.stringify(r.output) ?? "";
await vulsight.file(text, r.toolName);
}
},
});passmeans the check found nothing to hold.restrictmeans the check flagged the text.unavailablemeans the guard did not finish a check of it, or was not reached.
undefined or null, such as JSON.stringify of a tool that returned nothing, sends nothing and answers pass. Any other value that is not a string answers unavailable with a warning.
A flagged text holds later payments for an hour. Under a page's URL it holds payments to that seller, in any session and through the proxy URL too. Under a tool's name it holds every payment in the session.
What each result does to a payment is under injection_suspected. Send tool output only, never the user's own turns. The check reads untrusted content, and the person gives the orders.
The guard reads the first 64 KiB of each text. A longer one is sent cut at 64 KiB and holds the payments it concerns for an hour, so send long output in parts.
Sending faster than the rate limit, or past the account's 16 MiB a UTC day, gets a 429. Then file() answers unavailable, and the next payment that text concerns sends it again. If the guard still answers 429, that payment blocks in both modes. Past the day's limit, that lasts until midnight UTC.
Report the settlement
Call vulsight.reportSettlement(paid) right after each paid request. It reads the seller's receipt and posts its transaction to the decision that allowed the payment. The guard records it as reported and does not check it on chain.
A second call with the same response returns the recorded report and posts nothing. After an outage, call it again with the same response. Report at once, since a later receipt that names the same transaction blocks the report.
A paid request you send without wrapFetchWithPayment needs processPaymentResult on new x402HTTPClient(client) first.
A 3xx other than a 304 from the paid request means the payment may have settled. The SDK does not follow it and logs a warning. Before you pay again, report the response or check the decision on Decisions.
When the seller answers the paid request with another 402 or a failed receipt, reportSettlement throws. It names the seller's code only when it is a known x402 refusal code.
| Outcome | Codes | What it means | Next step |
|---|---|---|---|
| The wallet is short | insufficient_ invalid_ invalid_ | The seller still holds the signed payment until it expires. On EVM that is when the offer's time limit passes, and on Solana when its blockhash is no longer valid. | Wait until the signed payment expires, then fund the wallet. Check its transfers to the payee on chain before you pay again. |
| It may have settled | invalid_ invalid_ invalid_ settlement_ | A seller can send these codes for a payment that settled, or that may still settle. | Wait until the transfer confirms or the signed payment expires. Then check the wallet's transfers to the payee on chain before you pay again. |
| Any other known code | invalid_ invalid_ invalid_ invalid_ invalid_ invalid_ | The seller says the payment did not settle. That is only its claim while it holds the signed payment. | Wait until the transfer confirms or the signed payment expires. Then check the wallet's transfers to the payee on chain before you pay again. |
| No known code | None | The guard cannot tell whether the payment settled. | Wait until the transfer confirms or the signed payment expires. Then check the wallet's transfers to the payee on chain before you pay again. |
In each case, an amount the guard allowed or a reviewer approved still counts toward the daily cap. It counts for the UTC day the guard checked it.
Solana
The SDK checks Solana Mainnet and Solana Devnet payments too. Register the @x402/svm client scheme with your own Solana signer, then call guard(client, options). Allow the same network on Policy.
Solana addresses are case-sensitive, so never lowercase them. A held payment needs a fresh blockhash after approval, so let the x402 client build a new transaction. The daily cap is one budget across every network.
Limits
- The SDK pays only the supported payments. When a seller offers several, it keeps the supported ones before the x402 client picks.
- In both modes, it refuses before signing when no offer is supported. It does the same for an unknown network, a 402 that is not x402 version 2, or a payment it cannot read.
- It refuses a 402 that arrives through a redirect. Ask for the final URL.
- Code you register on the x402 client is trusted, and the guard does not check what it sends:
- Your schemes and extensions run after the check.
- A hook you register after
guard()can change a checked payment. - An
onPaymentCreationFailurehook can pay past a block. - An
onPaymentRequiredhook can answer a 402 with its own payment. - Code that edits the 402's offers changes what the guard judges.
Upgrading from 0.6
Update with bun add @vulsight/guard@latest, then check these changes. The changelog lists every one.
- Call
guard()before your ownonPaymentResponsehooks, and passreportSettlementthe responsewrapFetchWithPaymentreturned. - A paid request sent another way needs
processPaymentResultbeforereportSettlement. - A second
guard()on one x402 client throws, and so does a missing API key or a badsession. observepays only when the guard did not answer in time.- The held payment messages changed, so code that matched their text must too.
- A client you wrap yourself must expose
registerPolicy. Anx402Clienthas it. vulsight.api.deciderefuses fields the API does not take and a scheme that is not lowercase, and a base URL that redirects now throws.RuleIdincludespayment_method_supported, so handle it in exhaustive maps and switches.
Next steps
- Policy says how to raise a limit or allow a payee.
- Reason codes explains each sentence in a decision.
- Reviews covers approving, denying and rerunning a held payment.
- Errors lists every code the guard returns.
- Proxy URL needs no code change and works with any x402 client.