Reviews
REVIEW means the action does not run yet. review.status starts as PENDING. Someone with an approver role has to approve or deny it, or the review expires.
A review is approved on the Vulnify server: from the Slack app, with an MFA step-up, or by a separate approver in the app. The process that called check() does not approve anything by itself.
The stored decision stays REVIEW for the life of the event. finalDecision is what changes:
| Review | finalDecision |
|---|---|
| Pending | REVIEW |
| Approved | ALLOW |
| Denied | BLOCK |
| Expired | BLOCK |
Resolving a review does not emit a new webhook. Clients poll GET /v1/events/{id}.
Review object
Section titled “Review object”{ "status": "PENDING", "expiresAt": null, "decidedAt": null, "note": null}status |
Meaning |
|---|---|
PENDING |
Waiting for a person. finalDecision is REVIEW. |
APPROVED |
A reviewer approved it. finalDecision is ALLOW. The action may run if you held it. |
DENIED |
A reviewer denied it. finalDecision is BLOCK. Do not run it. |
EXPIRED |
The review window elapsed. finalDecision is BLOCK. |
review is null when the event has no human review. expiresAt, decidedAt, and note are strings or null. expiresAt and decidedAt are date-times when set.
The organization has a review window, in hours. A pending review expires after that time. The exact window is configured in the app. These docs do not assume a default.
Approving a HIGH or CRITICAL review asks for a current authenticator code when MFA is enabled on the approver’s account.
Who can approve is set on the policy (approverRoles). Roles in the app are owner, admin, and member.
Waiting from the SDK
Section titled “Waiting from the SDK”By default, do not poll. Tell the caller that a person must approve, and leave the action unrun. The export samples in the quickstart do this.
If you intentionally want the process to block until a person answers, pass wait. Obey finalDecision (Python: final_decision) when the response includes it. An idempotent replay that omits the field falls back to decision for the initial check, and to review.status while polling.
| SDK | Call | Behavior |
|---|---|---|
| Node.js | waitForReview(id, { timeoutMs, pollMs }) |
Polls every 2 seconds for 5 minutes by default. Returns review.status once it leaves PENDING: APPROVED, DENIED, or EXPIRED. Returns TIMEOUT when the client gives up. |
| Node.js | guard(action, fn) |
Runs fn when finalDecision ?? decision is ALLOW. BLOCK and REVIEW throw VulnifyBlockedError. |
| Node.js | guard(action, fn, wait) |
Same initial check. When that outcome is REVIEW, polls and runs fn only if waitForReview returns APPROVED. |
| Python | wait_for_review(event_id, timeout=300, poll=2) |
When final_decision is set, returns ALLOW or BLOCK and keeps polling while it is REVIEW. When final_decision is None, returns APPROVED, DENIED, or EXPIRED. Returns TIMEOUT when the client gives up. |
| Python | guard(fn, **action) |
Runs fn when final_decision is ALLOW, or when it is None and decision is ALLOW. BLOCK and REVIEW raise VulnifyBlockedError. |
| Python | guard(fn, wait={"timeout": 300}, **action) |
Same initial check. When that outcome is REVIEW, runs fn if the poll returns ALLOW or APPROVED. |
TIMEOUT means nobody answered before the client gave up. EXPIRED is the server status when the review window elapses. On Python, an expired review that includes final_decision comes back from wait_for_review as BLOCK.
guard() without wait throws on REVIEW and does not run your function. The stored decision stays REVIEW after approval. Run the action when finalDecision is ALLOW. Node.js waitForReview still reports that approval as APPROVED.
Polling uses GET /v1/events/{id}. See Get an event.
With the Slack app connected, approvers can answer from the message when Approve and Deny buttons are enabled for that integration. An incoming webhook can post alerts only. It does not approve the review. See Slack.

