Approval block
Configure the Approval block: approvers, channels, timeout and escalation, plus the API call that records a decision on a request.
The Approval block (type: "approval") pauses the run until a named approver approves or rejects the request, then continues or stops. For the plain-language guides, see Add an approval step and Approve or reject a request.
When to use it
- Sign-off on a payment, a vendor bill over a limit, or a journal entry before it is posted.
- A review point after an AI Agent step, before anything is written to the ledger.
While the step waits, the run's status is waiting_approval and the request appears in Studio under Approvals.
Configuration
Field (config key) | Editor label | Type | Required | Meaning |
|---|---|---|---|---|
approvers | Approvers | string array | Yes | Email addresses of the people who may decide. The editor takes a comma-separated list. |
reason | Reason | string | Yes | Text shown to approvers in every channel. Template references are resolved, for example Approve bill {{steps.extract.structured.billNumber}}. |
notifyChannels | Notification channels | array | No | How approvers are told. See below. With no channel, nobody is notified and the request is decided in Studio or by API. |
timeout | Timeout | string | No | How long to wait: a number and d, h, m or s, such as 7d or 24h. Default 7d. |
timeoutAction | Not shown | string | No | What happens at the timeout: fail (default), reject, approve or escalate. |
escalateTo | Escalation Approvers | string array | For escalate | Who receives the request when it times out with timeoutAction: "escalate". |
escalationTimeout | Not shown | string | No | How long the escalated request waits. Defaults to timeout. |
gracePeriod | Not shown | string | No | Extra time after the timeout before timeoutAction applies, such as 2h. |
reminders | Not shown | object | No | { "before": ["24h", "1h"], "channels": ["email"] }: reminders sent that long before the timeout. Channels are email (default) and slack. |
context | Not shown | object | No | Label and value pairs shown to approvers, such as { "Vendor": "{{input.vendorName}}" }. |
approveLabel, rejectLabel | Not shown | string | No | Button text. Defaults Approve and Reject. |
decisionMessage | Slack message after decision | string | No | replace (default) swaps the Slack request for a short decision note. keep_request keeps it, removes the buttons and adds the decision. |
decisionMessageText | Text added after decision | string | No | Text added to the end of the Slack message after a decision. |
requireAll | Require All Approvers | boolean | No | Reserved. Ignored today: the first decision from any approver applies. |
Escalation Approvers has an effect only with timeoutAction: "escalate", which the editor does not set. Set it in the workflow definition. Escalation goes one level: if the escalated request also times out, the step fails.
Notification channels
| Channel | Shape | Behaviour |
|---|---|---|
{ "type": "email", "instructions": "...", "subject"?, "bodyHtml"?, "sender"? } | One email per approver. instructions tells the AI how to write the email. A subject or bodyHtml you set is used instead of the AI draft. sender overrides the from address. | |
| Slack | { "type": "slack", "channel": "C12345678", "threadTs"? } | One request posted in the channel. Only the listed approvers can approve or reject from Slack. |
If channels are set and the request reaches no approver, the request is closed and the step fails, naming why.
Decisions
- The first decision from any approver applies; other approvers' links stop working.
- In Studio, a person who is not a listed approver cannot decide, except a company owner or admin. Their decision is recorded as an override in the Audit trail.
- Approve: the step succeeds and the run continues.
- Reject: the step fails and the run stops with
Approval step "<name>" was rejected. To branch on a rejection instead, set"errorHandling": { "strategy": "continue" }on the step and testdecision.approvedin a later Condition. - Timeout:
failfails the step;rejectrecords an automatic rejection bysystem;approverecords an automatic approval bysystem;escalatesends a new request toescalateTo.
Output
| Field | Type | Meaning |
|---|---|---|
decision.approved | boolean | true when approved. |
decision.decidedBy | string | The approver's email, api_key:<id> for an API decision, or system for a timeout action. |
decision.decidedAt | string | ISO timestamp of the decision. |
decision.reason | string | The comment given with the decision. |
timedOut | boolean | true when the timeout decided the step. |
durationMs | number | Time from request to decision. |
approval | object | The approval record, including id, status, approvers and requestedAt. |
Read the decision as {{steps.<step id>.decision.approved}}. The editor's output list shows approved, approvedBy, approvedAt and comment; the executor returns the fields above.
Example
{
"id": "controller_approval",
"type": "approval",
"name": "Controller approves payment run",
"config": {
"approvers": ["controller@example.com", "cfo@example.com"],
"reason": "Payment run of {{steps.build_run.result.total}} across {{steps.build_run.result.count}} vendors",
"context": { "Pay date": "{{input.payDate}}" },
"timeout": "2d",
"timeoutAction": "escalate",
"escalateTo": ["cfo@example.com"],
"escalationTimeout": "1d",
"reminders": { "before": ["4h"], "channels": ["email"] },
"notifyChannels": [
{ "type": "email", "instructions": "Write a short, plain email asking the approver to review this payment run." },
{ "type": "slack", "channel": "C12345678" }
]
}
}Record a decision by API
Find pending requests with GET /api/v1/approvals (workflows:read scope). With no status filter it returns pending requests; each item's id is the approval id. Then post the decision:
curl -X POST https://workflow.loopfour.ai/api/v1/approvals/<approval id>/decide \
-H 'x-api-key: wfk_live_your_api_key' \
-H 'Content-Type: application/json' \
-d '{ "approved": true, "reason": "Checked against the remittance" }'The key needs the approvals:decide scope (workflows:write is also accepted). An API decision is recorded with decidedBy set to api_key:<key id>.
{
"success": true,
"data": {
"approvalId": "3f6c2b1e-8a4d-4f1b-9c7e-2d5a6b8c9e01",
"status": "approved",
"decidedAt": "2026-11-12T17:04:11.000Z",
"decidedBy": "api_key:7b1dā¦",
"reason": "Checked against the remittance"
}
}| Status | error.code | When |
|---|---|---|
| 404 | APPROVAL_NOT_FOUND | No approval with this id in your company. |
| 400 | APPROVAL_ALREADY_DECIDED | The request is no longer pending. |
| 400 | APPROVAL_EXPIRED | The decision deadline has passed. |
| 403 | NOT_AN_APPROVER | A signed-in user who is not an approver, owner or admin. |
| 409 | RUN_NOT_WAITING | The run has ended, or the decision could not be recorded. |
| 500 | APPROVAL_ERROR | The decision was recorded but the run could not be resumed. |
See Runs API for the run endpoints.
Loopfour