Approval Gate Node

The Approval Gate pauses a run until a person — or a system — decides. The event waits at the gate; approve it and the run continues through the Approve output, reject it and it continues through the Reject output. If nobody decides in time, the gate decides for them.

How it works

When an event reaches an Approval Gate the run stops and a pending approval is created with the payload as it arrived. Somebody then decides, in whichever of these ways the gate is set up for:

  • From the email — the gate emails up to two people a link with Approve and Reject buttons.
  • From the dashboard — the Approvals table at the bottom of the node's page lists every wait; expand one and resolve it with a comment.
  • From a resume URL — every wait has a signed URL. Opened in a browser it shows the approval page; called from code (with the gate's secret) it decides over HTTP.

The first decision wins. Once decided, the payload continues downstream with an _approval block describing what happened. Past the timeout, the gate applies its auto action — approve or reject — and the run continues the same way.

A gate hung directly from a webhook holds the whole event, not just its own branch: the delivery to the webhook's URL and the webhook's other branches wait for the decision too. Approve, and they run along with the gate's Approve output. Reject, and only the Reject output runs: nothing is delivered, and the event shows as rejected. A gate further down (after any other node) only holds its own branch — the delivery has already happened, so the event stays delivered either way.

On the canvas the gate appears as an amber node with two output handles, main (approved) and rejected. A paused gate does not wait and does not pass events through: the run stops there, and the skip is recorded in telemetry.


The node page, top to bottom

Everything below is what you see when you open an Approval Gate from the canvas, in the same order.

Decided via

How the decision reaches the gate. This choice changes what the rest of the Config tab shows.

OptionWhat happens
NativeHostWebhook sends the notification email from its own address. Nothing to connect.
GmailThe email goes out from a Google account you connect right below (Google account section). The gate shows a gap until an account is picked.
Webhook callNo email at all. The run waits until something calls the wait's resume URL over HTTP — your backend, a script, Postman — or someone opens that URL in a browser. See Decide by webhook call.

Config tab — Native and Gmail

SectionWhat it does
Notify EmailsUp to two addresses. Each gets its own copy of the email with the Approve and Reject links.
Email SubjectTemplate. Empty means "Approval Required: <node name>".
Email Body (HTML)Template for the body; switch to the code editor for full HTML. Empty means the default layout: the first six payload fields and the two buttons.

Subject and body accept the usual {{payload.field}} expressions and filters, plus these meta values:

ExpressionValue
{{meta.approveUrl}}Link to the approve page (the decision is its button, not opening the link)
{{meta.rejectUrl}}Link to the reject page (the decision is its button, not opening the link)
{{meta.eventId}}The event waiting at the gate
{{meta.approvalNodeName}}This gate's name
{{meta.timeoutMinutes}}Minutes before the auto action
{{meta.autoAction}}approve or reject
⚠An HTML template escapes what it inserts. Every {{ }} in an HTML body comes out as text, so a value from the payload can't add tags or links to your email. To insert HTML you trust, add | raw — for example, the HTML an AI Node wrote: <div>{{$("AI").aiResponse | raw}}</div>. Plain-text bodies don't change.
ℹLinks go out only as http, https or mailto. A link with any other scheme (javascript:, data:, tel:…) becomes #. Images and other sources (src) take only http and https.
ℹThe links in the email are single-use and tied to that wait. Anyone holding a link can decide unless Require sign-in is on (see Who can decide).

Config tab — Webhook call

SectionWhat it does
Resume URLThe expression {{$resumeUrl("<node name>")}} to copy into a node that runs while the gate waits — an HTTP Action to your backend before the gate, or a Slack or Discord Notification on its own branch (see Where the link can go). It resolves to that run's signed URL. The card also shows the URL of the last test wait, so you can try the contract from Postman right away.
SecretThe gate's own secret, sent as the X-HostWebhook-Approval-Secret header. View, Copy, or Regenerate it here — regenerating invalidates the previous value at once. It lives only on this gate; it is not a credential or a signing secret.
DecisionWhich field of the POST body carries the decision. Default action. Dots reach nested fields (result.verdict).
Reply to the callerWhat the POST receives back: a status code (200–599, default 200) and an optional body written one key: value per line, with templates over the payload that leaves the gate. Empty means { "resumed": true, "eventId": "…", "action": "…" } (resumed: false, plus test: true, for a Run Test wait).

Approval page tab

The public page a reviewer sees when they open the link — from the email, from a message, or from the resume URL in a browser.

SectionWhat it does
Page TitleHeading of the page. Empty means the gate's name.
DescriptionOne line under the title. Templates allowed.
Display FieldsUp to ten rows of Label + value, where the value is an expression such as {{payload.amount | currency}}. Empty means the first eight payload fields, automatically.

Timeout and Auto Action on Timeout

Timeout is how long the gate waits (default 24 hours). When it runs out, the gate applies the Auto Action: Auto-reject (the default, fail-safe) or Auto-approve. The wait shows as Expired in the table, the payload continues through the matching output and _approval.resolvedVia reads timeout.

Who can decide

Require sign-in — off by default. Off, anyone holding the approval link can decide, and the record only says which address the link was sent to — not who actually clicked. On, the page asks them to sign in to HostWebhook first and the record names that user. Turn it on when the link is posted to a shared channel and the decision matters.

Run Test (right panel)

Creates a real test wait with the chosen example payload — it shows up in the Approvals table tagged Test. With Native or Gmail the email really goes out to the notify addresses, with a TEST RUN banner on top; with Webhook call nothing is sent and the wait's URL appears in the Resume URL card. Deciding a test wait resolves it only: nothing downstream runs.

Approvals table

Every wait this gate has created, newest first, with a status filter (Pending, Approved, Rejected, Expired) and a search over the payload and the event id. A pending row has Copy URL — its resume URL — and Resolve. Expand a row to see the payload, the details (event, expiry, who decided and how, comment) and, while it is pending, the Approve and Reject buttons. A comment is required to decide from here. The table updates live as decisions arrive.


What the reviewer sees

1

The email

Subject Approval Required: <node name> unless you changed it, the payload fields, and two buttons: Approve and Reject. Each button is a one-click link.

2

The approval page

Title, description and the display fields you configured; a comment box; the Approve and Reject buttons. A test wait shows a TEST RUN banner. With Require sign-in on, the page asks for a HostWebhook login before showing the buttons.

3

After deciding

A confirmation page. The link is spent: opening it again shows the decision that was taken, and the same wait cannot be decided twice — from any channel.


Decide by webhook call

With Decided via: Webhook call the gate sends no email: the run pauses until something calls its resume URL over HTTP. The URL comes from {{$resumeUrl("Node name")}} in any node before the gate — send it to your backend with an HTTP Action, or post it to Slack or Discord. Opened in a browser it still shows the approval page; called from code it answers JSON. Whichever arrives first wins.

The node that carries the link has to run while the gate is waiting. Two shapes do that:

1

Before the gate, on the same branch

Webhook → HTTP Action (or Email Action) → gate. The node sends the link, then the run reaches the gate and waits.

2

On a branch of its own

Webhook → Notification, and webhook → any node → gate. A Slack or Discord Notification has no outputs, so nothing can come after it: it always goes on its own branch. Set its Trigger to Always: it starts on Failed delivery, and with that the message — link included — only goes out when the delivery fails.

⚠Don't hang the gate directly from the webhook when the link travels in a Notification. A gate right after the webhook holds the whole event — its delivery and its other branches, Notifications included — until someone decides, so the message with the link is not sent while the gate waits, and the gate ends on its timeout. Put any node between the webhook and the gate; a Transform is enough.

Try it from Postman

1

Get a URL

Click Run Test on the node page: the Resume URL card shows the test wait's URL with a Copy button. Deciding a test wait answers you (resumed: false, test: true) but runs nothing downstream — it is for checking the contract. To watch the flow continue, decide a real run's wait: the URL your flow sent out, or Copy URL on an Event row in the Approvals table.

2

Add the headers

X-HostWebhook-Approval-Secret: the secret from the Secret card. Accept: application/json — without it, or with text/html, you get the approval page instead of JSON.

3

GET to look, POST to decide

GET returns what is waiting without resuming anything. POST with a JSON body such as { "action": "approve" } decides and resumes the run.

GET — what is waiting

bashInspect
curl "<resume url>" \
  -H "Accept: application/json" \
  -H "X-HostWebhook-Approval-Secret: hwa_…"
json200 OK
{
  "status": "pending",
  "eventId": "evt_9987e5f6a7b8c9d0e1f2a3b4",
  "expiresAt": "2025-05-01T13:00:00.000Z",
  "payload": { "orderId": "ord_42", "amount": 250000 },
  "decision": null
}

Once decided, status becomes approved or rejected and decision carries the action, who decided and when. Past the timeout it reads expired.

POST — decide and resume

Send any JSON body you like. The only thing it must carry is the decision; the gate reads two fields and leaves the rest alone:

FieldWhat the gate does with it
action (or the field named in Decision)Required: approve or reject. Missing, or any other value → 400 with an error body, and nothing resumes; the wait stays as it was.
commentStored as the decision's comment, when present.
anything elsePassed through untouched, inside payload._approval.
bashDecide
curl -X POST "<resume url>" \
  -H "X-HostWebhook-Approval-Secret: hwa_…" \
  -H "Content-Type: application/json" \
  -d '{ "action": "reject", "message": "Budget is closed for Q3", "ticket": "T-9" }'

The whole body lands inside payload._approval, under the gate's own fields — so your message and ticket are there for the next nodes, and action, resolvedAt, resolvedVia and the rest are always the gate's, whatever the body says:

jsonpayload._approval after that POST
{
  "message": "Budget is closed for Q3",
  "ticket": "T-9",
  "action": "reject",
  "resolvedAt": "2025-05-01T12:15:00.000Z",
  "resolvedBy": "webhook",
  "resolvedByEmail": null,
  "resolvedVia": "webhook",
  "comment": null,
  "approvalNodeName": "High-Value Transaction Review"
}

The caller receives the configured Reply to the caller, rendered against that same payload, so {{payload._approval.action}} and {{payload._approval.ticket}} both work there. A POST that does not carry the decision gets this instead, and the wait is untouched:

json400 Bad Request
{
  "error": "decision_missing",
  "field": "action",
  "message": "The body has no "action". Send "approve" or "reject" in it; nothing was resumed."
}

Status codes

CodeWhen
401The secret header is missing or wrong
403The gate is not in Webhook call mode
202GET before the run has reached the gate (nothing to inspect yet)
409POST on a wait already decided — the first decision stands
410The wait expired; the auto action already ran
400The decision field is missing (error: decision_missing) or holds something other than approve or reject (error: decision_invalid); the response names the field. Nothing resumes.

What comes out

The payload leaves the gate as it arrived, plus an _approval block. It goes through the Approve output (main port) or the Reject output (rejected port) — never both.

jsonPayload leaving the gate
{
  "orderId": "ord_42",
  "amount": 250000,
  "_approval": {
    "action": "approve",
    "resolvedAt": "2025-05-01T12:15:00.000Z",
    "resolvedBy": "Jane Cooper",
    "resolvedByEmail": "jane@example.com",
    "resolvedVia": "manual",
    "comment": "Verified by finance",
    "approvalNodeName": "High-Value Transaction Review"
  }
}
FieldMeaning
actionapprove or reject
resolvedViamanual (email link, page or dashboard), webhook (a POST to the resume URL), timeout (the auto action)
resolvedBy / resolvedByEmailThe signed-in user (dashboard, or the page with Require sign-in), the address the gate's own email was sent to, approval link when the link was opened from anywhere else (a Slack or Discord message, an email your flow sent), or a label such as webhook or system:timeout
commentWhat the decider wrote, or null
…caller fieldsWith Webhook call, every other field of the POST body is included here, untouched, under the ones above — the gate's fields always win

Sending the link yourself

Any node that runs before the gate can use {{$resumeUrl("Node name")}} — the gate's exact name — and it resolves to the URL of the wait that this run is about to create. Put it in a Slack or Discord message, an email of your own, or an HTTP Action to your backend. Whoever opens it sees the approval page; whatever calls it with the secret decides over HTTP. The link exists before the gate is reached: opening it too early shows a "not ready yet" page rather than an error.


API reference

FieldTypeDefaultDescription
namestring—The gate's name. Also what $resumeUrl("…") looks up.
isActivebooleantruePaused (false) stops the run at the gate; no wait is created.
providerstringnativenative | gmail | webhook — the Decided via choice.
credentialIdstring | nullnullThe connected Google account, with provider gmail.
notifyEmailsstring[][]Up to two addresses (email providers only).
emailSubjectstring""Template; empty means the default subject.
emailBodystring""HTML template; empty means the default layout.
webhookCall.decisionFieldstringactionField of the POST body with the decision (provider webhook).
webhookCall.reply{ status?, body? }nullWhat the POST receives back. Empty means { resumed, eventId, action } — resumed is false (with test: true and a note) for a Run Test wait, which never runs downstream.
pageConfig{ title?, description?, fields? }{}The approval page; fields is up to ten { label, key } rows, key being an expression.
timeoutMinutesnumber1440How long to wait before the auto action.
autoActionOnTimeoutstringrejectapprove | reject.
requireIdentitybooleanfalseRequire sign-in on the approval page.
requireCommentbooleanfalseThe approval page refuses a decision without a comment (the dashboard table always requires one).
allowedApproverIdsstring[][]Dashboard users allowed to decide; empty means anyone in the organization. Applies to dashboard decisions, which are the ones with a signed-in user.
inputNodes / outputNodes{ nodeType, nodeId, port? }[][]Wiring. Outputs use port main (approved) or rejected.

The node's JSON never contains the webhook-call secret; it only says webhookCall.hasSecret. Reveal or regenerate it with the two secret routes below.

bashCreate an Approval Gate decided by webhook call
curl -X POST /api/approval-nodes \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{
    "workspaceId": "<your-workspace-id>",
    "name": "High-Value Transaction Review",
    "provider": "webhook",
    "webhookCall": { "decisionField": "action", "reply": { "status": 200, "body": { "ok": true } } },
    "timeoutMinutes": 60,
    "autoActionOnTimeout": "reject",
    "inputNodes": [{ "nodeType": "webhook", "nodeId": "6654a1b2c3d4e5f6a7b8c9d0" }]
  }'
MethodRouteDescription
GET/api/approval-nodesList approval gates
POST/api/approval-nodesCreate an approval gate
GET/api/approval-nodes/:idGet one
PATCH/api/approval-nodes/:idUpdate one
DELETE/api/approval-nodes/:idDelete one
POST/api/approval-nodes/:id/testRun Test: create a test wait (and send the email, with email providers)
GET/api/approval-nodes/:id/webhook-secretReveal the gate's webhook-call secret
POST/api/approval-nodes/:id/webhook-secretRegenerate it; the previous value stops working at once
GET/api/approval-nodes/pendingPending waits of the organization, each with its resumeUrl
GET/api/approval-nodes/historyAll waits with filters (status, search, approvalNodeId), each with its resumeUrl
POST/api/approval-nodes/pending/:id/approveApprove a wait from the dashboard, body { comment }
POST/api/approval-nodes/pending/:id/rejectReject a wait from the dashboard, body { comment }
GET/api/a/:tokenThe resume URL: approval page for a browser, JSON for a machine
POST/api/a/:tokenThe resume URL: the page's form, or a machine's decision
✦Put a Filter or a Conditional in front of the gate so only the events that need a human wait for one — transactions over a threshold, new customers, flagged content — while the rest continue on their own.
⚠A decision is final. A rejected event cannot be approved later; replay it from the source if it needs another pass.