#Quickstart: Your code
Pause anything you wrote for a human decision (a service, a cron job, a Lambda, a Python script, an agent framework) with two HTTP calls. No SDK to install. (Driving an AI agent instead? See Quickstart: Agents.)
By the end you'll have code that opens a decision, a decision waiting in the Inbox with its context, and your code continuing on the branch the human picked. Already on Inngest? The last section swaps the polling for a one-line adapter call.
#1. Get an API key
Dashboard → API Keys → create one. It's shown once (hashed afterward), so store it. Export it for the snippets below:
export RATIFIA_API_KEY=…export RATIFIA_API_URL=https://api.ratifia.com
#2. Open a decision
POST /v1/worker/decisions. The only required fields are externalRunId and stepRef: your ids, whatever correlates the decision back to the thing that asked. Everything else shapes who decides, what they see, and how long they have:
export async function requestRefundReview(refundId: string, amountUsd: number) { const res = await fetch(`${API}/v1/worker/decisions`, { method: 'POST', headers, body: JSON.stringify({ engine: 'api', externalRunId: `refund-${refundId}`, // your id, unique per run stepRef: 'refund-review', // which gate in your flow policy: 'refunds', // optional: names who decides + whether required: amountUsd > 100, // whether a human is needed at all assigneeEmail: 'support-lead@acme.com', timeoutSec: 4 * 60 * 60, // SLA: auto-expire (as reject) after 4h context: { prompt: `Refund $${amountUsd.toFixed(2)} to the customer?`, blocks: [ { kind: 'fields', title: 'Refund', fields: [ { label: 'Refund id', value: refundId }, { label: 'Amount', value: `$${amountUsd.toFixed(2)}` }, ], }, ], }, }), }) // 201 { approvalNeeded: true, decisionId }, or 200 { approvalNeeded: false } // when the policy gate says no human is needed. return (await res.json()) as { approvalNeeded: boolean; decisionId?: string }}
The response is 201 { approvalNeeded: true, decisionId }, or 200 { approvalNeeded: false, reason: "policy_gate_not_required" } when you passed required: false and no human is needed; in that case just carry on.
The same call from a shell:
curl -X POST "$RATIFIA_API_URL/v1/worker/decisions" \ -H "Authorization: Bearer $RATIFIA_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "engine": "api", "externalRunId": "refund-8812", "stepRef": "refund-review", "assigneeEmail": "support-lead@acme.com", "timeoutSec": 14400, "context": { "prompt": "Refund $240.00 to the customer?" } }'
#3. Wait for the verdict
Poll GET /v1/worker/decisions/:id until status leaves PENDING:
export async function awaitVerdict(decisionId: string, intervalMs = 5_000) { for (;;) { const res = await fetch(`${API}/v1/worker/decisions/${decisionId}`, { headers }) const d = (await res.json()) as { status: 'PENDING' | 'APPROVED' | 'REJECTED' | 'EXPIRED' | 'CANCELLED' decidedBy: string | null note: string | null } if (d.status !== 'PENDING') return d await new Promise((r) => setTimeout(r, intervalMs)) }}
status resolves to APPROVED, REJECTED, EXPIRED (the SLA passed, so treat it as a rejection), or CANCELLED. Alongside it: decidedBy, decidedAt, and note (the reviewer's free text). If you asked for a structured answer it's in responseValue; a refined draft is in output.
#4. Decide
Run it. The decision appears in the Inbox (and by email, Slack, or Discord if you've configured notifications) with the prompt and fields you sent. Approve or reject, and your poll returns on the next tick with the verdict.
That's the whole integration. The MCP server and the Inngest adapter are thin wrappers over these same two calls.
#Beyond approve / reject
The request body takes the same options the MCP tools expose:
responseSpec. Ask the human to pick ({ "type": "select", "options": [{ "id": "…", "label": "…" }, …] }) or type ({ "type": "text" }); read the answer fromresponseValue.proposedOutput. Attach an editable draft (an email, a message); read the human's final version fromoutput. See Conversational Approvals.policy+required.requireddecides whether a human is needed at all; a namedpolicysupplies who approves and how they're reached. See Policies.factsHash. Bind the approval to the facts it covers, then have the action check it right before it runs. See Approvals that can't be skipped.context.blocks. Typed context: fields, diffs, tool calls, AI output. See Context.
Every field is in the API reference and tryable in the API explorer.
#Already on Inngest?
Skip the polling. @ratifia/adapter-inngest makes the same request and then parks your function on step.waitForEvent, so Inngest resumes it when the decision resolves and nothing of yours sits blocked.
npm install @ratifia/sdk @ratifia/adapter-inngest inngestCreate the client. It takes your API key, not your Inngest client, because Ratifia never wraps or owns your function:
const inngest = new Inngest({ id: 'acme-expenses' }) // No `inngest` client needed here. Ratifia is just a step.const flowplane = new FlowplaneInngest({ flowplaneApiKey: process.env.FLOWPLANE_API_KEY!, flowplaneApiUrl: process.env.FLOWPLANE_API_URL ?? 'https://api.ratifia.com',})
Then, inside your own inngest.createFunction, call awaitApproval wherever a human should decide. It returns { approved, decidedBy, note } once a reviewer decides, or immediately if the policy gate says no human is needed:
export const expenseApproval = inngest.createFunction( { id: 'expense-approval' }, { event: 'acme/expense.submitted' }, async (ctx) => { const { event, step } = ctx const amount = (event.data as { amount: number }).amount // Pause this run for a human decision. Inngest pauses here (via // step.waitForEvent) until Ratifia records the decision and resumes the // run. Your function keeps full control: this is one step, not a wrapper. const decision = await flowplane.awaitApproval(ctx, { id: 'manager-review', required: amount > 1000, policy: 'expenses', assigneeEmail: 'manager@acme.com', prompt: `Approve a $${amount.toFixed(2)} expense?`, context: { aiReasoning: 'Amount is within the team budget; vendor is known.', blocks: [ { kind: 'fields', title: 'Expense', fields: [{ label: 'Amount', value: `$${amount.toFixed(2)}` }], }, ], }, }) if (!decision.approved) { return { status: 'rejected', by: decision.decidedBy } } return await step.run('disburse', () => ({ status: 'paid', amount })) },)
Serve expenseApproval like any other Inngest function, send the triggering event (amount > 1000 to force a human), and decide in the Inbox. The run resumes on the branch you chose.
Ratifia is a step, not an orchestrator
awaitApproval slots into the function you already own and run. Ratifia only ever sees the decision, never your code, your other steps, or your credentials. Temporal and Trigger.dev adapters are planned; until then the two REST calls above work from any engine.
Next: the Human-in-the-loop guide for context blocks, policy gates, quorum, and SLAs, all of which apply identically to every door.