If you run a payment processing SaaS, banking-as-a-service platform, or spend management company, you already know the tension: every automation you add has to survive PCI DSS audits, BSA/AML reviews, and GDPR Art.28 sub-processor scrutiny.
Zapier and Make solve the convenience problem. They don't solve the compliance problem. Every payment event, merchant credential, and SAR-adjacent signal that flows through their cloud adds an unapproved data processor to your cardholder data environment — which is exactly what PCI DSS Level 1 prohibits.
Self-hosted n8n keeps all of that inside your VPC. Here are five production-grade workflows built for FinTech SaaS vendors, each with complete import-ready JSON.
Why FinTech SaaS Vendors Choose Self-Hosted n8n
| Requirement | Zapier/Make | Self-hosted n8n |
|---|---|---|
| PCI DSS Level 1 (cardholder data) | Not authorized for CDE | Runs inside your own CDE |
| BSA/AML SAR data isolation | Routes through commercial cloud | Air-gapped on your infra |
| GDPR Art.28 sub-processor | Adds Zapier to every customer DPA | No sub-processor added |
| SOC2 CM-3 workflow versioning | No git integration | JSON in your repo, PR-reviewed |
| Volume economics at scale | $40K+/mo at 100M events | ~$40/mo VPS |
| NACHA/SWIFT audit trail | External SaaS logs | Postgres in your own VPC |
Workflow 1: Payment Job Pipeline Health Monitor
The problem: A stuck payment job at 2 AM means delayed settlements, chargebacks, and angry merchants by morning.
The workflow: Every 5 minutes, query your payment_jobs table for jobs that are overdue against their SLA. Classify as CRITICAL (failed or elapsed > SLA_minutes) or DEGRADED (pending > 2× expected processing time). Use $getWorkflowStaticData to suppress duplicate alerts for the same job within a 30-minute window. Log every event to Postgres for audit.
{
"name": "Payment Job Pipeline Health Monitor",
"nodes": [
{
"name": "Every 5 Minutes",
"type": "n8n-nodes-base.scheduleTrigger",
"parameters": { "rule": { "interval": [{ "field": "minutes", "minutesInterval": 5 }] } },
"position": [250, 300]
},
{
"name": "Query Overdue Payment Jobs",
"type": "n8n-nodes-base.postgres",
"parameters": {
"operation": "executeQuery",
"query": "SELECT job_id, merchant_id, job_type, status, created_at, sla_minutes, EXTRACT(EPOCH FROM (NOW() - created_at))/60 AS elapsed_minutes FROM payment_jobs WHERE status IN ('pending','processing','failed') AND created_at > NOW() - INTERVAL '4 hours' ORDER BY elapsed_minutes DESC LIMIT 100"
},
"position": [450, 300]
},
{
"name": "Classify Job Status",
"type": "n8n-nodes-base.code",
"parameters": {
"jsCode": "const staticData = $getWorkflowStaticData('global');\nconst alertedJobs = staticData.alertedJobs || {};\nconst now = Date.now();\nconst DEDUP_WINDOW = 30 * 60 * 1000;\nreturn $input.all().map(item => {\n const j = item.json;\n const elapsed = parseFloat(j.elapsed_minutes);\n let severity = null;\n if (j.status === 'failed' || elapsed > j.sla_minutes) severity = 'CRITICAL';\n else if (elapsed > j.sla_minutes * 2) severity = 'DEGRADED';\n if (!severity) return null;\n const key = j.job_id + '_' + severity;\n if (alertedJobs[key] && now - alertedJobs[key] < DEDUP_WINDOW) return null;\n alertedJobs[key] = now;\n staticData.alertedJobs = alertedJobs;\n return { json: { ...j, severity, elapsed_min: Math.round(elapsed) } };\n}).filter(Boolean);"
},
"position": [650, 300]
},
{
"name": "Filter Non-OK",
"type": "n8n-nodes-base.filter",
"parameters": { "conditions": { "string": [{ "value1": "={{ $json.severity }}", "operation": "isNotEmpty" }] } },
"position": [850, 300]
},
{
"name": "Slack #platform-ops",
"type": "n8n-nodes-base.slack",
"parameters": {
"channel": "#platform-ops",
"text": "={{ $json.severity === 'CRITICAL' ? ':rotating_light:' : ':warning:' }} *{{ $json.severity }}* payment job | Merchant: `{{ $json.merchant_id }}` | Type: `{{ $json.job_type }}` | Elapsed: {{ $json.elapsed_min }}min | Job ID: `{{ $json.job_id }}`"
},
"position": [1050, 200]
},
{
"name": "Log to Postgres",
"type": "n8n-nodes-base.postgres",
"parameters": {
"operation": "executeQuery",
"query": "INSERT INTO pipeline_health_events (job_id, merchant_id, job_type, severity, elapsed_minutes, alerted_at) VALUES ('{{ $json.job_id }}', '{{ $json.merchant_id }}', '{{ $json.job_type }}', '{{ $json.severity }}', {{ $json.elapsed_min }}, NOW()) ON CONFLICT (job_id, severity) DO UPDATE SET alerted_at = NOW()"
},
"position": [1050, 400]
}
]
}
Workflow 2: New Merchant/Bank Client Onboarding Drip
The problem: Manually sending API keys, sandbox invites, and milestone check-ins across hundreds of new clients per month doesn't scale.
The workflow: When a new client row appears in your onboarding sheet, classify them by type (enterprise bank, fintech startup, direct merchant), then trigger a multi-touch sequence: Day 0 sends API credentials and sandbox access, Day 3 checks if they've processed their first test transaction, Day 7 delivers the reconciliation guide and go-live checklist.
{
"name": "FinTech Client Onboarding Drip",
"nodes": [
{
"name": "New Client in Sheet",
"type": "n8n-nodes-base.googleSheetsTrigger",
"parameters": { "sheetId": "YOUR_SHEET_ID", "event": "rowAdded" },
"position": [250, 300]
},
{
"name": "Classify Client Tier",
"type": "n8n-nodes-base.code",
"parameters": {
"jsCode": "const c = $input.first().json;\nconst type = (c.client_type || '').toUpperCase();\nconst volume = parseFloat(c.monthly_volume_usd) || 0;\nlet tier = 'MERCHANT';\nif (type.includes('BANK') && volume > 10000000) tier = 'ENTERPRISE_BANK';\nelse if (type.includes('BANK')) tier = 'MID_MARKET_BANK';\nelse if (type.includes('FINTECH') || type.includes('STARTUP')) tier = 'FINTECH_STARTUP';\nreturn [{ json: { ...c, tier } }];"
},
"position": [450, 300]
},
{
"name": "Day 0 — API Creds + Sandbox",
"type": "n8n-nodes-base.gmail",
"parameters": {
"toList": "={{ $json.technical_contact_email }}",
"subject": "Your {{ $json.tier === 'ENTERPRISE_BANK' ? 'Enterprise' : '' }} API credentials and sandbox access",
"message": "Hi {{ $json.contact_name }},\n\nWelcome to [Platform]. Your sandbox API key: {{ $json.sandbox_api_key }}\nWebhook signing secret: {{ $json.signing_secret }}\n\nSandbox dashboard: https://sandbox.platform.com\n\nYour CSM {{ $json.csm_name }} will reach out shortly.\n\nBest,\nThe [Platform] Team"
},
"position": [650, 200]
},
{
"name": "Slack CSM DM",
"type": "n8n-nodes-base.slack",
"parameters": {
"channel": "={{ $json.csm_slack_id }}",
"text": ":new: New {{ $json.tier }} client onboarded: *{{ $json.company_name }}* | Contact: {{ $json.contact_name }} | Volume: ${{ $json.monthly_volume_usd }}/mo"
},
"position": [650, 400]
},
{
"name": "Wait 3 Days",
"type": "n8n-nodes-base.wait",
"parameters": { "amount": 3, "unit": "days" },
"position": [850, 300]
},
{
"name": "Day 3 — First Transaction Check-in",
"type": "n8n-nodes-base.gmail",
"parameters": {
"toList": "={{ $json.technical_contact_email }}",
"subject": "Quick check-in — have you processed your first test transaction?",
"message": "Hi {{ $json.contact_name }},\n\nJust checking in — have you had a chance to process your first sandbox transaction? If you run into any issues with the API, your CSM {{ $json.csm_name }} is ready to jump on a call.\n\nCommon first steps: https://docs.platform.com/quickstart"
},
"position": [1050, 200]
},
{
"name": "Wait 4 More Days",
"type": "n8n-nodes-base.wait",
"parameters": { "amount": 4, "unit": "days" },
"position": [1050, 400]
},
{
"name": "Day 7 — Reconciliation Guide + Go-Live",
"type": "n8n-nodes-base.gmail",
"parameters": {
"toList": "={{ $json.technical_contact_email }}",
"subject": "Your go-live checklist and reconciliation guide",
"message": "Hi {{ $json.contact_name }},\n\nYou've had a full week with the sandbox. Attached is your production go-live checklist and reconciliation guide.\n\nGo-live checklist: https://docs.platform.com/go-live\nReconciliation guide: https://docs.platform.com/reconciliation"
},
"position": [1250, 300]
},
{
"name": "Mark Onboarding Complete",
"type": "n8n-nodes-base.googleSheets",
"parameters": {
"operation": "update",
"sheetId": "YOUR_SHEET_ID",
"columns": { "mappingMode": "defineBelow", "value": { "onboarding_complete": true, "onboarded_at": "={{ $now.toISO() }}" } }
},
"position": [1450, 300]
}
]
}
Workflow 3: FinTech Regulatory & Compliance Deadline Tracker
The problem: Missing a PCI DSS QSA deadline or an MTL renewal triggers regulatory action — not just internal embarrassment.
The workflow: Every weekday at 8 AM, scan your compliance deadlines sheet. Classify each by urgency tier. Look up the required action for each regulation type, then route alerts to the right Slack channel and owner. Log everything to Postgres for audit evidence.
{
"name": "FinTech Compliance Deadline Tracker",
"nodes": [
{
"name": "Weekdays 8 AM",
"type": "n8n-nodes-base.scheduleTrigger",
"parameters": { "rule": { "interval": [{ "field": "cronExpression", "expression": "0 8 * * 1-5" }] } },
"position": [250, 300]
},
{
"name": "Load Compliance Deadlines",
"type": "n8n-nodes-base.googleSheets",
"parameters": { "operation": "getAll", "sheetId": "YOUR_COMPLIANCE_SHEET_ID" },
"position": [450, 300]
},
{
"name": "Classify + Action Lookup",
"type": "n8n-nodes-base.code",
"parameters": {
"jsCode": "const actionMap = {\n PCI_DSS_QSA: 'Submit QSA assessment report to card brands and acquiring bank',\n SOC2_EVIDENCE: 'Compile evidence package — access logs, change tickets, incident reports',\n BSA_SAR_FILING: 'File SAR with FinCEN within 30 days of detection (31 USC 5318(g))',\n FINCEN_CTR: 'File Currency Transaction Report for cash transactions >$10K (31 USC 5313)',\n GDPR_DPA_RENEWAL: 'Review and renew Data Processing Agreements with EU customers',\n NACHA_AUDIT: 'Complete NACHA Operating Rules compliance self-audit',\n SWIFT_CSCF: 'Complete SWIFT Customer Security Controls Framework mandatory controls',\n STATE_MTL_RENEWAL: 'Submit state money transmitter license renewal application + surety bond update',\n FINRA_EXAM_PREP: 'Prepare for FINRA examination — transaction records, AML program docs'\n};\nreturn $input.all().map(item => {\n const d = item.json;\n if (!d.deadline_date || d.status === 'COMPLETE') return null;\n const daysLeft = Math.floor((new Date(d.deadline_date) - new Date()) / 86400000);\n let urgency;\n if (daysLeft < 0) urgency = 'OVERDUE';\n else if (daysLeft <= 7) urgency = 'CRITICAL';\n else if (daysLeft <= 21) urgency = 'URGENT';\n else if (daysLeft <= 60) urgency = 'WARNING';\n else if (daysLeft <= 90) urgency = 'NOTICE';\n else return null;\n const action = actionMap[d.regulation_type] || 'Review compliance requirement and confirm status';\n return { json: { ...d, daysLeft, urgency, action } };\n}).filter(Boolean);"
},
"position": [650, 300]
},
{
"name": "Route by Urgency",
"type": "n8n-nodes-base.switch",
"parameters": {
"dataType": "string",
"value1": "={{ $json.urgency }}",
"rules": {
"rules": [
{ "value2": "OVERDUE", "output": 0 },
{ "value2": "CRITICAL", "output": 0 },
{ "value2": "URGENT", "output": 1 },
{ "value2": "WARNING", "output": 2 },
{ "value2": "NOTICE", "output": 3 }
]
}
},
"position": [850, 300]
},
{
"name": "Slack #compliance-ops @here",
"type": "n8n-nodes-base.slack",
"parameters": {
"channel": "#compliance-ops",
"text": "<!here> :rotating_light: *{{ $json.urgency }}* compliance deadline | Regulation: *{{ $json.regulation_type }}* | {{ $json.daysLeft < 0 ? Math.abs($json.daysLeft) + ' days OVERDUE' : $json.daysLeft + ' days remaining' }} | Owner: {{ $json.owner }} | Action: {{ $json.action }}"
},
"position": [1050, 150]
},
{
"name": "Gmail Compliance Owner",
"type": "n8n-nodes-base.gmail",
"parameters": {
"toList": "={{ $json.owner_email }}",
"subject": "[{{ $json.urgency }}] Compliance deadline: {{ $json.regulation_type }} — {{ $json.daysLeft < 0 ? 'OVERDUE' : $json.daysLeft + ' days' }}",
"message": "{{ $json.regulation_name }} deadline approaching.\n\nDeadline: {{ $json.deadline_date }}\nDays remaining: {{ $json.daysLeft }}\nRequired action: {{ $json.action }}\n\nPlease update the compliance tracker once complete."
},
"position": [1050, 350]
},
{
"name": "Log to Postgres",
"type": "n8n-nodes-base.postgres",
"parameters": {
"operation": "executeQuery",
"query": "INSERT INTO compliance_alert_log (regulation_type, urgency, days_left, owner, alerted_at) VALUES ('{{ $json.regulation_type }}', '{{ $json.urgency }}', {{ $json.daysLeft }}, '{{ $json.owner }}', NOW())"
},
"position": [1050, 550]
}
]
}
Workflow 4: Transaction Anomaly & Fraud Signal Alert Pipeline
The problem: Chargebacks and dispute spikes need to hit your fraud ops team before the merchant notices, not after.
The workflow: Receive webhooks for payment events (chargebacks, disputes, velocity anomalies). Classify each by risk level — HIGH_RISK for chargebacks and disputes over $10K, ELEVATED for velocity anomalies. Deduplicate per merchant within a 15-minute window. Route CRITICAL signals to Slack and email the merchant, log all events to Postgres.
{
"name": "Transaction Anomaly Alert Pipeline",
"nodes": [
{
"name": "Payment Event Webhook",
"type": "n8n-nodes-base.webhook",
"parameters": { "path": "payment-events", "responseMode": "onReceived", "httpMethod": "POST" },
"position": [250, 300]
},
{
"name": "Classify Risk Level",
"type": "n8n-nodes-base.code",
"parameters": {
"jsCode": "const ev = $input.first().json.body || $input.first().json;\nconst staticData = $getWorkflowStaticData('global');\nconst alertedMerchants = staticData.alertedMerchants || {};\nconst now = Date.now();\nconst DEDUP_WINDOW = 15 * 60 * 1000;\nconst amount = parseFloat(ev.amount_usd) || 0;\nconst eventType = ev.event_type || '';\nlet riskLevel = null;\nif (['chargeback.initiated', 'dispute.opened'].includes(eventType) && amount > 10000) riskLevel = 'HIGH_RISK';\nelse if (['chargeback.initiated', 'dispute.opened'].includes(eventType)) riskLevel = 'ELEVATED';\nelse if (eventType === 'velocity.anomaly') riskLevel = 'ELEVATED';\nif (!riskLevel) return [];\nconst key = ev.merchant_id + '_' + riskLevel;\nif (alertedMerchants[key] && now - alertedMerchants[key] < DEDUP_WINDOW) return [];\nalertedMerchants[key] = now;\nstaticData.alertedMerchants = alertedMerchants;\nreturn [{ json: { ...ev, riskLevel, amount } }];"
},
"position": [450, 300]
},
{
"name": "Route by Risk",
"type": "n8n-nodes-base.switch",
"parameters": {
"dataType": "string",
"value1": "={{ $json.riskLevel }}",
"rules": { "rules": [{ "value2": "HIGH_RISK", "output": 0 }, { "value2": "ELEVATED", "output": 1 }] }
},
"position": [650, 300]
},
{
"name": "Slack #fraud-ops",
"type": "n8n-nodes-base.slack",
"parameters": {
"channel": "#fraud-ops",
"text": ":red_circle: *{{ $json.riskLevel }}* | {{ $json.event_type }} | Merchant: `{{ $json.merchant_id }}` | Amount: ${{ $json.amount }} | Transaction: `{{ $json.transaction_id }}`"
},
"position": [850, 200]
},
{
"name": "Gmail Merchant Alert",
"type": "n8n-nodes-base.gmail",
"parameters": {
"toList": "={{ $json.merchant_email }}",
"subject": "Action required: {{ $json.event_type === 'chargeback.initiated' ? 'Chargeback initiated' : 'Dispute opened' }} — ${{ $json.amount }}",
"message": "A {{ $json.event_type }} has been filed against transaction {{ $json.transaction_id }} for ${{ $json.amount }}.\n\nPlease log in to your dashboard to review and respond within the required timeframe."
},
"position": [850, 400]
},
{
"name": "Log to Postgres",
"type": "n8n-nodes-base.postgres",
"parameters": {
"operation": "executeQuery",
"query": "INSERT INTO fraud_signal_log (transaction_id, merchant_id, event_type, risk_level, amount_usd, raw_payload, logged_at) VALUES ('{{ $json.transaction_id }}', '{{ $json.merchant_id }}', '{{ $json.event_type }}', '{{ $json.riskLevel }}', {{ $json.amount }}, '{{ JSON.stringify($json) }}'::jsonb, NOW())"
},
"position": [850, 600]
}
]
}
Workflow 5: Weekly FinTech Platform KPI Dashboard
The problem: Manually pulling payment volume, chargeback rates, and MRR from multiple tables every Monday morning is an hour of engineering time that should be zero.
The workflow: Every Monday at 8 AM, run two parallel Postgres queries for this week and last week. Merge the results, compute week-over-week deltas for key FinTech metrics, and email a color-coded HTML dashboard to leadership.
{
"name": "Weekly FinTech Platform KPI Dashboard",
"nodes": [
{
"name": "Monday 8 AM",
"type": "n8n-nodes-base.scheduleTrigger",
"parameters": { "rule": { "interval": [{ "field": "cronExpression", "expression": "0 8 * * 1" }] } },
"position": [250, 300]
},
{
"name": "This Week KPIs",
"type": "n8n-nodes-base.postgres",
"parameters": {
"operation": "executeQuery",
"query": "SELECT COUNT(DISTINCT merchant_id) AS active_merchants, SUM(amount_usd) AS total_payment_volume, ROUND(100.0 * SUM(CASE WHEN status='succeeded' THEN 1 ELSE 0 END)/COUNT(*), 2) AS success_rate_pct, COUNT(CASE WHEN event_type='chargeback.initiated' THEN 1 END) AS chargebacks, SUM(mrr_usd) AS mrr FROM payments LEFT JOIN merchants USING(merchant_id) WHERE created_at >= NOW() - INTERVAL '7 days'"
},
"position": [450, 200]
},
{
"name": "Last Week KPIs",
"type": "n8n-nodes-base.postgres",
"parameters": {
"operation": "executeQuery",
"query": "SELECT COUNT(DISTINCT merchant_id) AS active_merchants_prev, SUM(amount_usd) AS total_payment_volume_prev, ROUND(100.0 * SUM(CASE WHEN status='succeeded' THEN 1 ELSE 0 END)/COUNT(*), 2) AS success_rate_prev, COUNT(CASE WHEN event_type='chargeback.initiated' THEN 1 END) AS chargebacks_prev, SUM(mrr_usd) AS mrr_prev FROM payments LEFT JOIN merchants USING(merchant_id) WHERE created_at >= NOW() - INTERVAL '14 days' AND created_at < NOW() - INTERVAL '7 days'"
},
"position": [450, 400]
},
{
"name": "Merge",
"type": "n8n-nodes-base.merge",
"parameters": { "mode": "combine", "combinationMode": "mergeByIndex" },
"position": [650, 300]
},
{
"name": "Compute WoW Deltas",
"type": "n8n-nodes-base.code",
"parameters": {
"jsCode": "const d = $input.first().json;\nconst wow = (curr, prev) => prev > 0 ? ((curr - prev) / prev * 100).toFixed(1) + '%' : 'N/A';\nconst fmt = (n) => n ? '$' + Number(n).toLocaleString('en-US', {maximumFractionDigits:0}) : '$0';\nreturn [{ json: {\n active_merchants: d.active_merchants,\n active_merchants_wow: wow(d.active_merchants, d.active_merchants_prev),\n total_payment_volume: fmt(d.total_payment_volume),\n volume_wow: wow(d.total_payment_volume, d.total_payment_volume_prev),\n success_rate_pct: d.success_rate_pct + '%',\n success_rate_wow: wow(d.success_rate_pct, d.success_rate_prev),\n chargebacks: d.chargebacks,\n chargebacks_wow: wow(d.chargebacks, d.chargebacks_prev),\n mrr: fmt(d.mrr),\n mrr_wow: wow(d.mrr, d.mrr_prev)\n} }];"
},
"position": [850, 300]
},
{
"name": "HTML Email to Leadership",
"type": "n8n-nodes-base.gmail",
"parameters": {
"toList": "ceo@company.com",
"bccList": "cfo@company.com,cto@company.com,vp-risk@company.com",
"subject": "Weekly FinTech Platform KPIs — {{ $now.format('MMM D, YYYY') }}",
"message": "<h2>Weekly FinTech Platform KPIs</h2><table border='1' cellpadding='8' style='border-collapse:collapse'><tr><th>Metric</th><th>This Week</th><th>WoW</th></tr><tr><td>Active Merchants</td><td>{{ $json.active_merchants }}</td><td>{{ $json.active_merchants_wow }}</td></tr><tr><td>Payment Volume</td><td>{{ $json.total_payment_volume }}</td><td>{{ $json.volume_wow }}</td></tr><tr><td>Success Rate</td><td>{{ $json.success_rate_pct }}</td><td>{{ $json.success_rate_wow }}</td></tr><tr><td>Chargebacks</td><td>{{ $json.chargebacks }}</td><td>{{ $json.chargebacks_wow }}</td></tr><tr><td>MRR</td><td>{{ $json.mrr }}</td><td>{{ $json.mrr_wow }}</td></tr></table>",
"options": { "bodyContentType": "html" }
},
"position": [1050, 300]
}
]
}
The FinTech SaaS Self-Hosted Case in One Number
A mid-size payment processor handling $10M/day in transactions generates roughly 60–80 million webhook events per month. At Zapier's Business plan pricing, that's approximately $40,000–55,000/month. On a $40/month VPS running self-hosted n8n, it's $40/month.
That's before you count the PCI DSS audit finding you avoid by keeping cardholder data environment events off Zapier's cloud.
Get These Workflows
All five workflows above are adapted from the full FlowKit n8n Template Library at stripeai.gumroad.com.
Templates include complete JSON ready to import, setup guides, and variable configuration checklists — so you're shipping automations in minutes, not building from scratch.
Built these patterns for your own FinTech stack? Drop your variations in the comments — always interested in how payment ops teams are adapting these.
Top comments (0)