Webhook icon
Labels icon
Query icon
If icon
MailSend icon
Exit icon
ChatCompletion icon
Anthropic icon
OutputValues icon
Set icon
SlackIncomingWebhook icon
Pause icon
Switch icon
Request icon
Fail icon
Log icon

Customer Refund Request Desk with Policy Rules, Approval and Stripe

Classify refund requests with AI, apply refund policy rules, approve large refunds by hand, refund through Stripe safely and reply to the customer.

Categories
AIBusiness

Every shop answers the same refund emails: the parcel never came, the mug is chipped, the customer changed their mind. Support agents look up the order, check the return window and the customer's history, ask a lead for anything big, click refund in Stripe, and write a reply. It is slow, inconsistent between agents, and easy to get wrong, such as refunding twice or refunding outside the window. This blueprint does that loop end to end. AI only reads the message; your policy makes the decision, a person approves what the policy says needs approval, and every refund and reply is checked. It's for support and operations teams at e-commerce and subscription businesses that take payments through Stripe.

How it works

  1. The support form or helpdesk POSTs the request to the support_form webhook. The JSON body is mapped onto typed inputs, so an invalid email or order ID is rejected with HTTP 422.
  2. label_request tags the execution with the order ID. order looks the order up by ID and email together, with what is left to refund, days since delivery, and the customer's refunds in the last 90 days. An unknown order or a mismatched email gets the same neutral reply (reply_unknown_order), so the desk never confirms whose order is whose.
  3. open_request_check ignores a second form for an order that is already waiting for approval.
  4. classify (AI ChatCompletion) reads the message and returns, as JSON that classification parses, the reason (changed mind, damaged, wrong item, not as described, not received, duplicate charge, other), the language and a one-line summary. Input guardrails stop very long messages and messages with instructions aimed at the AI; those, and any reason outside the list, are treated as "other", which always goes to a person.
  5. policy applies the rules, all visible as plain expressions:
    • decline when nothing is left to refund, or when the reason's window has passed (30 days for a change of mind, 60 for damaged or wrong items, and so on, in variables.windows);
    • ask a person above auto_refund_limit, for customers with max_recent_refunds refunds in 90 days, for "not received" when the carrier confirmed delivery, and for duplicate charges or unclear reasons;
    • refund automatically otherwise.
  6. When a person is needed, ask_support_lead posts the facts to Slack, and support_decision (Pause, 2 days) offers a form: refund the requested amount, refund a different amount, or decline with a reason for the customer.
  7. decision merges the policy and the human answer into one outcome. An approved amount can never exceed what is left to refund.
  8. act (Switch) refunds through the Stripe REST API with an Idempotency-Key made from the order, what was already refunded and the amount, checks Stripe's answer, and records the refund on the order.
  9. draft_reply writes the reply in the customer's language from the facts only. An output guardrail blocks replies that offer vouchers or discounts. send_reply sends the draft only if it names the order and, for a refund, the exact amount; otherwise it sends a fixed template.
  10. audit writes one row per request (reason, outcome, amount, who decided and why, the Stripe refund ID), and close_request updates the KV record.

Error handling

  • Never pays twice. The idempotency key stays the same for the same order state. If the order update fails after Stripe refunded, sending the same request again reuses the key, and Stripe returns the first refund instead of paying again.
  • Stripe errors stop the run. A refusal (expired key, refund larger than the charge) fails the run with Stripe's message. Nothing is recorded and the customer is not told it was refunded. Network errors retry 4 times with backoff.
  • AI never decides money. The model only classifies. Amounts come from the order and the policy, and a customer asking for 1,000 EUR on a 39.99 order can be refunded at most 39.99.
  • Prompt injection goes to a person. A message with instructions aimed at the AI is flagged by the input guardrail, treated as "other" and sent for approval.
  • Checked replies. A draft that misses the order number or the amount, or offers a voucher, is replaced by a template.
  • No silent stalls. Without an approval in 2 days, support is reminded in Slack and nothing is refunded or sent. A failed run frees the order for a new request and posts one Slack message saying to check Stripe before retrying.

What this blueprint teaches about Kestra

  • Switch on an outcome computed by OutputValues, where every policy rule is a readable Pebble expression.
  • Pause with an onResume form (SELECT, FLOAT, STRING) for a human decision, merged with the automatic path into one outcome.
  • The HTTP Request task with bearer auth, form data, an idempotency header, allowFailed and retries, to call any payment API safely.
  • AI ChatCompletion twice: once with input guardrails to classify into JSON parsed with fromJson, once with an output guardrail to write, with Kestra checking the result before it is sent.
  • Webhook input mapping, the KV store for one-open-request-per-order, and Labels to find every execution for an order.

Prerequisites

  • A Stripe account (a test-mode key to start), an Anthropic API key, an SMTP server, a Slack incoming webhook.
  • A Postgres database with your orders and an audit table. The flow expects these columns; adapt the order query to your own schema:
    CREATE TABLE orders (order_id text PRIMARY KEY, customer_email text NOT NULL, payment_intent_id text NOT NULL,
      currency text NOT NULL, amount_cents int NOT NULL, refunded_cents int NOT NULL DEFAULT 0,
      created_at timestamptz NOT NULL, delivered_at timestamptz);
    CREATE TABLE refund_requests (id bigserial PRIMARY KEY, execution_id text, order_id text, customer_email text,
      reason text, outcome text, amount_cents int, decided_by text, why text, stripe_refund_id text, decided_at timestamptz);
    

Secrets

  • STRIPE_SECRET_KEY: a restricted key with write access to Refunds is enough.
  • ANTHROPIC_API_KEY
  • REFUND_DESK_WEBHOOK_KEY: a long random string, the key in the webhook URL.
  • SMTP_HOST, SMTP_USERNAME, SMTP_PASSWORD, SUPPORT_FROM_EMAIL
  • SLACK_WEBHOOK_URL
  • POSTGRES_URL (JDBC URL), POSTGRES_USER, POSTGRES_PASSWORD

Inputs

  • order_id, customer_email (EMAIL), message.
  • requested_amount (FLOAT, optional): empty means everything still refundable.
  • auto_refund_limit (default 50): set it to 0 to approve every refund by hand.
  • max_recent_refunds (default 2).

Outputs

  • outcome (refunded, declined or no_decision) and refunded_cents.

Quick start

  1. Add the secrets (a Stripe test-mode key), create the tables, and insert a test order with a test-mode PaymentIntent ID.
  2. Send a request:
    curl -X POST "$KESTRA/api/v1/main/executions/webhook/company.team/customer-refund-request-desk/$REFUND_DESK_WEBHOOK_KEY" \
      -H 'Content-Type: application/json' \
      -d '{"order_id": "A1001", "email": "alice@example.com", "message": "The lamp arrived with a cracked shade."}'
    
  3. Under 50 it is refunded and answered at once. Above it, approve the request in Kestra from the link in Slack.

How to extend

  • Read requests straight from your helpdesk (Zendesk, Freshdesk, Gorgias webhooks) and post the reply back as a ticket comment instead of an email.
  • Move the windows and limits to the KV store so support leads can change them without editing the flow.
  • Add a weekly flow that reads refund_requests and reports refund rates by reason and product.

Plugins used

See How

New to Kestra?

Use blueprints to kickstart your first workflows.