On this page
Set upTry itWhat each answer meansDenied and review answersCopies and retriesRequest headersWhat the proxy checksLimitsPurchase tasksLanes / Proxy URL
Route an agent through the proxy URL
Put the proxy URL in front of the seller's URL. Your x402 client pays as usual, and Cerberus checks every payment before the seller sees it.
- Lane
- Enforced
- You need
- An API key and proxy URL, an x402 client, and a wallet with test USDC
- Takes
- About 5 minutes
Set up with AI
Paste a prompt into Claude Code, Cursor, or Codex, and your assistant wires Cerberus into your agent. The prompts name the environment variables and never hold your key. MCP is advisory, since the agent chooses whether to ask.
Set up Cerberus in this project so it checks the x402 payments my agent makes.
1. First read https://cerberus.vulsight.com/llms.txt and the skill at https://cerberus.vulsight.com/skills/vulsight-guard/SKILL.md.
2. Find the function that makes paid x402 requests.
3. Install or update to the latest @vulsight/guard and pay through viaProxy from it, wrapFetchWithPayment(viaProxy(fetch, process.env.VULSIGHT_PROXY_URL), client), with viaProxy inside, so every paid request goes through the proxy URL and each 402 is confirmed with the seller. If the agent is not TypeScript or JavaScript, put the VULSIGHT_PROXY_URL value in front of each seller URL instead, with the seller's https:// left out.
4. Add an empty VULSIGHT_PROXY_URL= line to .env.example. Never write a real key, proxy token, or proxy URL into a committed file. Make sure .env is in .gitignore, and add it if it is not. I put the value in my own .env.
5. When I say the value is in place, make one test payment to https://cerberus.vulsight.com/merchant/dataset (0.05 test USDC on Base Sepolia). Then tell me to confirm the decision shows on Home at https://cerberus.vulsight.com/dashboard.It works with any x402 client in any language. Use the proxy URL or the SDK hook, not both.
Set up
A seller's URL becomes https://cerberus.vulsight.com/p/<proxy token>/<seller url>, with the seller's scheme left out. The seller changes nothing and needs no account here.
Copy your proxy URL and export it. It is shown once beside your API key, on Home for your first key and on Keys when you make or rotate one.
export VULSIGHT_PROXY_URL="<your proxy URL>"You see: a URL like https://cerberus.vulsight.com/p/vsp_test_.../, ending in a slash.
Install an x402 client.
bun add @x402/fetch @x402/evm viemYou see: the three packages in your package.json.
Make a throwaway wallet in the same shell. Paste its address into the Circle faucet and request test USDC on Base Sepolia.
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. The key is exported, never printed.
The token in the URL is a secret, and its vsp_test_ prefix does not mean test money. Keep the full URL out of browser history, shared logs and screenshots. If you did not save it, rotate the key on the Keys page for a new one. Rotating replaces the API key too.
Try it
Ask the demo seller for its dataset through the proxy URL.
curl -i "${VULSIGHT_PROXY_URL}cerberus.vulsight.com/merchant/dataset"HTTP/2 402
cache-control: no-store
content-type: application/json
payment-required: eyJ4NDAyVmVyc2lvbiI6...
{}That is the seller's 402 with only supported payment offers. If none are supported, the proxy refuses before your client signs. Cerberus decides the retry that carries the signed payment.
This x402 client makes that retry and prints the answer.
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 pay = wrapFetchWithPayment(fetch, client);
// The proxy URL shown with your API key, then the seller's URL
// with its scheme left out.
const proxy = need("VULSIGHT_PROXY_URL");
const seller = "cerberus.vulsight.com/merchant/dataset";
const res = await pay(proxy + seller);
// 200 allowed or 200 review_approved with the dataset,
// 403 denied with the reasons, or 402 review_pending.
const status = res.headers.get("guard-status");
console.log(res.status, status, await res.text());Run it with bun agent.ts, or node agent.ts on Node 22.18 or newer with "type": "module" in your package.json. It pays 0.05 test USDC.
If Node refuses the .ts file, run npx tsx agent.ts instead.
In TypeScript, viaProxy from @vulsight/guard 0.8.1 or newer writes the prefix for you, so the agent calls the seller's own URL. Add it with bun add @vulsight/guard, and this agent.ts does the same.
import { viaProxy } 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),
);
// Sends every request through the proxy URL shown with your API key,
// and signs only a 402 the seller confirms when asked directly.
const pay = wrapFetchWithPayment(
viaProxy(fetch, need("VULSIGHT_PROXY_URL")),
client,
);
const res = await pay("https://cerberus.vulsight.com/merchant/dataset");
// 200 allowed or 200 review_approved with the dataset,
// 403 denied with the reasons, or 402 review_pending.
const status = res.headers.get("guard-status");
console.log(res.status, status, await res.text());It checks the proxy URL when it starts. A seller URL that is not https throws before anything is sent, since the proxy reaches https sellers only, so no request skips Cerberus.
When the proxy answers 402, viaProxy also asks the seller directly and signs the seller's own 402. If the two name a different payee or amount, it throws ProxyTermsError and signs nothing. So not even a compromised proxy chooses who is paid or how much. The agent needs to reach the seller directly too.
Only supported payment offers reach the signer. If the seller offers only Permit2, escrow, or another unsupported method, nothing is signed. Ask the seller for a supported offer instead.
HTTP/2 200
cache-control: no-store
content-type: application/json
guard-decision-id: 3f6b2a91-5c0e-4d7a-9b18-2e4f7c6d8a05
guard-settlement-status: reported
guard-status: allowed
payment-response: eyJzdWNjZXNzIjp0cnVlLCJ0cmFuc2FjdGlvbiI6...
{
"name": "product-reviews",
"rows": 10000,
"sample": [...]
}For a denied payment, point it at /merchant/priority (2.50 USDC). The x402 client refuses payments over one dollar before Cerberus sees them. Keep that cap, or raise it to the most one payment should cost with client.setSpendControls({ maxAmountPerPayment: "$5" }).
What each answer means
Read guard-status first. Sent says whether this request's payment left the proxy.
| guard-status | HTTP | Sent | Means | Next step |
|---|---|---|---|---|
allowed | The seller's code, or 502 or 504 | Yes | Cerberus allowed the payment and the proxy forwarded it. The body is the seller's answer, unless the seller failed after the payment left. Then the proxy answers 502 or 504 with a merchant_ code. | Check the HTTP status before you use the body. On a 502 or 504, follow the code on Errors. Read guard-settlement-status for the receipt. |
review_approved | The seller's code, or 502 or 504 | Yes | A person approved the held payment, and the proxy forwarded it. | Treat it as allowed. |
review_pending | 402 | No | The payment is held for a person. The review stays open. | Approve it on Review, then request the URL again and sign the new 402. See Denied and review answers. |
denied | 403 | No | Your policy or the content check refused the payment. The code is payment_denied, and the message carries the reasons. | Read the reasons. Do not pay the seller another way. |
review_denied | 403 | No | A person denied the held payment. | Do not pay it. |
review_expired | 403 | No | Nobody answered before the review timeout. Nothing was paid. | Request the URL again and sign the new 402. Cerberus decides it as a new payment. |
resign_required | 402 | No | Cerberus allowed or approved the payment, but its signature ran out before the proxy could forward it. It comes with guard-next-step: fresh_quote. | Request the URL again and sign the new 402. The same decision answers it. |
not_forwarded | 4xx or 5xx | No | The proxy refused this request itself. It did not leave the proxy. | Follow the code's entry on Errors before you re-sign. |
not_enforced | 409 | No | The request asked for enforce mode, and the agent is in observe mode. The code is proxy_not_enforced. | Switch the agent to enforce on Agents, or leave out x-vulsight-require-mode. |
redirected_after_payment | Any | By the earlier request | The seller answered the paid request with a redirect, and this answer is from following it. The payment may already be made. | Read the decision in guard-decision-id before you pay for it again. |
The proxy writes these headers itself. A seller cannot set them.
| Header | On | Means |
|---|---|---|
guard-status | Every answer about a payment | What happened to the payment, from the list above. |
guard-decision-id | Every answer with a decision | The decision's id. Read it on the Decisions page or at GET /api/v1/decisions/{id}. It is Cerberus's permission, not proof that money moved. |
guard-settlement-status | Every paid answer | confirmed means the Solana network confirmed the transaction. reported means the seller's receipt is on the ledger on the seller's word, as every Base receipt is. unverified means no usable receipt came back, which does not prove that no money moved. Check your wallet, and report a payment that went through with POST /api/v1/settlements. |
guard-next-step | A signature that ran out | fresh_quote. Request the URL again and sign the new 402. |
guard-shadow-status | Observe mode only | What enforce mode would have decided, such as denied or review_pending. The payment went through anyway, with guard-status: allowed. |
Every error code the proxy returns, with its cause and next step, is on Errors. When a seller fails after the payment left, the message says what became of the payment.
Denied and review answers
A denied payment answers 403 with the reasons. Nothing is forwarded.
HTTP/2 403
content-type: application/json
guard-decision-id: 9107cf25-aa34-4d7b-a546-c8b5253634a3
guard-status: denied
{
"error": {
"code": "payment_denied",
"message": "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.50 USDC, over the daily cap of 1.00 USDC. 0x894d…4807 is not on your allowlist. Auto-allow is off in your policy."
},
"decisionId": "9107cf25-aa34-4d7b-a546-c8b5253634a3"
}The proxy holds a Base payment for review up to 35 seconds while you decide. Approve in time and your client gets the paid answer. Otherwise it gets this 402, and the review stays open.
HTTP/2 402
cache-control: no-store
content-type: application/json
guard-decision-id: daf814c5-e82b-41d3-8af2-cb71b5ccd6d8
guard-status: review_pending
payment-required: eyJlcnJvciI6IlRoaXMgcGF5bWVu...
retry-after: 0
{
"error": {
"code": "review_pending",
"message": "This payment waits on a review, so it was not forwarded yet. Approve or deny it on the Review page at https://cerberus.vulsight.com/review before 2026-09-26 14:02:00 UTC, when the review expires. Then send it again after the retry-after seconds, signed afresh if the signature has expired, and the same review answers it."
},
"decisionId": "daf814c5-e82b-41d3-8af2-cb71b5ccd6d8"
}Approve or deny the payment on Review.
You see: the card moves under Resolved in the last day.
Request the URL again and sign the 402 it returns.
You see: 200 with guard-status review_approved, or 403 with review_denied.
If the review expired first, do step 2 twice.
You see: 403 with review_expired, then a new decision.
Stock x402 clients such as @x402/fetch hand the 402 back to your code and do not retry, so your code sends step 2. Give your client a read timeout of at least 60 seconds. A client that gives up sooner can be charged without getting the paid body.
A Solana payment is not held. It gets the 402 at once, with retry-after: 2. The Reviews page has the buttons, the timeout and how to hold a payment on purpose.
Copies and retries
The proxy knows a payment by what was signed. That is an EVM authorization's payer and nonce, or the Solana transaction the buyer signed.
A copy of the same signed payment, such as a library's automatic retry, reads the decision the first send got. It adds nothing to the day's cap or the hour's count.
| The proxy answers | When the copy arrives |
|---|---|
409 decision_already_used | Its settlement is on file. It paid, so do not send it again. |
403 payment_denied | Its payment was denied, or its review was denied or expired. The answer carries the same reasons. |
| Forwarded | The proxy kept its decision for a retry, after a hold, a brief failure on our side, or a client that hung up. It checks the copy again and forwards it under that decision. An open review answers 402 review_pending again. |
409 duplicate_payment | Anything else, or it was sent after the seller's 402 changed or to another URL. An open review answers this too. |
A payment signed afresh, with a new nonce or a new Solana transaction, is a new attempt. It picks up the decision the proxy kept for the same offer, such as an open or approved review. Otherwise Cerberus decides it as a new payment.
Never resend a payment that answered duplicate_payment or decision_already_used. Check its decision and your wallet first.
Request headers
The proxy reads these headers on the way to the seller.
| Header | What the proxy does |
|---|---|
PAYMENT-SIGNATURE | The signed x402 v2 payment. The proxy checks it and forwards it only when Cerberus allows it. |
X-PAYMENT | An x402 v1 payment. Refused with x402_v1. |
x-vulsight-require-mode | Set to enforce, the proxy answers only while the agent is in enforce mode. Otherwise it answers proxy_not_enforced. The seller never sees this header. |
AuthorizationX-API-KeyApi-KeyX-Auth-TokenX-Access-Token | Sent to the seller. After a redirect off the seller's site, the proxy drops them for the rest of the chain. It marks that URL with ~x/ before the host, so keep the mark. Put a seller key in one of these five. |
Authorization: Bearer vs_test_... | A Cerberus API key. Refused with key_in_request, since the seller must not receive it. |
CookieRefererX-Forwarded-For | Never sent to the seller. |
What the proxy checks
- The signed payment. The payer, payee and amount come from the signature, and the asset and network from the seller's 402. The terms must be ones that 402 offered. Then your policy decides, as
POST /api/v1/decisionsdoes. - Every answer it relays: the seller's 402, pages the agent fetches through it, and paid answers. A payment waits while an answer from that seller is still being checked. A flagged page holds or blocks payments to that seller.
- A page flagged for the same seller through the SDK hook, the advisory MCP tool or
POST /api/v1/contextholds payments to it for an hour. The proxy does not wait for those checks, so wait for their answer before you pay. - The receipt on the way back. The settlement lands on the ledger without a report from the agent.
- A URL that never answers 402 passes through and makes no decision.
Limits
| Limit | Value |
|---|---|
| Seller URL after the token | 2,000 characters |
| Request body, and each seller answer | 4 MiB |
| Seller answers relayed a UTC day, per account | 256 MiB |
| Seller answers relayed a minute, per agent on each server instance | 120 |
| Text sent for checking a UTC day, per account | 16 MiB |
| Text checked in one answer | 64 KiB |
| Hold on a Base payment while you review | Up to 35 seconds |
Past the daily text limit, unpaid requests answer 429 until midnight UTC. Past 64 KiB, the agent still gets the whole answer, and the next payment to that seller waits for your review. Payments decided and pages checked count toward the key's rate limit.
- Only traffic through this URL is checked. The same agent calling the seller directly is not.
- The proxy checks, it never signs. Your key stays on your side, and Cerberus cannot pay for you or claw a payment back.
- The token lands in logs and shell history. Rotate the key on Keys if it leaks, and the old token can no longer pay.
- The proxy takes x402 v2 and the supported payments only.
The rest of the limits are on the security model page.
Purchase tasks
Create a task on Policy and copy its id. Send it in x-vulsight-task and an optional lower lifetime purchase limit in x-vulsight-task-max. With the SDK wrapper, use viaProxy(fetch, proxyUrl, { task: serverIssuedId, maxPurchasesPerTask: 3 }).
These headers are stripped before reaching the seller, including the direct quote confirmation. A parked payment stays bound to its task when re-signed. A retry cannot switch tasks, and an unused allowance for a closed task cannot forward.
Without the headers a payment has no task limit. A task allows 10 lifetime purchases before review by default. Purchases to the same payee and network within 300 seconds wait for review either way, whatever URL, amount, task or session they use.
