Cross-posted from raposa.group.
The failure we are designing against
An agent that can call a refund or payout API will, sooner or later, call it with the wrong number. The causes are boring and well documented: a hallucinated amount, a tool called twice after a retry, an instruction smuggled in through a ticket body or a web page. None of them look like an attack from inside the agent loop — the model is confident every time.
The usual first fix is to ask the model to double-check itself ("are you sure this refund is correct?"). That is not a control. The same model that produced the number is judging it, and a prompt injection reaches both steps. The second fix — a hard-coded threshold that blocks anything above €100 — is a control, but a blunt one: it stops the agent from being useful exactly on the cases where it would save the most time.
What finance teams actually ask for is simpler to state: a named person presses Approve before money moves, and afterwards we can prove who it was.
The pattern
Put a gate between "the agent decided to pay" and "the payment API was called". The gate has exactly three outcomes, and your code has to handle all three explicitly:
| Outcome | What the agent does | Why |
|---|---|---|
| A human approved | Proceed. Store the approval id next to your own transaction record. | The id is the link between your ledger and the audit trail. |
| Rejected, or the request expired | Do not proceed. Tell the user why. | A rejection is information for the customer conversation, not a silent failure. |
| Nobody answered before your timeout | Do not proceed. | Silence is not consent. Most home-grown gates get this wrong and fall through to "approved". |
Three more rules that separate a gate from a prompt:
- The approver is outside the model. The approve button lives in a signed email link, a Telegram card or a Slack message. Opening the message decides nothing; only the button does. The approver never needs access to the agent runtime, and the agent never sees the approver's credentials.
- The agent's key cannot approve. If the same credential can both request and grant, an injected instruction can do both. Requesting and deciding must be two different principals.
- Every decision is sealed. Who, when, with which comment — appended to a hash chain where each entry hashes the previous one. Editing or deleting a past decision breaks verification from that point on, so tampering is detectable rather than merely discouraged.
Implementation, three ways
The examples use Raposa Aval, the hosted version of this pattern (EU-hosted, GDPR, free sandbox of 100 approvals a month). The shape is the same if you build the gate yourself — the point of the article is the shape.
1. Python, inside your own agent code
pip install raposa — a zero-dependency client. guard() returns only when a named person approved; rejection or expiry raises Denied, silence raises TimedOut. The refund call sits after the guard and nowhere else.
from raposa import Raposa, Denied, TimedOut
gate = Raposa() # reads RAPOSA_API_KEY from the environment
def issue_refund(order_id, amount_eur, reason):
try:
decision = gate.guard(
action="issue_refund",
context=f"Order {order_id}, EUR {amount_eur:.2f}. Reason: {reason}",
risk="high" if amount_eur >= 100 else "medium",
requested_by="support-agent",
wait_sec=600,
)
except Denied as exc:
return f"Refund not issued — a human declined: {exc}"
except TimedOut as exc:
return f"Refund not issued — nobody answered in time: {exc}"
payments.refund(order_id, amount_eur) # the real call, only here
ledger.note(order_id, approval_id=decision["id"],
approved_by=decision["decided_by"], at=decision["decided_at"])
return f"Refund issued. Approved by {decision['decided_by']}."
Two details worth copying even if you never use the library. First, the risk field is set by your code from the amount, not by the model — the model does not get to grade its own request. Second, the approval id goes into your ledger; that is what turns "we have an audit log" into "here is the entry for this transaction".
Without the SDK it is one POST and a poll. Create: POST /api/v1/approvals with action, context, risk and requested_by (the schema is strict — unknown fields are rejected, and creation is limited to 60 requests a minute per key). Read: GET /api/v1/approvals/{id} until status is no longer pending. A pending approval past expires_at flips to expired on read, and that transition is audited like a decision. If you would rather not poll, pass a webhook_url: the decision is POSTed with an HMAC-SHA256 signature in X-Raposa-Signature, retried four times, and every attempt is logged.
2. MCP, for Claude Code, Cursor or any tool-calling agent
If the agent is an LLM with tools rather than your own code, give it the gate as a tool. The raposa-mcp server exposes request_human_approval(action, context, risk), which returns approved: true only when a person pressed Approve. Timeout, expiry and rejection all come back as approved: false.
claude mcp add raposa -e RAPOSA_API_KEY=<your key> -- uvx raposa-mcp
Or over HTTP, with nothing to install — the same three tools are served at https://dcescrypt.com/api/mcp (Streamable HTTP, stateless):
{"mcpServers": {"raposa": {"url": "https://dcescrypt.com/api/mcp",
"headers": {"Authorization": "Bearer <your key>"}}}}
The system prompt does the rest: "Before any payment, refund or transfer, call request_human_approval and proceed only if it returns approved: true." The key is read from the environment and never appears in tool inputs or outputs, so the model cannot leak it and an injected page cannot use it. Over HTTP each call waits up to 50 seconds; if nobody has decided by then the tool returns status: "timeout", approved: false and the id, and the request stays open for get_approval later.
This is also the honest answer to "can't the agent just be told to ask?" — it can, and it will comply most of the time. The tool makes the ask real: the agent cannot fabricate an approval, because approval is a state on a server it does not control.
3. n8n, with no code
In n8n the gate is a node. Install n8n-nodes-raposa (Settings → Community Nodes), drop a Raposa Approval node in front of the Stripe, bank or ERP step, set the operation to Ask and Wait. The workflow pauses until a human decides. A timeout is an error, never an approval; a rejection stops the workflow unless you turn Fail on Reject off and branch on status yourself. The same node works as a tool for n8n's AI Agent, so a chat-driven agent gets the same gate as a scheduled workflow.
Who approves, and from where
The approver is a person on your team. You create approvers on your account page or with POST /v1/approvers; each gets a console login scoped to your approvals only, optional TOTP, and can bind Telegram or Slack so the approve button arrives where they already are. Their name is recorded in the decision (decided_by: "approver:anna"), in the webhook and in your audit export. There is no per-seat price — adding an approver never changes the bill — because a gate that makes you ration approvers is a gate people route around.
For payments specifically, two options matter. approvers: ["finance", "cfo"] with required: 2 gives you N-of-M sign-off on large amounts. remind_after_sec with escalate_to pulls in a second approver when the first one has not answered — so a request does not silently expire because someone is on a plane.
Proving it afterwards
An auditor's question is never "did you have a process"; it is "show me the entry for this transaction". That is why the approval id goes in your ledger. From there, GET /api/v1/audit/export returns the entries for your approvals, each with its position in the chain, its prev_hash and hash, and a recomputed self_hash_ok. Each entry is sha256(prev_hash + ts + event_type + approval_id + actor + payload). You cannot recompute other customers' entries — isolation forbids it — but the chain as a whole is verified continuously on the operator side, and any break is a page, not a footnote.
Keep the id. Export the chain. Those two habits are the difference between "we log approvals" and evidence.
Failure modes to design for
- Retries. A retried tool call must not create a second payment. Create the approval once, keep the id, and make the payment step idempotent on that id.
-
Double decisions. Two approvers clicking at once must not produce two outcomes. A second decision on the same approval answers
409; treat it as "already decided, re-read the status". -
Expiry. Pick
expires_in_secfrom the business, not from the HTTP timeout: a refund request nobody looked at for a day should expire, and the customer should hear why, rather than being paid on Monday by a queue that woke up. -
Prompt injection. Assume the agent's context is hostile. The gate is worth having precisely because the approver reads the
actionandcontextfields — so write them in plain, specific language ("Refund EUR 240 to customer 9182 for order 7731, duplicate charge") rather than pasting the model's reasoning.
Cost
The sandbox is free — 100 approvals a month, no card, no expiry — and includes the whole product: API, console, approve from email, Telegram or Slack, approver groups with N-of-M, reminders, escalation, webhooks and the audit export. Team is €149 a month for 2,500 approvals, unlimited approvers. A side-by-side with an SDK approval pause and other vendors is on the compare page, where every claim about us is a named test that ran today.
Docs: https://raposa.group/docs/ · MCP server and n8n node are MIT on GitHub (agentlabbusiness). Questions about the polling/timeout design welcome in the comments.
Top comments (0)