A refund gated by a human approval
The run pauses on
requestApprovaluntil a person decides, and the side effect runs only after a freshapproveddecision. If nobody decides within the timeout, thefallbackwins.requestApprovalis a workflow primitive: call it from the body, never from a step.
The whole workflow is a single workflows/refund.workflow.ts file. You start it
like any other workflow, as
Getting started with workflows
shows.
import { z } from '@stackbone/sdk';
import { requestApproval } from '@stackbone/sdk/workflow';
export const inputSchema = z.object({
orderId: z.string(),
amount: z.number().positive(),
});
export const outputSchema = z.object({
orderId: z.string(),
refunded: z.boolean(),
decision: z.string(),
});
export async function refundWorkflow(input: z.infer<typeof inputSchema>) {
'use workflow';
const decision = await requestApproval({
// No `token`: the SDK assigns a resume key unique to this run.
topic: 'refund',
payload: { orderId: input.orderId, amount: input.amount },
title: 'Approve refund',
timeout: '24h',
fallback: 'reject',
});
if (decision.status !== 'approved') {
return { orderId: input.orderId, refunded: false, decision: decision.status };
}
await performRefund(input.orderId, input.amount);
return { orderId: input.orderId, refunded: true, decision: decision.status };
}
async function performRefund(orderId: string, amount: number) {
'use step'; // the side effect, gated behind the approval. Keep it idempotent
return { refundId: `rf_${orderId}_${amount}` };
}A reviewer decides from the dashboard or with stackbone hitl approve <id> --yes,
which wakes the parked run.
Two details decide whether this holds up in production.
- Leave
tokenunset. The SDK then assigns a resume key unique to the run. A retry, or a second refund run for the same order while the first approval is pending, cannot collide with a key another live run holds. A collision resolves to thefallbackinstead of waiting for a person. See when to pass your own token. - Keep
performRefundidempotent. The runtime re-runs a step that crashes part-way through, so key the payout onorderIdand let a repeat call return the existing refund rather than issuing a second one.
What's next
- An onboarding pipeline: a linear run with no pause in it.
- A scheduled digest with a long wait:
start a run from a cron schedule and pace it with
sleep. - What is a workflow: the model behind this example.
- Human-in-the-loop: the full
requestApprovalsurface and the deciding commands.