Human Approval
Category: Operations
This page is generated from the Air Pipe marketplace. Browse it live to install into your organization.
Work that needs a person to say yes.
A refund over a threshold, a contract going out, a deploy to production. The flow runs up to the decision and then stops. The decision might arrive in ten seconds or next Tuesday, and nothing should be holding a request open in between.
How it works
- name: Ticket
json_output: '{"token": "a|uuid|"}' # the credential for this one approval
- name: AskApprover
run_when_succeeded: [Ticket] # the token must exist before it is mailed
email: # plus `smtp:` -- see approval.yml
from: a|var::approver_from|
to: a|var::approver_email|
subject: Approve this refund
text: 'Review: a|var::public_base|/_ap/resume/a|org_uuid|/a|Ticket::token|?approved_by=a|var::approver_email->url_encode|'
- name: Approval
run_when_succeeded: [AskApprover]
wait_for_callback:
token: a|Ticket::token|
timeout: 7d
- name: Refund
run_when_succeeded: [Approval] # unreachable unless somebody approved
The caller gets 202 and a run id straight away. The run is parked in the durable store —
not a held connection, not a thread — so a restart does not lose it and a week-long wait costs
one row.
The approver opens the link and gets a short page with a Confirm button. Opening the link
changes nothing — mail scanners and chat link previews open links on their own, so only the
button answers. Pressing it resumes the run, and what the link carried becomes the approval's
output: a|Approval::approved_by| above reads the approved_by in the link. A program can
instead POST JSON to the same link, and that body becomes the output.
The parts worth getting right
You mint the token. It is the whole credential — anyone holding it can approve — so use
a|uuid|, never something guessable like the order number. Same reasoning as a password-reset
link. Wait on the action that mints it: a token that does not resolve renders as null, and
wait_for_callback refuses it rather than parking a run anyone could resume.
The organisation is in the URL and is not a secret. It already appears in every managed
route. It is there so a token guessed in one organisation cannot reach a run in another. Build
the link with a|org_uuid| and it is correct wherever the config runs.
A deadline that passes is a failure, not an approval. When timeout expires the waiting
action fails, so everything gated on it with run_when_succeeded does not run. If it
completed quietly instead, an ignored request would issue the refund by itself.
Every wrong link looks the same. Unknown token, already used, expired, another
organisation's — all return a bare 404, because different answers would tell someone
guessing which tokens exist.
The refund request is built from what people sent. The order id comes from the requester
and approved_by from whoever confirms, so inside the JSON body they are written
"a|body::order_id->json_escape|", and the amount a|body::amount->to_number|. Pasted bare,
an amount of 4200, "order_id": "Z-9999" refunded a different order and an approved_by of
x", "amount": 999999, "y": " changed the amount; now the first sends no refund and the second
arrives as plain text.
Files
| File | What it is |
|---|---|
approval.yml | The real thing: emails the approver, refunds on approval. Copy this one. |
demo.yml | The same flow without the email, so it runs with no SMTP: the link is in the parked run (see below). |
Variables
| Variable | Meaning |
|---|---|
PUBLIC_BASE_URL | Where this engine is reachable, so the link resolves for the recipient |
APPROVER_EMAIL | Who receives approval requests |
APPROVER_FROM | The address approval requests are sent from |
SMTP_SERVER, SMTP_USER, SMTP_PASS | The mail server approval.yml sends through (port 587, STARTTLS) |
DATABASE_URL | Used by demo.yml to record approved refunds |
Trying it
export AIRPIPE__DATABASE_URL='postgresql://user:pass@localhost:5432/yourdb'
airpipe server --api-key <key> --config-dir . --port 44111
curl -X POST http://localhost:44111/demo/refunds/request \
-H 'content-type: application/json' -d '{"order_id":"A-1001","amount":4200}'
# -> {"deferred": true, "run_uuid": "...", "resume_in_secs": 3600}
# the answer is the run id only; the link is in the parked row:
# SELECT '/_ap/resume/' || org_uuid || '/' || callback_token FROM ap_deferred_runs;
# as a person: open it in a browser, with what to send, and press Confirm
# http://localhost:44111/_ap/resume/<org>/<token>?approved_by=finance-lead
# as a program:
curl -X POST "http://localhost:44111/_ap/resume/<org>/<token>" \
-H 'content-type: application/json' -d '{"approved_by":"finance-lead"}'
# -> {"resumed": true} and the refund is now recorded
Adapting it
- Approve and reject — send two links that differ only in a parameter
(
?decision=approve,?decision=reject) and branch after the wait ona|Approval::decision|, instead of treating any answer as a yes. - Two approvals — chain a second
wait_for_callbackwith its own token. The run parks again; it costs another row, not another connection. - Approval in your own UI — let your application mint the token (an unguessable one, such
as a uuid) and send it in the request: use
token: a|body::token|, build the link from it, and have the application POST to the link when its user decides. - A shorter deadline —
timeoutbounds how long the row lives. Pick the shortest window the humans can realistically meet, because that is also how long the work stays pending.
Requirements
A durable run store. Managed has one; self-hosted needs AIRPIPE__DATABASE_URL. Without one
the wait is refused rather than becoming an in-process pause a restart would lose.
There is a ceiling on how many runs one organisation may hold parked — 1,000 on managed by default, raisable on request. At the ceiling a new suspension is refused rather than queued silently.
Configuration
approval.yml
name: HumanApproval
docs: true
# Work that needs a person to say yes.
#
# A refund over a threshold, a contract going out, a deploy to production: the flow runs up to
# the decision, then stops until somebody decides. The decision might come in ten seconds or
# next Tuesday, and nothing should be holding a request open in the meantime.
#
# The run is parked in the durable store. Whoever holds the link resumes it, and what they post
# becomes the approval's output — so the actions after it can read who approved and why.
#
# Needs a durable run store: managed has one, self-hosted needs AIRPIPE__DATABASE_URL.
global:
variables:
# Where this engine is reachable, so the approval link resolves for whoever receives it.
public_base: "a|ap_var::PUBLIC_BASE_URL|"
approver_email: "a|ap_var::APPROVER_EMAIL|"
# Who the approval request comes from. Required by the email action, and worth being a
# variable rather than hard-coded so a fork does not send as somebody else's domain.
approver_from: "a|ap_var::APPROVER_FROM|"
# The mail server the request is sent through (submission port 587, STARTTLS).
smtp_server: "a|ap_var::SMTP_SERVER|"
smtp_user: "a|ap_var::SMTP_USER|"
smtp_pass: "a|ap_var::SMTP_PASS|"
interfaces:
# ── Ask for approval, then wait ───────────────────────────────────────────
refunds/request:
output: http
method: POST
summary: Request a refund and wait for a human to approve it
description: >
Answers 202 immediately with a run id; the refund itself only happens once somebody
approves. If nobody does before the deadline the run fails and no refund is issued.
tags: [approval]
request_example: '{"order_id": "A-1001", "amount": 4200, "reason": "damaged in transit"}'
actions:
# The token is the only credential for resuming this run, so it is a uuid rather than
# anything guessable like the order id.
- name: Ticket
json_output: '{"token": "a|uuid|"}'
- name: AskApprover
run_when_succeeded: [Ticket]
email:
smtp:
server: a|var::smtp_server|
port: 587
user: a|var::smtp_user|
pass: a|var::smtp_pass|
from: a|var::approver_from|
to: a|var::approver_email|
subject: 'Approve refund a|body::order_id| — a|body::amount| cents'
text: |
Order: a|body::order_id|
Amount: a|body::amount|
Reason: a|body::reason|
Review and approve (the link opens a page with a Confirm button):
a|var::public_base|/_ap/resume/a|org_uuid|/a|Ticket::token|?approved_by=a|var::approver_email->url_encode|
Nothing happens if you ignore this. The request expires in 7 days and the refund
is not issued.
# The run stops here. It resumes when the approver presses Confirm on the page the link
# opens (opening the link alone changes nothing), and what the link carried -- here
# approved_by -- becomes this action's output. A program can POST JSON to it instead.
- name: Approval
run_when_succeeded: [AskApprover]
wait_for_callback:
token: a|Ticket::token|
timeout: "7d"
# Gated on the approval, so an expired request cannot reach it.
# The body is built from what the requester and the approver sent, so their text is
# JSON-escaped and the amount must be a number: neither can add or override a field in the
# request the payment provider receives (an amount of `1, "order_id": "X"` refunds nothing).
- name: Refund
run_when_succeeded: [Approval]
http:
url: a|var::public_base|/mock/payments/refund
method: POST
headers:
content-type: application/json
body: '{"order_id": "a|body::order_id->json_escape|", "amount": a|body::amount->to_number|, "approved_by": "a|Approval::approved_by->json_escape|"}'
# ── Stand-in for a payment provider, so the pack runs on its own ──────────
mock/payments/refund:
output: http
method: POST
summary: Stand-in payment provider
tags: [demo]
actions:
- name: Charge
input: a|body|
json_output: '{}'
hide_data_on_success: true
response_on_success:
http_code: 200
headers:
content-type: application/json
body: '{"refunded": true, "order_id": "a|Charge::order_id->json_escape|"}'
demo.yml
name: HumanApprovalDemo
docs: true
# The same approval flow with the email step removed, so the pack can be run with no SMTP.
# Instead of sending the link, this returns it — which is also the shape you want when the
# approval lives in your own UI rather than an inbox.
interfaces:
demo/refunds/request:
output: http
method: POST
summary: Request a refund and get back the link that approves it
tags: [demo]
request_example: '{"order_id": "A-1001", "amount": 4200}'
actions:
- name: Ticket
json_output: '{"token": "a|uuid|"}'
# Where a real config emails the link, this records it. The run parks either way, so the
# link has to be built BEFORE the wait — which is why the token is minted in its own
# action rather than by the wait itself.
- name: Link
run_when_succeeded: [Ticket]
json_output: '{"approve_url": "/_ap/resume/a|org_uuid|/a|Ticket::token|"}'
- name: Approval
run_when_succeeded: [Link]
wait_for_callback:
token: a|Ticket::token|
timeout: "1h"
- name: Refund
run_when_succeeded: [Approval]
database: main
query: INSERT INTO ap_approval_demo (order_id, approved_by) VALUES ($1, $2)
params:
- a|body::order_id|
- a|Approval::approved_by|
global:
databases:
main:
driver: postgres
conn_string: "a|ap_var::DATABASE_URL|"