On this page
Hold a payment on purposeAnswer a reviewReview timeoutWhen the wait endsRun a held payment againHold notificationsGuides / Reviews
Reviews
The guard holds a payment when a rule wants a person to decide. You answer it on Review before the review timeout closes it.
The rules that hold a payment are on reason codes. Each lane waits for the answer in its own way.
Hold a payment on purpose
Hold one payment to see the flow before a real one needs you.
On Home, press Run a held payment.
You see: "Held for your review. Approve or deny it under Needs your review." Nothing is signed.
Open Review.
You see: the payment as a card, with its reason and a countdown.
Home's run pays a seller your allowlist does not list, so it is held as a first payment. Auto-allow unlisted payees under (USDC) starts at 0. The demo seller is already on a new account's allowlist, so the steps below hold it by amount instead.
To hold a payment from your own agent, lower Hold over (USDC) for one run.
On Policy, set Hold over (USDC) to 0.01 and press Save policy.
You see: "Saved. The next decision runs under these rules."
Run your agent against the demo seller's dataset,
vulsight-guard.vercel.app/merchant/dataset, which costs 0.05 test USDC. The Quickstart shows how to send it through the proxy URL.You see: the payment on Review. Its reason reads "The amount 0.05 USDC is over your hold threshold of 0.01 USDC, so it waits for a person."
Answer it, then set Hold over (USDC) back to what it was (0 on a new policy) and save.
You see: the next payment goes through without a hold.
Answer a review
Review lists every payment waiting on a person. Each card names the agent, the amount, the payee and the reasons, and counts down the time left.
Open Review.
You see: how many payments are waiting, then one card for each.
Optional. Press Show the decision to read the full record.
You see: the decision page, with the same buttons.
Press Approve.
You see: the card moves under Resolved in the last day, stamped approved, by you.
A card shows the buttons that fit its reasons.
| Button | Shows on | What it does |
|---|---|---|
| Approve | Every hold except a content review or one the guard cannot judge. | Allows this payment once. The allowlist does not change. |
| Approve and always allow this payee | A hold whose only reason is a first payment to this payee. | Approves, and adds the payee to your allowlist on this network until you remove it on Payees. It also skips the managed wallet blocklist for this payee. |
| Approve and clear page | A content review that shows the flagged page. | Allows this payment and clears that page for this agent. Other flagged pages can still hold later payments. |
| Approve this payment | A content review with no page to clear. | Allows this payment once. |
| Deny | Every hold. A content review labels it Deny payment. A hold the guard cannot judge shows only Deny payment (Deny call for a contract call). | Asks you to confirm, then tells the agent the payment was denied. Nothing is paid. |
Before an approval lands, the guard checks the network, the asset and the payee denylist as the policy stands now. It checks the managed wallet blocklist when that is on. The daily cap stays as it was at the hold, so raising it now makes no room. If a check fails, the card names the cause and the next step.
A hold made before midnight UTC can no longer be approved after it. Deny it or let it expire, then run the payment again.
Review timeout
A review stays open for the agent's review timeout, two minutes (120 seconds) by default. The clock starts when the guard holds the payment. When it runs out, the review expires and nothing is paid.
Change it per agent on Policy, under Review timeout (seconds). The policy page lists every field.
The agent's own wait is separate. Raise waitForReviewSeconds (SDK) or wait_seconds (advisory MCP tool) along with it. Otherwise the agent stops waiting while the review is open.
When the wait ends
| Lane | Waits | When the wait ends |
|---|---|---|
| Proxy URL | An EVM payment up to 35 seconds, less when the review or the authorization ends sooner. A Solana payment gets its answer at once. | Answers 402 with guard-status: review_pending. The review stays open. |
| SDK | waitForReviewSeconds, 120 by default. | Throws, and nothing is signed. The review stays open. |
| MCP (advisory) | wait_seconds, 120 by default, at most 300. | Tells the agent not to pay yet. The review stays open. |
| HTTP API | Up to 30 seconds a call to GET /api/v1/decisions/{id}?wait=30. | Answers 200 with status: review_pending. Poll again. |
An approval inside the wait goes ahead at once. The proxy forwards the payment, the SDK signs it, and the advisory MCP tool tells the agent to pay. Once the review expires, every lane reads review_expired and nothing is paid.
Run a held payment again
If the agent stopped waiting, the next step depends on the lane and on whether the review is still open. An open review counts toward today's cap until it is denied or expires, and an approved one keeps counting.
| Lane | The review is still open | The review expired |
|---|---|---|
| Proxy URL | Approve or deny it. Then request the URL again and sign the 402 it returns. The same review answers that retry. | Request the URL again and sign the 402. That first retry answers 403 with guard-status: review_expired, and nothing is paid. Request the URL once more and sign the new 402. That is a new payment, decided again. |
| SDK | Deny it, since an approval now does not release the payment that stopped. Then run the payment again and approve the new review while the SDK waits. | Run the payment again and approve the new review while the SDK waits. |
| MCP (advisory) | With an idempotency_key, answer it. Then call check_payment again with the same payment, context, seller_402 and key, if the agent read nothing new since. Without a key, deny it and call again with a new key. | Call check_payment again with a new idempotency_key. |
| HTTP API | Answer it and keep polling the same decision. A repeat POST with the same Idempotency-Key and body answers it too. | POST the decision again with a new Idempotency-Key, or none. |
The proxy knows a payment by what was signed. A copy of the same signed payment, such as an HTTP library's automatic retry, reads the decision the first send got. It opens no second review and adds nothing to the day's cap.
Hold notifications
A hold can reach you before you open the dashboard. Turn on any of three channels under Settings.
- A webhook posts signed JSON to a URL you save.
- Email goes to the address you sign in with, once you type back a six-digit code sent there. A sign-in provider that verified the address, such as Google, skips the code.
- Push goes to a browser. On iPhone, add the site to the home screen first, then turn push on from there.
Every channel names the agent, the amount, the payee, the reasons and when the review closes. Each links to the decision's page, which has Approve and Deny.
The email and the push name the seller by its host. The webhook carries the full resource URL.
The webhook body holds these fields.
| Field | Type | Holds |
|---|---|---|
event | string | review_pending |
decisionId | string | The decision's id. A retried post repeats it. |
agent | string | The agent's name. |
amount | string, optional | The amount and asset, such as 0.05 USDC. A contract call has one only when it is a token transfer or grant the guard can read. |
grant | string, optional | On an ERC-20 approve or increaseAllowance only. approve sets what the payee may spend to the amount, and increase raises it by the amount. Nothing has moved yet. |
payee | string | The payee's address, shortened. |
resourceUrl | string, optional | The URL the agent pays for, in full. A contract call has none. |
reasons | string[] | The sentence of each rule that did not pass and has one. |
reviewUrl | string | The decision's page on this site, with Approve and Deny. |
createdAt | string | When the guard held the payment, in UTC. |
expiresAt | string | When the review closes, createdAt plus the Review timeout. |
{
"event": "review_pending",
"decisionId": "daf814c5-e82b-41d3-8af2-cb71b5ccd6d8",
"agent": "Research agent",
"amount": "0.05 USDC",
"payee": "0x3333…3333",
"resourceUrl": "https:// merchant.example/ dataset",
"reasons": [
"0x3333…3333 is not on your allowlist. Auto-allow is off in your policy."
],
"reviewUrl": "https:// vulsight-guard.vercel.app/ decisions/ daf814c5-e82b-41d3-8af2-cb71b5ccd6d8",
"createdAt": "2026-09-09T21:06:54.047Z",
"expiresAt": "2026-09-09T21:08:54.047Z"
}Each post carries x-vulsight-event: review_pending and x-vulsight-signature: sha256=<hex>. The signature is the HMAC-SHA256 of the raw body under the secret Settings showed you once.
Check the signature before you trust the body, and compare in constant time. The signature covers the body and not the headers, so read the event from the body's event field. This receiver runs on Bun or Node.
import { createHmac, timingSafeEqual } from "node:crypto";
const HEADER = "x-vulsight-signature";
export async function POST(request: Request) {
const secret = process.env.VULSIGHT_WEBHOOK_SECRET;
if (!secret) {
return new Response("webhook secret not configured", {
status: 500,
});
}
const body = await request.text();
const digest = createHmac("sha256", secret)
.update(body)
.digest("hex");
const expected = Buffer.from(`sha256=${digest}`);
const header = request.headers.get(HEADER) ?? "";
const given = Buffer.from(header, "utf8");
if (
given.length !== expected.length ||
!timingSafeEqual(given, expected)
) {
return new Response("bad signature", { status: 401 });
}
const event = JSON.parse(body);
// Skip the Settings test post.
if (event.event !== "review_pending") return new Response("ok");
// Your handling here.
console.log(
`${event.agent} is waiting on a review: ${event.reviewUrl}`,
);
return new Response("ok");
}Send a test event on Settings posts one signed x-vulsight-event: test, at most once a minute. Its body holds event, id and createdAt, so no two test posts share a signature. Settings shows what your URL answered.
Delivery rules
- The URL must be a public https address on the default port. The guard refuses private and loopback hosts on save and before every post, and follows no redirects.
- Each channel gets five seconds to answer a hold. After a timeout, a dropped connection, a 429 or a 5xx, it gets one more try a second later. A receiver may see the same
decisionIdtwice, so act on it once. - An account gets at most twenty emails a day, codes and tests included.
- When a test send is refused, Settings names the cause.