Skip to content

Human approvals

Most workflows run unattended. Some should not. A refund above a threshold, a message to every customer, an invoice write-off: the work automates cleanly up to the moment somebody has to say yes.

awaitApproval() is that moment. The run stops and waits in the database. Nothing runs, nothing holds a connection, and nothing times out. A run can wait for days. Someone decides over the API, and the run continues from where it stopped with the decision in hand.

The reconciliation workflow from Write a workflow writes off invoices only after a person agrees.

const decision = this.awaitApproval("write-off", {
invoices: toWriteOff.map((invoice) => invoice.number),
total_cents: toWriteOff.reduce((sum, i) => sum + i.amount_cents, 0),
});
if (!decision.approved) {
this.complete({
chased: toChase.length,
written_off: [],
why: decision.comment,
});
return;
}
this.runStepMany(
this.writeOff,
toWriteOff.map((invoice) => ({ invoice_number: invoice.number })),
);

The second argument is what the approver sees. Put in what a person needs to make the call, and nothing you would not want in the audit record. It is also the approval’s identity, which the next section explains.

A rejection is a decision, not a failure. awaitApproval() returns { approved: false, ... } rather than throwing, so you write the branch yourself: notify the claimant, route to a second approver, or finish with a different result.

Never wrap awaitApproval() in try/catch. Like runStep() and complete(), it signals by throwing a value the host must see.

interface ApprovalDecision {
approved: boolean;
approver: string | null; // who decided, as reported by the caller
decidedBy: string | null; // the authenticated subject that recorded it
comment: string | null;
data: unknown; // optional structured payload
decidedAt: string; // ISO-8601
}

approver and decidedBy differ when a system records a decision for someone. The app sets approver to the person who selected the button and decidedBy to the service that relayed it. Read approver as who decided, and decidedBy as what recorded it.

An approval is identified by its name and its request object together.

The coordinator replays on every tick, so awaitApproval() runs again each time the run wakes up. It resolves to the same approval, rather than raising a new one, because the name and the request match a record that already exists.

Three consequences follow:

  • A changed request is a different approval. If you edit the workflow so it passes a different object, a run that is already parked raises a second approval on its next tick. Do not reshape a request that live runs wait on.
  • One name with different requests is several approvals. This is useful, for example one approval per line item. Each is decided on its own, and the run raises the next one only after the previous one is decided.
  • Keep requests small and stable. A timestamp or a random id in the request makes every tick look like a new approval.

The example above builds its request from memoized step results, so the request is the same on every replay.

One org API key covers the run-and-approve loop: start the run, see what it waits on, decide it, and read the result. None of it needs a browser session.

Terminal window
export URAI=https://chat.app.urai.dev
export KEY=sk-urai-...
Terminal window
curl -X POST $URAI/api/workflows/{workflow_id}/runs \
-H "authorization: Bearer $KEY" \
-H 'content-type: application/json' \
-d '{"args": {"org_id": "org_123"}}'
Terminal window
curl $URAI/api/workflows/runs/{run_id}/approvals \
-H "authorization: Bearer $KEY"
[
{
"id": "3a7e...",
"run_id": "0f1c...",
"name": "write-off",
"request": { "invoices": ["INV-2041", "INV-2088"], "total_cents": 412000 },
"status": "awaiting",
"decision": null,
"decided_by": null,
"created_at": "2026-08-11T09:16:03Z",
"decided_at": null
}
]

request is the object your workflow passed. status is awaiting until someone decides, then decided.

An empty list means the run has not reached the call yet. The steps before it have to finish first. Poll again rather than assuming the run has no approval.

Terminal window
curl -X POST $URAI/api/workflows/approvals/{approval_id}/decision \
-H "authorization: Bearer $KEY" \
-H 'content-type: application/json' \
-d '{"approved": true, "comment": "Both disputed for over a year"}'
Field Required Notes
approved yes true or false. Both resume the run.
comment no Free text, given to the workflow and kept in the record.
data no Arbitrary JSON, arrives as decision.data.

The run resumes at once. To reject, send {"approved": false, "comment": "..."} and the run takes your rejection branch.

Use data when an approver amends rather than simply agrees:

{ "approved": true, "comment": "Write off the smaller one only", "data": { "only": ["INV-2088"] } }

Your workflow has to read decision.data. Nothing applies it for you.

Terminal window
curl $URAI/api/workflows/runs/{run_id} -H "authorization: Bearer $KEY"
{
"id": "0f1c...",
"status": "completed",
"result": { "chased": 4, "written_off": ["INV-2041", "INV-2088"] },
"error": null,
"completed_at": "2026-08-11T09:22:40Z"
}

A rejected run completes too, with whatever your rejection branch returned. status: "completed" means the workflow ran to the end. It does not mean the person agreed.

A second decision on the same approval returns 409.

This guards two people selecting Approve at the same moment, and it makes a retry safe. If a decision call fails with a network error, retry it: a 409 tells you the first attempt landed. Read the approval back to see which way it went.

Credential Can it decide?
Signed-in session Yes.
Org API key (sk-urai-...) Yes. A project-scoped key reaches only runs of workflows in its project.
Webhook trigger secret No.

A webhook secret starts runs and polls their status. It cannot list or decide approvals. A system that must decide needs an org API key.

You do not have to poll. When a run parks, the person who started it receives a workflow_approval_requested event on their notification channel, and the app shows a link to the run.

Poll the approvals endpoint for anything unattended. A notification is a live nudge. The database is the record.

The CLI drives the same park-and-resume loop, so you can exercise both branches before you deploy.

Terminal window
uraijs workflow run ReconcileInvoices -a '{"org_id":"org_123"}'
uraijs workflow approve --id 8f2c... --name write-off --comment "ok"

Add --reject to take the other branch.

Everything before the approval has already happened. The reminders in the example are sent by the time a person sees the request. Put the irreversible action after the approval. An approval rolls nothing back.

A parked run reports running. Check the approvals endpoint to learn whether the run is working or waiting.

A parked run never times out. If your process needs a deadline, cancel the run yourself with POST /api/workflows/runs/{run_id}/cancel. Cancelling needs a signed-in person, so build the deadline into a process that has one.

A long wait outlives a credential. A parked run keeps its secret access, and it resolves each secret fresh when the step runs. That is usually what you want. It also means an integration disconnected during the wait fails at the step, not at the approval.