Recipe: customer refund approval

Threshold-based, multi-level human approval — small refunds need one sign-off, large ones need two.

What it does: a refund request is validated, then routed by amount — small refunds need one approval, large ones need a second (manager) approval. On approval it calls a payment connector and emails the customer.

When to use it: any policy-gated action with tiered authority — refunds, discounts, access grants, spend approvals.

Flow

validate ─▶ l1_approval (form) ─▶ after_l1
                                    ├─ rejected ───────────────▶ rejected
                                    ├─ amount > 500 ─▶ l2_approval (form) ─▶ after_l2 ─┐
                                    └─ else ─────────────────────────────────────────▶ process_refund
                                                                                          │ onError → refund_failed

                                                                                  notify_customer ─▶ end

The workflow

// Customer refund approval — threshold-based, multi-level human approval.
//
// A refund request is validated, then routed by amount: small refunds need one
// approval, large refunds need a second (manager) approval. On approval it calls a
// payment connector, then notifies the customer. Demonstrates branches, two forms,
// a connector, and a result reducer.
//
// Connections used: payments (generic-http), composio_gmail.
//
//   flowctl def create --file examples/workflows/refund-approval.js
//   flowctl run start --workflow RefundApproval \
//     --input '{"order_id":"O-77","amount":800,"reason":"damaged"}' -q
//   # then submit the approval form(s) the run parks on:
//   flowctl form submit <exec> l1_approval --data '{"approved":true}'

function validate(ctx) {
  return { status: "validated", needs_l2: ctx.vars.amount > 500 };
}

function on_l1(ctx, data) {
  return { l1_approved: data.approved === true, l1_by: data.approver || "" };
}
function on_l2(ctx, data) {
  return { l2_approved: data.approved === true, l2_by: data.approver || "" };
}

function decide(ctx) { return {}; }

function refund_req(ctx) {
  return { body: { order_id: ctx.vars.order_id, amount: ctx.vars.amount, reason: ctx.vars.reason } };
}
function refund_resp(ctx, response) {
  return { refund_id: response.body && response.body.id, refunded: response.status_code < 300, status: "refunded" };
}
function refund_failed(ctx) {
  return { refunded: false, status: "refund_failed", error: ctx.error && ctx.error.message };
}

function notify_req(ctx) {
  return { recipient_email: ctx.vars.customer_email, subject: "Refund for order " + ctx.vars.order_id, body: "Your refund has been processed." };
}
function notify_resp(ctx, response) { return { customer_notified: response.successful === true }; }

function rejected(ctx) { return { status: "rejected" }; }

workflow({
  name: "RefundApproval",
  version: "1",
  description: "Threshold-based multi-level refund approval.",
  initial: {
    order_id: null, amount: 0, reason: "", customer_email: "customer@example.com",
    l1_approved: false, l2_approved: false, needs_l2: false, status: "pending",
  },
  inputSchema: {
    type: "object",
    properties: { order_id: { type: "string" }, amount: { type: "number" }, reason: { type: "string" } },
    required: ["order_id", "amount"],
  },
  triggers: [apiTrigger("request_refund")],
  steps: [
    step("validate", { start: true, next: "l1_approval", label: "Validate" }, validate),

    form("l1_approval", {
      title: "Approve refund for {{order_id}} ({{amount}})",
      schema: {
        type: "object",
        properties: { approved: { type: "boolean" }, approver: { type: "string" } },
        required: ["approved"],
      },
      next: "after_l1",
    }, on_l1),

    step("after_l1", {
      branches: [
        branch("l1_approved == false", "rejected"),
        branch("needs_l2 == true", "l2_approval"),
        branch("true", "process_refund"),
      ],
    }, decide),

    form("l2_approval", {
      title: "Manager approval for {{order_id}} ({{amount}})",
      schema: {
        type: "object",
        properties: { approved: { type: "boolean" }, approver: { type: "string" } },
        required: ["approved"],
      },
      next: "after_l2",
    }, on_l2),

    step("after_l2", {
      branches: [
        branch("l2_approved == true", "process_refund"),
        branch("true", "rejected"),
      ],
    }, decide),

    connector("process_refund", {
      connection: "payments",
      operation: "create_refund",
      instance: "primary",
      requestMapper: refund_req,
      responseCombiner: refund_resp,
      onError: "refund_failed",
      next: "notify_customer",
    }),
    step("refund_failed", { next: "end", label: "Refund failed" }, refund_failed),

    connector("notify_customer", {
      connection: "composio_gmail",
      operation: "send_email",
      instance: "my-gmail",
      requestMapper: notify_req,
      responseCombiner: notify_resp,
      next: "end",
    }),

    step("rejected", { next: "end", label: "Rejected" }, rejected),
  ],
  outcomeTemplates: {
    completed: "Refund {{order_id}} ({{amount}}) — {{status}}.",
  },
  result: (vars) => ({
    order_id: vars.order_id,
    amount: vars.amount,
    status: vars.status,
    refunded: vars.status === "refunded",
  }),
});

Run it

flowctl def create --file examples/workflows/refund-approval.js
flowctl run start --workflow RefundApproval \
  --input '{"order_id":"O-77","amount":800,"reason":"damaged"}' -q
flowctl form submit <exec> l1_approval --data '{"approved":true,"approver":"sam"}'
flowctl form submit <exec> l2_approval --data '{"approved":true,"approver":"manager"}'   # only for amount > 500
Drive the forms from Claude Code

Start the run with —channel <id> and the approval forms are pushed into your session, schema and all — see channels. Otherwise poll run status and submit each form as the run parks on it.

Variations

  • More tiers. Add an amount > 5000 branch and a third approval form.
  • Auto-approve small refunds. Route amounts under a floor straight to process_refund.
  • Compensate on failure. Have refund_failed open a ticket or alert instead of just ending.