Docs

#Approvals that can't be skipped

Your code decides when a human is needed (the required flag, see When a human is needed). That keeps the rule where it belongs, in code you can read, test and review. It also means the guarantee that an approval actually happened has to live in your code too.

The failure to design against is not a malicious caller. It's an ordinary change: a refactor that moves the payout above the approval step, a merge that drops an await, an AI-written edit that "simplifies" the gate. Each of these compiles, passes a happy-path test, and pays without anyone approving.

The pattern below makes that impossible by construction: the action that matters refuses to run unless Ratifia confirms a matching, unspent approval, checked at the moment it acts. Skip the approval step anywhere upstream and the action throws.

It uses an insurance claim payout as the running example. Every snippet on this page is compiled and tested against the SDK.

Agents use a hook instead

For an AI agent calling tools, enforce approval with the MCP gate hook, which blocks the tool call itself. See Enforce approval on specific tools. This page is for your own code and durable workflows.

#Setup

bash
npm install @ratifia/sdk @ratifia/adapter-inngest

@ratifia/sdk verifies and consumes approvals. The Inngest adapter requests them from inside a workflow; without Inngest, request them with POST /v1/worker/decisions (see Quickstart: Your code).

ts
const apiUrl = process.env.RATIFIA_API_URL ?? 'https://api.ratifia.com'const ratifia = new Ratifia({ apiKey: process.env.RATIFIA_API_KEY!, apiUrl })const approvals = new FlowplaneInngest({ flowplaneApiKey: process.env.RATIFIA_API_KEY!, flowplaneApiUrl: apiUrl })

#1. Rules can only add reviews

Decide the minimum in deterministic code. If an AI triage step also has a say, OR its answer in: it can escalate a routine case to a human, but it can never talk a large payout out of review.

ts
export const REVIEW_THRESHOLD_CENTS = 500_000 // $5,000
// Deterministic code decides the minimum. The AI can add a review; it can never// remove one, because its answer is only ever OR'd in.export function needsReview(claim: Claim, aiSaysReview: boolean): boolean {  return claim.amountCents >= REVIEW_THRESHOLD_CENTS || aiSaysReview}

The anti-pattern is letting the model's answer stand alone:

ts
// Don't: one wrong or manipulated triage answer skips review entirely.required: triage.aiSaysReview

#2. Bind the approval to what was approved

An approval for "pay claim 1042" should not also authorize paying claim 1042 ten times the amount, or to a different account. Build the facts that matter with one function, and use it both when you request the approval and when you act.

ts
// Exactly what the approver is approving. The same function builds the facts at// request time and at payout, so a changed amount or payee can't reuse the approval.export const payoutFacts = (claim: Claim) => ({  claimId: claim.id,  amountCents: claim.amountCents,  payeeAccount: claim.payeeAccount,})

Pass them as facts. Only a SHA-256 hash of them is sent to Ratifia, never the values: the approver sees what you put in context, and the hash just pins the approval to these exact facts. Key order doesn't matter. Values must be plain JSON (strings, finite numbers, booleans, null, arrays, objects); anything that wouldn't round-trip, like a Date or undefined, throws instead of producing a hash that quietly stops matching.

#3. Check at the action, and spend the approval once

This is the step that makes the rest hold. The payout function re-applies the rule itself, then calls consumeApproval immediately before moving money:

ts
export async function payClaim(  deps: { ratifia: Pick<Ratifia, 'consumeApproval'>; bank: Bank },  claim: Claim,  decisionId: string | undefined,) {  // The payout re-applies the rule itself instead of trusting that some earlier  // step asked. Skip the approval step upstream and this refuses.  if (claim.amountCents >= REVIEW_THRESHOLD_CENTS && !decisionId) {    throw new Error(`Claim ${claim.id} needs an approval before it can be paid`)  }  if (decisionId) {    // Throws ApprovalNotValidError unless the decision is APPROVED, covers these    // exact facts, and hasn't been spent on a different payout.    await deps.ratifia.consumeApproval({      decisionId,      facts: payoutFacts(claim),      stepRef: 'payout-review',      consumer: `payout:${claim.id}`,    })  }  return deps.bank.transfer({ to: claim.payeeAccount, amountCents: claim.amountCents, reference: claim.id })}

consumeApproval throws ApprovalNotValidError, and the payout never runs, unless all of these are true:

CheckRefused with
The decision exists in your orgnot_found
It was approved (not pending, rejected, expired or cancelled)not_approved
Its facts hash matches the facts you're acting onfacts_mismatch
It's for the step (and workflow) you name, if you name themstep_mismatch, workflow_mismatch
It hasn't already been spent on a different actionconsumed

Consuming is atomic: if two payouts race on the same approval, exactly one wins. The consumer key identifies this action, so a retry of the same payout (the step crashed after consuming, and your engine runs it again) succeeds with alreadyConsumed: true instead of being refused.

Don't catch ApprovalNotValidError to carry on. If you catch it at all, only to hold the case for a person to look at.

Just checking?

verifyApproval runs the same checks without spending the approval, for showing "approved" in your own UI or a pre-flight. The action itself should always consumeApproval.

#4. Put it together

The workflow requests the approval with the same facts, and hands the decisionId to the payout. When the rule says no review is needed, no decision is created, decisionId is absent, and the payout's own rule agrees it can pay.

ts
export const processClaim = inngest.createFunction(  {    id: 'process-claim',    // If the run fails for good (retries exhausted, including an unknown policy    // name that nobody fixed in time), hold the claim for a person. Never pay    // from here. Fix the cause, then replay the failed runs.    onFailure: async ({ event, error, step }) => {      const claim = event.data.event.data as Claim      await step.run('hold-claim', () => holdClaim(claim.id, error.message))    },  },  { event: 'acme/claim.submitted' },  async (ctx) => {    const claim = ctx.event.data as Claim    const triage = await ctx.step.run('triage', () => triageClaim(claim))
    const decision = await approvals.awaitApproval(ctx, {      id: 'payout-review',      workflowRef: 'claims',      policy: 'claims',      required: needsReview(claim, triage.aiSaysReview),      facts: payoutFacts(claim),      prompt: `Pay $${(claim.amountCents / 100).toFixed(2)} on claim ${claim.id}? ${triage.reason}`,    })    if (!decision.approved) return { status: 'rejected', by: decision.decidedBy }
    return ctx.step.run('pay', () => payClaim({ ratifia, bank }, claim, decision.decisionId))  },)

onFailure is where recovery goes. If the run fails for good (for example the approval named a policy that doesn't exist and nobody fixed it before the retries ran out), hold the claim for a person. Never pay from a failure handler. Fix the cause, then replay the failed runs: no decision was created, so a replay opens exactly one.

#5. Test the gate

Test the refusals, not only the happy path. These are the tests that catch the refactor that moves the payout above the approval:

  • a payout that needs review refuses with no decisionId, and the bank is never called;
  • a payout refuses when consumeApproval throws (mismatched facts, not approved, already spent);
  • it consumes with the payout's own facts before paying;
  • the rule only adds reviews: a large amount needs review whatever the AI says.

The example on this page ships with exactly these tests.

#Over the API

The SDK calls two endpoints, usable directly with an org API key:

bash
# Read-only check. Always 200, with valid and a reason when it isn't.curl "$RATIFIA_API_URL/v1/worker/decisions/$DECISION_ID/verify?factsHash=$HASH&stepRef=payout-review" \  -H "Authorization: Bearer $RATIFIA_API_KEY"
# Spend it on one action.curl -X POST "$RATIFIA_API_URL/v1/worker/decisions/$DECISION_ID/consume" \  -H "Authorization: Bearer $RATIFIA_API_KEY" -H "Content-Type: application/json" \  -d '{ "factsHash": "'"$HASH"'", "stepRef": "payout-review", "consumer": "payout:clm_1042" }'

Send the same factsHash when you open the decision (POST /v1/worker/decisions). It's the lowercase hex SHA-256 of the facts as canonical JSON: object keys sorted at every level, no whitespace. hashFacts from @ratifia/sdk produces it. Every field is in the API reference.