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.
Ask for a decision
Section titled “Ask for a decision”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.
What you get back
Section titled “What you get back”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.
What makes two approvals the same
Section titled “What makes two approvals the same”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.
Drive the whole loop with an API key
Section titled “Drive the whole loop with an API key”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.
export URAI=https://chat.app.urai.devexport KEY=sk-urai-...1. Start a run
Section titled “1. Start a run”curl -X POST $URAI/api/workflows/{workflow_id}/runs \ -H "authorization: Bearer $KEY" \ -H 'content-type: application/json' \ -d '{"args": {"org_id": "org_123"}}'2. Find what it waits on
Section titled “2. Find what it waits on”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.
3. Decide
Section titled “3. Decide”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.
4. Read the result
Section titled “4. Read the result”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 decision happens exactly once
Section titled “A decision happens exactly once”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.
Who can decide
Section titled “Who can decide”| 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.
Notifications
Section titled “Notifications”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.
Approve during local development
Section titled “Approve during local development”The CLI drives the same park-and-resume loop, so you can exercise both branches before you deploy.
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.
Things that will bite you
Section titled “Things that will bite you”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.