#Approvals in n8n
Put a person in front of anything an n8n workflow is about to do: send an email, issue a refund, post to a customer. Ratifia asks the people in your policy, waits for their answer, and records who decided and why. The workflow continues on their answer.
No public URL needed. Every call goes out from n8n to Ratifia, so this works on n8n Cloud, self-hosted n8n, or n8n on your laptop, with no tunnel and no webhook for Ratifia to reach.
#Two ways to call Ratifia
The Ratifia node (n8n-nodes-ratifia, a community node). One node asks and waits; two more check the answer before you act. Install it from Settings → Community nodes on a self-hosted n8n. n8n Cloud runs only community nodes that n8n has verified, and this one is not verified yet, so on Cloud use HTTP Request nodes. The source is at github.com/themillhouse/ratifia-n8n.
HTTP Request nodes. The same calls with only built-in nodes, for instances where you can't install community nodes. Use a Header Auth credential with name Authorization and value Bearer <your API key>.
Either way you need an API key (dashboard, API Keys) and a policy. The key belongs to one organization, so it decides whose policies and approvers apply.
#See it before you build with it
Set the node's operation to Send a Test Decision, put your own address in Send To, and hit Execute step. It creates one real decision with sample facts, a source link and an editable draft, so you can see every panel a reviewer gets. The output carries the url. It expires by itself after 15 minutes and nothing acts on the verdict either way.
#Examples to import
Each example is an AI-drafted reply to a customer that nobody wants sent unread.
| Workflow | What it shows |
|---|---|
| approval-gate-node.json | The Ratifia node: ask, wait, then verify and consume before sending |
| approval-gate-http.json | The same with HTTP Request nodes only |
| send-customer-email.json | A protected send: a workflow of its own that refuses any email not approved |
| ask-then-hand-off.json | Asks for approval, then calls the protected send |
| try-to-get-around-it.json | Hands the protected send six emails it should mostly refuse |
| send-customer-email-node.json | The protected send with the Ratifia node, three nodes shorter |
| ask-then-hand-off-node.json | Asks with a Draft for Review the reviewer can rewrite, then hands off |
Import with Workflows → Import from file, then set the credential and change the policy key (customer-replies) to one of yours.
#How the gate works
#Your workflow decides when, your policy decides who
Put an IF in front of the approval if only some cases need a person: large refunds, first-time recipients, anything with an attachment. That rule is yours and lives in the workflow. The policy you name decides who is asked, how many must agree, and where they answer. Change the approvers in the dashboard; the workflow doesn't change.
Name a policy by its key. An unknown key is refused with 422 unknown_policy and nothing is created, so a typo fails loudly instead of skipping review.
#Asking and waiting
Ratifia node: Request Approval and Wait, with a question, a policy, and Facts (below). It polls until there is an answer, then outputs the decision with approved, rejected and expired flags to branch on.
HTTP: POST /v1/worker/decisions with engine: "api", your policy, a factsHash, and what the reviewer should read in context. Then poll GET /v1/worker/decisions/:id every 10 to 30 seconds until status is no longer PENDING.
Handle every outcome, not just approve and reject:
status | Meaning |
|---|---|
APPROVED | Approved. Still check it at the action (below). |
REJECTED | Someone said no. |
EXPIRED | Nobody answered before the decision's timeout. |
CANCELLED | The request was withdrawn. |
A waiting execution stays open until someone answers. That's cheap, but not free, so give decisions a timeout that matches how fast your reviewers reply.
When no approval was needed, because you turned Required off or the policy decided this case doesn't need review, the item passes straight through with approvalNeeded: false and approved: true. Nothing reached a person, and the workflow carries on.
#Asking for more than yes or no
Answer Type changes what the reviewer is asked for. Choice gives them options you define, each with an ID your workflow branches on; Typed Answer asks for a value in their own words. Either way the answer comes back on responseValue.
Draft for Review takes text the reviewer can rewrite before approving: the reply you intend to send. The approved text comes back as output, which is their edit when they made one. Send output, not what you drafted. Over HTTP these are the responseSpec and proposedOutput fields, and the read returns responseValue, refinedOutput and output.
#Linking to the record instead of copying it
Records takes the records this decision is about, a label and a URL per row, shown to the reviewer as links: the ticket, the customer, the invoice.
If a record holds personal data, link to it rather than copying values into Data. The system that owns the record is the one that logs who read it and can delete it later. See Sensitive data and PII.
#Checking the approval at the action
Branching on APPROVED and then sending isn't enough on its own. It trusts whatever came before, it can't tell whether the email changed after approval, and a retry sends twice.
So bind the approval to the exact action, and check it where the action happens:
- When you ask, pass the facts that matter: for an email, the recipient, subject, body and attachments. Only a SHA-256 hash of them is sent.
- Right before you act, build the facts again from what you are about to send, then verify and consume the approval.
- Act only on a fresh consume. Skip a retry, refuse anything else.
#What to bind when the reviewer can edit
If you send a Draft for Review, do not bind the text they are allowed to rewrite. An edited body is a changed body, so an approval that bound it would refuse the very edit it asked for, with facts_mismatch.
Bind the things that must not change — recipient, attachments, usually subject, and any figure the decision turns on — then send the reviewer's version from output.
Pull those figures out of the prose. A refund amount written only inside the body is not a fact: the body is the editable part, so nothing binds it, no context contract can require it, and the reviewer reads it in a sentence instead of seeing it as a value with a source. Give it its own row. The body needs no hash, because it is the approved artifact: whatever comes back on output is what a person signed off.
Where correctness depends on the outside world, read it again at the action. Rebuilding the facts from the arguments you were handed proves only that the action hasn't changed since approval. A caller that claims a refund line is $100 when the receipt says $1,000, and claims it at both ends, passes every check. If the protected workflow re-reads the receipt and binds that amount, the lie is refused.
With the Ratifia node, use the Verify Approval and Consume Approval operations and give them the same facts; the node hashes them for you. With HTTP, hash with n8n's Crypto node (SHA256, hex) over the facts as JSON with keys in alphabetical order, then call GET /v1/worker/decisions/:id/verify?factsHash=… and POST /v1/worker/decisions/:id/consume with { factsHash, consumer }.
Hashing it yourself: flat facts, keys typed in order
Two things quietly break the hand-rolled version.
JSON.stringify keeps the order you typed the keys in — it does not sort them. The example workflows only match because the keys are written alphabetically in the expression; reorder them and the hash changes.
And Ratifia sorts keys at every level, while the expression only orders the top one. So this recipe is correct for flat facts only. Nest an object and the two hashes diverge — you'll get facts_mismatch at the gate, the action will silently never run, and it will look like a rejected approval rather than a hashing bug.
Keep HTTP facts flat, or use the Ratifia node and let it hash them for you.
consumer names this one action. Derive it from the facts (the examples use send-email:<hash>), so a retry of the same email always presents the same key.
What you get:
| At the action | Result |
|---|---|
| The approved email | Consumed, sent |
| The approved email, edited after approval | facts_mismatch, not sent |
| The approved email again | alreadyConsumed: true, not sent twice |
| A rejected, missing or unknown decision | not_approved / not_found, not sent |
See Approvals that can't be skipped for the same pattern in code.
#Keep the action where only the check can reach it
For the strongest version, put the action in a workflow of its own and keep its credential there and nowhere else. Other workflows hand it the email and a decisionId; it rebuilds the facts from its own arguments and refuses anything not approved. Upstream steps can then be reordered, skipped or rewritten, by a person or by an AI editing the workflow, and the email still can't go out without an approval for exactly that email. The send-customer-email example is built this way.