A refund gated by a human approval

The run pauses on requestApproval until a person decides, and the side effect runs only after a fresh approved decision. If nobody decides within the timeout, the fallback wins. requestApproval is 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.

workflows/refund.workflow.ts
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 token unset. 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 the fallback instead of waiting for a person. See when to pass your own token.
  • Keep performRefund idempotent. The runtime re-runs a step that crashes part-way through, so key the payout on orderId and let a repeat call return the existing refund rather than issuing a second one.

What's next

BUILT WITH ❤️ FROM CANADA AND SPAIN