AutomationFlowsData & Sheets › Refund Form Webhook Handler

Refund Form Webhook Handler

Original n8n title: Refund Form

Refund Form. Uses postgres. Event-driven trigger; 6 nodes.

Event trigger★★★★☆ complexity6 nodesPostgres
Data & Sheets Trigger: Event Nodes: 6 Complexity: ★★★★☆ Added:

The workflow JSON

Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →

Download .json
{
  "name": "Refund Form",
  "active": true,
  "nodes": [
    {
      "parameters": {},
      "id": "r1000000-0000-0000-0000-000000000001",
      "name": "When clicking 'Execute workflow'",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        220,
        320
      ]
    },
    {
      "parameters": {
        "path": "refund-form",
        "httpMethod": "GET",
        "responseMode": "responseNode",
        "options": {}
      },
      "id": "r1000000-0000-0000-0000-000000000002",
      "name": "Refund Form Webhook",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        220,
        140
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "SELECT MAX(CASE WHEN key = 'n8n_base_url' THEN value END) AS n8n_base_url FROM public.global_settings WHERE key = 'n8n_base_url';"
      },
      "id": "r1000000-0000-0000-0000-000000000003",
      "name": "Load Global Settings",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.6,
      "position": [
        480,
        220
      ],
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "WITH directory AS (\n  SELECT trim(concat_ws(' ', rm.\"First name\", rm.\"Last name\")) AS name,\n         NULLIF(trim(rm.\"Email address\"), '') AS email\n  FROM public.raw_members rm\n  WHERE COALESCE(rm.is_current, true) = true\n  UNION\n  SELECT trim(concat_ws(' ', rc.\"First name\", rc.\"Last name\")) AS name,\n         NULLIF(trim(rc.\"Email address\"), '') AS email\n  FROM public.raw_contacts rc\n  WHERE COALESCE(rc.is_current, true) = true\n), membership_categories AS (\n  SELECT DISTINCT trim(category) AS membership\n  FROM public.membership_packages\n  WHERE COALESCE(trim(category), '') <> ''\n)\nSELECT 'person' AS row_type, name, email, NULL::text AS membership\nFROM directory\nWHERE COALESCE(name, '') <> ''\nUNION ALL\nSELECT 'membership' AS row_type, NULL::text AS name, NULL::text AS email, membership\nFROM membership_categories\nORDER BY row_type, name NULLS LAST, email NULLS LAST, membership NULLS LAST\nLIMIT 2000;"
      },
      "id": "r1000000-0000-0000-0000-000000000006",
      "name": "Load Directory",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.6,
      "position": [
        720,
        220
      ],
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "const source = $('Refund Form Webhook').first()?.json ?? $('When clicking \\'Execute workflow\\'').first()?.json ?? $input.first()?.json ?? {};\nconst query = source.query ?? source;\nconst settings = $('Load Global Settings').first()?.json ?? {};\nconst headers = source.headers ?? {};\nconst rows = $input.all().map((item) => item.json ?? {});\nconst directory = rows.filter((entry) => entry.row_type === 'person' && String(entry.name ?? '').trim());\nconst membershipCategories = rows.filter((entry) => entry.row_type === 'membership' && String(entry.membership ?? '').trim());\nconst clean = (value) => value === null || value === undefined ? '' : String(value).trim();\nconst escapeHtml = (value) => clean(value)\n  .replace(/&/g, '&amp;')\n  .replace(/</g, '&lt;')\n  .replace(/>/g, '&gt;')\n  .replace(/\\\"/g, '&quot;')\n  .replace(/'/g, '&#39;');\nconst deriveBaseUrl = (headers, fallback) => {\n  const forwardedProto = clean(headers['x-forwarded-proto']);\n  const forwardedHost = clean(headers['x-forwarded-host']);\n  const host = forwardedHost || clean(headers.host);\n  const forwardedPort = clean(headers['x-forwarded-port']);\n  if (!host) return fallback;\n  const proto = forwardedProto || 'http';\n  let authority = host;\n  if (forwardedPort && !authority.includes(':')) {\n    const isDefaultPort = (proto === 'http' && forwardedPort === '80') || (proto === 'https' && forwardedPort === '443');\n    if (!isDefaultPort) authority = `${authority}:${forwardedPort}`;\n  }\n  return `${proto}://${authority}`;\n};\nconst parseBoolean = (value) => ['1', 'true', 'yes', 'on'].includes(clean(value).toLowerCase());\nconst fallbackBaseUrl = clean(settings.n8n_base_url) || 'http://n8n:5678';\nconst n8nBaseUrl = deriveBaseUrl(headers, fallbackBaseUrl);\nconst actionUrl = `${n8nBaseUrl}/webhook/add-refund`;\nconst returnUrl = clean(query.return_url) || `${n8nBaseUrl}/webhook/refunds`;\nconst name = clean(query.name);\nconst refundFor = clean(query.refund_for ?? query.for_name ?? query.for);\nconst reason = clean(query.reason);\nconst membership = clean(query.membership);\nconst amount = clean(query.amount);\nconst fromDate = clean(query.from_date ?? query.fromDate);\nconst toDate = clean(query.to_date ?? query.toDate);\nconst waiveAdminFee = parseBoolean(query.waive_admin_fee ?? query.waiveAdminFee);\nconst errorMessage = clean(query.error);\nconst directoryOptions = directory.map((entry) => {\n  const label = entry.email ? `${entry.name} <${entry.email}>` : entry.name;\n  return `<option value=\"${escapeHtml(label)}\"></option>`;\n}).join('');\nconst membershipOptions = membershipCategories.map((entry) => `<option value=\"${escapeHtml(entry.membership)}\"></option>`).join('');\nconst directoryJson = JSON.stringify(directory).replace(/</g, '\\\\u003c');\nconst html = `<!doctype html>\n<html lang=\"en\">\n<head>\n  <meta charset=\"utf-8\">\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n  <title>Create Refund Request</title>\n  <style>\n    :root { color-scheme: light; --bg: #f7f8fc; --panel: #ffffff; --ink: #243447; --muted: #66788a; --line: #d9e2ec; --accent: #2f80d8; --accent-dark: #2567ad; --danger: #b93a3a; --soft: #f8fbff; --success-soft: #edf8f2; }\n    * { box-sizing: border-box; }\n    body { margin: 0; padding: 24px; min-height: 100vh; font-family: Lato, 'Helvetica Neue', Arial, sans-serif; background: linear-gradient(180deg, #fbfcfe 0%, var(--bg) 100%); color: var(--ink); }\n    .wrap { max-width: 980px; margin: 0 auto; background: var(--panel); border: 1px solid var(--line); border-radius: 18px; padding: 28px; box-shadow: 0 18px 40px rgba(36, 52, 71, 0.08); }\n    h1 { margin: 0 0 10px; font-size: 32px; line-height: 1.1; }\n    p { margin: 0 0 20px; color: var(--muted); }\n    form { display: grid; gap: 18px; }\n    .grid { display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); }\n    .search-row, .calc-panel { display:grid; gap:16px; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); padding:16px; border:1px solid var(--line); border-radius:14px; background: var(--soft); }\n    .calc-panel { grid-template-columns: minmax(0, 1.2fr) minmax(280px, 0.8fr); align-items: start; }\n    label { display: block; font-weight: 700; margin-bottom: 6px; }\n    input, textarea { width: 100%; border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; font: inherit; color: var(--ink); background: #fff; }\n    textarea { min-height: 96px; resize: vertical; }\n    .checkbox-row { display:flex; align-items:flex-start; gap:12px; padding:14px 16px; border:1px solid var(--line); border-radius:14px; background:#fff; }\n    .checkbox-row input { width:18px; height:18px; margin-top:2px; accent-color: var(--accent); }\n    .checkbox-row label { margin:0; }\n    .checkbox-row .subtle { margin:4px 0 0; font-size:14px; color: var(--muted); }\n    .calculation-summary { border:1px solid var(--line); border-radius:14px; padding:16px; background:#fff; }\n    .calculation-summary h2 { margin:0 0 12px; font-size:18px; }\n    .calc-grid { display:grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap:12px 16px; }\n    .calc-grid div { border:1px solid var(--line); border-radius:12px; padding:12px; background:#fbfcfe; }\n    .calc-grid span { display:block; font-size:12px; text-transform:uppercase; letter-spacing:.05em; color: var(--muted); font-weight:700; margin-bottom:4px; }\n    .calc-grid strong { display:block; font-size:18px; }\n    .calc-formula, .calc-message { margin-top:14px; font-size:14px; line-height:1.6; color: var(--muted); }\n    .calc-message.error-text { color: var(--danger); font-weight:700; }\n    .actions { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }\n    button, .secondary { appearance: none; border: 0; border-radius: 999px; padding: 12px 18px; font: inherit; font-weight: 700; cursor: pointer; text-decoration: none; }\n    button { color: #fff; background: linear-gradient(135deg, var(--accent) 0%, var(--accent-dark) 100%); }\n    .secondary { color: var(--ink); background: #eef2f7; border: 1px solid var(--line); }\n    .note { font-size: 14px; color: var(--muted); }\n    .error { margin: 0 0 20px; padding: 14px 16px; border-radius: 14px; background: #fdecec; color: var(--danger); font-weight: 700; }\n    @media (max-width: 720px) { body { padding: 14px; } .wrap { padding: 18px; } .calc-panel { grid-template-columns: 1fr; } .calc-grid { grid-template-columns: 1fr; } }\n  </style>\n</head>\n<body>\n  <div class=\"wrap\">\n    <h1>Create Refund Request</h1>\n    <p>Create a refund case and return to the refunds landing page. Search fields help populate the names, but you can always type directly into the actual requestor and member fields.</p>\n    ${errorMessage ? `<div class=\"error\">${escapeHtml(errorMessage)}</div>` : ''}\n    <form action=\"${actionUrl}\" method=\"post\">\n      <input type=\"hidden\" name=\"created_by\" value=\"refund_form_webhook\">\n      <input type=\"hidden\" name=\"return_url\" value=\"${escapeHtml(returnUrl)}\">\n      <div class=\"search-row\">\n        <div>\n          <label for=\"requestor_search\">Search Requestor By Name Or Email</label>\n          <input id=\"requestor_search\" type=\"search\" list=\"member-directory\" placeholder=\"Start typing a name or email\" onchange=\"applyDirectoryMatch(this.value, 'name')\">\n        </div>\n        <div>\n          <label for=\"member_search\">Search Member By Name Or Email</label>\n          <input id=\"member_search\" type=\"search\" list=\"member-directory\" placeholder=\"Start typing a name or email\" onchange=\"applyDirectoryMatch(this.value, 'refund_for')\">\n        </div>\n        <datalist id=\"member-directory\">${directoryOptions}</datalist>\n      </div>\n      <div class=\"grid\">\n        <div>\n          <label for=\"name\">Requestor Name</label>\n          <input id=\"name\" name=\"name\" type=\"text\" value=\"${escapeHtml(name)}\" required>\n        </div>\n        <div>\n          <label for=\"refund_for\">Member Name</label>\n          <input id=\"refund_for\" name=\"refund_for\" type=\"text\" value=\"${escapeHtml(refundFor)}\" required>\n        </div>\n      </div>\n      <div class=\"grid\">\n        <div>\n          <label for=\"reason\">Reason</label>\n          <input id=\"reason\" name=\"reason\" type=\"text\" list=\"reason-options\" value=\"${escapeHtml(reason)}\" required>\n          <datalist id=\"reason-options\">\n            <option value=\"Injured\"></option>\n            <option value=\"Deceased\"></option>\n            <option value=\"Membership Cancelled\"></option>\n            <option value=\"Other\"></option>\n          </datalist>\n        </div>\n        <div>\n          <label for=\"membership\">Membership</label>\n          <input id=\"membership\" name=\"membership\" type=\"text\" list=\"membership-categories\" value=\"${escapeHtml(membership)}\" placeholder=\"Search or type a membership category\" required>\n          <datalist id=\"membership-categories\">${membershipOptions}</datalist>\n        </div>\n      </div>\n      <div class=\"grid\">\n        <div>\n          <label for=\"amount\">Amount Paid</label>\n          <input id=\"amount\" name=\"amount\" type=\"number\" min=\"0\" step=\"0.01\" value=\"${escapeHtml(amount)}\" required>\n        </div>\n        <div>\n          <label for=\"from_date\">From Date</label>\n          <input id=\"from_date\" name=\"from_date\" type=\"date\" value=\"${escapeHtml(fromDate)}\" required>\n        </div>\n        <div>\n          <label for=\"to_date\">To Date</label>\n          <input id=\"to_date\" name=\"to_date\" type=\"date\" value=\"${escapeHtml(toDate)}\" required>\n        </div>\n      </div>\n      <div class=\"checkbox-row\">\n        <input id=\"waive_admin_fee\" name=\"waive_admin_fee\" type=\"checkbox\" value=\"1\"${waiveAdminFee ? ' checked' : ''}>\n        <div>\n          <label for=\"waive_admin_fee\">Waive admin fee and refund the full pro-rated amount</label>\n          <p class=\"subtle\">Unchecked uses the standard calculation with one month retained as the admin fee. Checked removes that deduction.</p>\n        </div>\n      </div>\n      <section class=\"calc-panel\">\n        <div>\n          <h2>Live Calculation</h2>\n          <p class=\"note\">This preview uses the same month-based logic as the refund workflow that writes to <code>public.refunds</code>.</p>\n          <div id=\"calculation-message\" class=\"calc-message\"></div>\n          <div id=\"calculation-formula\" class=\"calc-formula\"></div>\n        </div>\n        <div class=\"calculation-summary\">\n          <h2>Calculated Refund</h2>\n          <div class=\"calc-grid\">\n            <div><span>Calendar Months</span><strong id=\"calc-months\">-</strong></div>\n            <div><span>Admin Fee</span><strong id=\"calc-admin-fee\">-</strong></div>\n            <div><span>Refundable Months</span><strong id=\"calc-refundable-months\">-</strong></div>\n            <div><span>Refund Amount</span><strong id=\"calc-refund\">-</strong></div>\n          </div>\n        </div>\n      </section>\n      <div class=\"actions\">\n        <button type=\"submit\">Create Refund Request</button>\n        <a class=\"secondary\" href=\"${escapeHtml(returnUrl)}\">Back To Refunds</a>\n        <button type=\"button\" class=\"secondary\" onclick=\"window.close()\">Close</button>\n        <span class=\"note\">New refunds are created with status <strong>New Request</strong>. Request-bank-details and treasury emails are sent later from the refunds landing page.</span>\n      </div>\n    </form>\n  </div>\n  <script>\n    const memberDirectory = ${directoryJson};\n    function applyDirectoryMatch(value, targetId) {\n      const needle = String(value || '').trim().toLowerCase();\n      if (!needle) return;\n      const match = memberDirectory.find((entry) => {\n        const name = String(entry.name || '').trim();\n        const email = String(entry.email || '').trim();\n        const combined = email ? (name + ' <' + email + '>') : name;\n        return combined.toLowerCase() === needle || name.toLowerCase() === needle || email.toLowerCase() === needle;\n      });\n      if (match) document.getElementById(targetId).value = match.name;\n    }\n    function parseIsoDate(value) {\n      const raw = String(value || '').trim();\n      const match = raw.match(/^(\\\\d{4})-(\\\\d{2})-(\\\\d{2})$/);\n      if (!match) return null;\n      const year = Number.parseInt(match[1], 10);\n      const month = Number.parseInt(match[2], 10);\n      const day = Number.parseInt(match[3], 10);\n      if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) return null;\n      if (month < 1 || month > 12 || day < 1 || day > 31) return null;\n      return { year, month, day, iso: raw };\n    }\n    function formatMoney(value) {\n      const rounded = Math.round((Number(value) + Number.EPSILON) * 100) / 100;\n      const text = Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(2).replace(/\\\\.00$/, '').replace(/(\\\\.\\\\d)0$/, '$1');\n      return '\u00a3' + text;\n    }\n    function calculateRefund() {\n      const amount = Number.parseFloat(String(document.getElementById('amount').value || '').replace(/,/g, ''));\n      const fromDate = parseIsoDate(document.getElementById('from_date').value);\n      const toDate = parseIsoDate(document.getElementById('to_date').value);\n      const waiveAdminFee = document.getElementById('waive_admin_fee').checked;\n      if (!Number.isFinite(amount) || amount < 0) {\n        return { ok: false, message: 'Enter a valid amount paid to see the refund calculation.' };\n      }\n      if (!fromDate || !toDate) {\n        return { ok: false, message: 'Choose both From Date and To Date to calculate the refund.' };\n      }\n      const fromMonthNumber = (fromDate.year * 12) + fromDate.month;\n      const toMonthNumber = (toDate.year * 12) + toDate.month;\n      const fromDayNumber = Date.UTC(fromDate.year, fromDate.month - 1, fromDate.day);\n      const toDayNumber = Date.UTC(toDate.year, toDate.month - 1, toDate.day);\n      if (toDayNumber < fromDayNumber) {\n        return { ok: false, message: 'To Date must be on or after From Date.' };\n      }\n      const months = (toMonthNumber - fromMonthNumber) + 1;\n      const adminFeeMonths = waiveAdminFee ? 0 : 1;\n      const refundableMonths = Math.max(months - adminFeeMonths, 0);\n      const refund = Math.round((amount * refundableMonths) / 12);\n      const formula = waiveAdminFee\n        ? formatMoney(amount) + ' * ' + months + '/12 months = ' + formatMoney(refund)\n        : formatMoney(amount) + ' * (' + months + '-1)/12 months = ' + formatMoney(refund);\n      return { ok: true, months, adminFeeMonths, refundableMonths, refund, formula, waiveAdminFee };\n    }\n    function updateCalculationPreview() {\n      const result = calculateRefund();\n      const monthsEl = document.getElementById('calc-months');\n      const adminFeeEl = document.getElementById('calc-admin-fee');\n      const refundableEl = document.getElementById('calc-refundable-months');\n      const refundEl = document.getElementById('calc-refund');\n      const messageEl = document.getElementById('calculation-message');\n      const formulaEl = document.getElementById('calculation-formula');\n      if (!result.ok) {\n        monthsEl.textContent = '-';\n        adminFeeEl.textContent = '-';\n        refundableEl.textContent = '-';\n        refundEl.textContent = '-';\n        messageEl.textContent = result.message;\n        messageEl.className = 'calc-message error-text';\n        formulaEl.textContent = '';\n        return;\n      }\n      monthsEl.textContent = String(result.months);\n      adminFeeEl.textContent = result.waiveAdminFee ? 'Waived' : '1 month';\n      refundableEl.textContent = String(result.refundableMonths);\n      refundEl.textContent = formatMoney(result.refund);\n      messageEl.textContent = result.waiveAdminFee\n        ? 'Admin fee waived. The full pro-rated amount will be used when the refund record is created.'\n        : 'Standard admin fee applies. One month is retained before the refund is calculated.';\n      messageEl.className = 'calc-message';\n      formulaEl.textContent = 'Formula used: ' + result.formula;\n    }\n    ['amount', 'from_date', 'to_date', 'waive_admin_fee'].forEach((id) => {\n      document.getElementById(id).addEventListener('input', updateCalculationPreview);\n      document.getElementById(id).addEventListener('change', updateCalculationPreview);\n    });\n    updateCalculationPreview();\n  </script>\n</body>\n</html>`;\nreturn [{ json: { html } }];"
      },
      "id": "r1000000-0000-0000-0000-000000000004",
      "name": "Build Form HTML",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        980,
        220
      ]
    },
    {
      "parameters": {
        "respondWith": "text",
        "responseBody": "={{ $json.html }}",
        "options": {
          "responseCode": 200,
          "responseHeaders": {
            "entries": [
              {
                "name": "Content-Type",
                "value": "text/html; charset=utf-8"
              }
            ]
          }
        }
      },
      "id": "r1000000-0000-0000-0000-000000000005",
      "name": "Respond Form",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.5,
      "position": [
        1240,
        220
      ]
    }
  ],
  "connections": {
    "When clicking 'Execute workflow'": {
      "main": [
        [
          {
            "node": "Load Global Settings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Refund Form Webhook": {
      "main": [
        [
          {
            "node": "Load Global Settings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Load Global Settings": {
      "main": [
        [
          {
            "node": "Load Directory",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Load Directory": {
      "main": [
        [
          {
            "node": "Build Form HTML",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Form HTML": {
      "main": [
        [
          {
            "node": "Respond Form",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1",
    "availableInMCP": false
  },
  "staticData": {},
  "tags": [
    {
      "updatedAt": "2026-04-22T10:30:36.709Z",
      "createdAt": "2026-04-22T10:30:36.709Z",
      "id": "b66d135472c8652d",
      "name": "Refund"
    },
    {
      "updatedAt": "2026-04-22T10:30:36.709Z",
      "createdAt": "2026-04-22T10:30:36.709Z",
      "id": "08ef3342121ccbfe",
      "name": "Forms"
    }
  ],
  "description": ""
}

Credentials you'll need

Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.

Pro

For the full experience including quality scoring and batch install features for each workflow upgrade to Pro

About this workflow

Refund Form. Uses postgres. Event-driven trigger; 6 nodes.

Source: https://github.com/sshort/avondale-n8n/blob/1719cb56dca318d014e8f97961e8c92564a696a9/workflows/refund-form.json — original creator credit. Request a take-down →

More Data & Sheets workflows → · Browse all categories →

Related workflows

Workflows that share integrations, category, or trigger type with this one. All free to copy and import.

Data & Sheets

This workflow acts as a junior finance research analyst for a UK boutique M&A or corporate finance team. It listens for Slack messages, classifies the request, gathers company or market data, and prod

HTTP Request, Google Drive, Google Docs +5
Data & Sheets

Agendamiento_v2. Uses n8n-nodes-evolution-api, redis, httpRequest, executeWorkflowTrigger. Event-driven trigger; 59 nodes.

N8N Nodes Evolution Api, Redis, HTTP Request +3
Data & Sheets

Cancelacion_v2. Uses executeWorkflowTrigger, redis, httpRequest, n8n-nodes-evolution-api. Event-driven trigger; 46 nodes.

Execute Workflow Trigger, Redis, HTTP Request +3
Data & Sheets

Components. Uses postgres, readWriteFile. Event-driven trigger; 42 nodes.

Postgres, Read Write File
Data & Sheets

This N8N workflow is designed to enrich seller data stored in a Postgres database by performing automated Google search lookups. It uses Bright Data's Web Unlocker to bypass search result restrictions

Postgres, N8N Nodes Brightdata