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.
| Option | What happens |
|---|---|
| Native | HostWebhook sends the notification email from its own address. Nothing to connect. |
| Gmail | The 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 call | No 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
| Section | What it does |
|---|---|
| Notify Emails | Up to two addresses. Each gets its own copy of the email with the Approve and Reject links. |
| Email Subject | Template. 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:
| Expression | Value |
|---|---|
| {{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 |
{{ }} 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.javascript:, data:, tel:…) becomes #. Images and other sources (src) take only http and https.Config tab — Webhook call
| Section | What it does |
|---|---|
| Resume URL | The 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. |
| Secret | The 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. |
| Decision | Which field of the POST body carries the decision. Default action. Dots reach nested fields (result.verdict). |
| Reply to the caller | What 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.
| Section | What it does |
|---|---|
| Page Title | Heading of the page. Empty means the gate's name. |
| Description | One line under the title. Templates allowed. |
| Display Fields | Up 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
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.
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.
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.
Where the link can go
The node that carries the link has to run while the gate is waiting. Two shapes do that:
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.
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.
Try it from Postman
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.
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.
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
curl "<resume url>" \
-H "Accept: application/json" \
-H "X-HostWebhook-Approval-Secret: hwa_…"{
"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:
| Field | What 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. |
| comment | Stored as the decision's comment, when present. |
| anything else | Passed through untouched, inside payload._approval. |
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:
{
"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:
{
"error": "decision_missing",
"field": "action",
"message": "The body has no "action". Send "approve" or "reject" in it; nothing was resumed."
}Status codes
| Code | When |
|---|---|
| 401 | The secret header is missing or wrong |
| 403 | The gate is not in Webhook call mode |
| 202 | GET before the run has reached the gate (nothing to inspect yet) |
| 409 | POST on a wait already decided — the first decision stands |
| 410 | The wait expired; the auto action already ran |
| 400 | The 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.
{
"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"
}
}| Field | Meaning |
|---|---|
| action | approve or reject |
| resolvedVia | manual (email link, page or dashboard), webhook (a POST to the resume URL), timeout (the auto action) |
| resolvedBy / resolvedByEmail | The 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 |
| comment | What the decider wrote, or null |
| …caller fields | With 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
| Field | Type | Default | Description |
|---|---|---|---|
| name | string | — | The gate's name. Also what $resumeUrl("…") looks up. |
| isActive | boolean | true | Paused (false) stops the run at the gate; no wait is created. |
| provider | string | native | native | gmail | webhook — the Decided via choice. |
| credentialId | string | null | null | The connected Google account, with provider gmail. |
| notifyEmails | string[] | [] | Up to two addresses (email providers only). |
| emailSubject | string | "" | Template; empty means the default subject. |
| emailBody | string | "" | HTML template; empty means the default layout. |
| webhookCall.decisionField | string | action | Field of the POST body with the decision (provider webhook). |
| webhookCall.reply | { status?, body? } | null | What 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. |
| timeoutMinutes | number | 1440 | How long to wait before the auto action. |
| autoActionOnTimeout | string | reject | approve | reject. |
| requireIdentity | boolean | false | Require sign-in on the approval page. |
| requireComment | boolean | false | The approval page refuses a decision without a comment (the dashboard table always requires one). |
| allowedApproverIds | string[] | [] | 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.
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" }]
}'| Method | Route | Description |
|---|---|---|
| GET | /api/approval-nodes | List approval gates |
| POST | /api/approval-nodes | Create an approval gate |
| GET | /api/approval-nodes/:id | Get one |
| PATCH | /api/approval-nodes/:id | Update one |
| DELETE | /api/approval-nodes/:id | Delete one |
| POST | /api/approval-nodes/:id/test | Run Test: create a test wait (and send the email, with email providers) |
| GET | /api/approval-nodes/:id/webhook-secret | Reveal the gate's webhook-call secret |
| POST | /api/approval-nodes/:id/webhook-secret | Regenerate it; the previous value stops working at once |
| GET | /api/approval-nodes/pending | Pending waits of the organization, each with its resumeUrl |
| GET | /api/approval-nodes/history | All waits with filters (status, search, approvalNodeId), each with its resumeUrl |
| POST | /api/approval-nodes/pending/:id/approve | Approve a wait from the dashboard, body { comment } |
| POST | /api/approval-nodes/pending/:id/reject | Reject a wait from the dashboard, body { comment } |
| GET | /api/a/:token | The resume URL: approval page for a browser, JSON for a machine |
| POST | /api/a/:token | The resume URL: the page's form, or a machine's decision |