How it works

One skill call. A human in every loop that matters.

FinalApproval is the checkpoint you wire into your agent once. After that, every gated action your agent performs pauses for a human decision, and your own handler runs on the way back.

The mental model

One-time setup. Per-decision calls.

Once

The skill provisions a channel

Your agent runs the skill and describes what it wants approved. The skill creates the hosted review page, issues an API key, and writes the signed webhook endpoint and continuation handler directly into your codebase.

  • Hosted review UI at finalapproval.ai/a/<channel>
  • HMAC-signed webhook endpoint in your own app
  • Approve / deny handler wired into your agent
Every time

Your app POSTs approvals

At every gate point, your code calls POST /api/v1/approvals. A human reviews the HTML body, clicks Approve or Deny, and your handler branch runs on the webhook callback.

  • Rich HTML body describes the decision to the reviewer
  • Structured JSON data travels alongside for the handler
  • Decision fires back in seconds, not days
01

Setup

Your agent runs the skill

The skill is a one-time action. You describe what needs approval — in plain English — and the skill provisions the hosted channel, the signed endpoint, and the continuation code in your repo.

  • Hosted review UI

    Built from your description. Rendered server-side.

  • One-time API key

    Shown once. Stored as SHA-256 hash server-side.

  • Signed webhook endpoint

    HMAC-SHA256 with replay timestamp. Drop-in verifier.

  • Continuation handler

    Approve / deny branches in your own code.

your editor · agentskill: approval-create-channel
$ /approval-create-channel
Approve customer refunds above $1,000 before they post
Write RefundReview.tsx — review UI component
Write refund.schema.ts — typed payload
Wire POST /hooks/refunds — signed webhook
Wire onDecision() — approved → issue · denied → cancel
channel live at finalapproval.ai/a/refunds
02

Runtime

Your agent asks for approval

At every gate point, your code calls POST /api/v1/approvals with an HTML body the reviewer sees and a structured data payload your handler will use. The call blocks only long enough to log the request — the actual wait happens out of band.

  • body (HTML, for humans)

    Sanitized, Tailwind-styled, renders in the dashboard.

  • data (JSON, for machines)

    Travels through the webhook. Use it in your handler.

your app · nodePOST /api/v1/approvals
await fetch('https://www.finalapproval.ai/api/v1/approvals', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.FA_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
title: 'Refund $1,248 to Acme, Inc.',
body: `<p>Invoice #INV-9831 · support ticket #4421</p>`,
data: { invoice_id: 'INV-9831', amount: 1248 },
}),
});
03

The reviewer's seconds

A human reviews and decides

The reviewer opens the channel, reads the rendered HTML, and clicks Approve or Deny. Optional denial reasons are captured in the audit trail. The UI is keyboard-first — Enter approves, Esc cancels.

  • Decision takes seconds

    Most approvals resolve in under 90 seconds — faster than Slack, let alone email.

  • Sanitized HTML rendering

    DOMPurify allowlist strips scripts, iframes, and unsafe attributes before display.

finalapproval.ai/a/refunds/req_8f3a9c
Refund — $1,248channel refundsrefund-agent · 4s ago

Acme, Inc. · #INV-9831

Refund requested by support ticket #4421. Original charge placed 2026-04-02; customer reports duplicate billing.

04

Continuation

Your handler runs on the way back

The moment a decision is recorded, we sign and POST it to the webhook endpoint the skill wired into your app. Your handler verifies the HMAC, reads the decision, and runs the matching branch — executing the action or closing it out.

  • HMAC-SHA256 signed

    X-FinalApproval-Signature-256: sha256=<hex> over timestamp + body.

  • Replay-resistant

    X-FinalApproval-Timestamp header — verify it's within 5 minutes.

  • 8 retries over 24 hours

    Jittered backoff: 30s, 2m, 10m, 30m, 2h, 6h, 12h. Every attempt logged.

your app · webhook200 OK · 84ms
→ POST https://api.yourapp.com/hooks/refunds
{
"decision": "approved",
"approver": "sam@team.com",
"channel": "refunds",
"data": { "invoice_id": "INV-9831", "amount": 1248 }
}
onDecision(approved) · issueRefund() — branch wired by the skill

Under the hood

The approval lifecycle

  • pending

    The approval exists, the reviewer has been notified, and the webhook hasn't been called. No action has been taken on your end yet.

  • approved

    A human clicked Approve. The decision is written to the audit log, the webhook is queued, and the continuation branch runs when delivery succeeds.

  • denied

    A human clicked Deny. Any denial reason is attached to the record. The webhook delivers the decision so your handler can cancel cleanly.

  • expired

    Channels can auto-expire pending approvals after a configurable window (default 7 days). Expired requests are tombstoned, not deleted, so the history stays complete.

Put a human in the loop — in ninety seconds.

Install the skill, describe what you want approved, and ship.