Skip to content
On this pageHold a payment on purposeAnswer a reviewReview timeoutWhen the wait endsRun a held payment againHold notifications

Guides / 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.

  1. On Home, press Run a held payment.

    You see: "Held for your review. Approve or deny it under Needs your review." Nothing is signed.

  2. 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.

  1. On Policy, set Hold over (USDC) to 0.01 and press Save policy.

    You see: "Saved. The next decision runs under these rules."

  2. 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."

  3. 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.

  1. Open Review.

    You see: how many payments are waiting, then one card for each.

  2. Optional. Press Show the decision to read the full record.

    You see: the decision page, with the same buttons.

  3. 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.

ButtonShows onWhat it does
ApproveEvery 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 payeeA 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 pageA 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 paymentA content review with no page to clear.Allows this payment once.
DenyEvery 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.

note

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

LaneWaitsWhen the wait ends
Proxy URLAn 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.
SDKwaitForReviewSeconds, 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 APIUp 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.

LaneThe review is still openThe review expired
Proxy URLApprove 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.
SDKDeny 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 APIAnswer 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.

FieldTypeHolds
eventstringreview_pending
decisionIdstringThe decision's id. A retried post repeats it.
agentstringThe agent's name.
amountstring, optionalThe 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.
grantstring, optionalOn 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.
payeestringThe payee's address, shortened.
resourceUrlstring, optionalThe URL the agent pays for, in full. A contract call has none.
reasonsstring[]The sentence of each rule that did not pass and has one.
reviewUrlstringThe decision's page on this site, with Approve and Deny.
createdAtstringWhen the guard held the payment, in UTC.
expiresAtstringWhen the review closes, createdAt plus the Review timeout.
Webhook body
{
  "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.

webhook.ts
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 decisionId twice, 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.