TravelTech SaaS vendors—OTAs, hotel property management systems, booking APIs, travel management companies—run on high-volume, time-sensitive data. A missed booking confirmation, a rate parity gap, or a failed PCI-DSS audit can mean lost contracts and chargeback liability.
Zapier and Make.com work fine for simple triggers. But when your booking API processes 50,000+ transactions daily, routing that data through a third-party SaaS creates real risk: PCI-DSS scope expansion, GDPR Art. 28 sub-processor obligations, and NDC/GDS latency you can't explain to partners.
Here are 5 production-grade n8n workflows TravelTech SaaS vendors use to scale operations while keeping booking and guest data in-house.
1. New Hotel/OTA Customer Onboarding Drip
Why it matters: A hotel tech vendor's first 14 days with a new property client determines 60-day activation rate. Manual onboarding doesn't scale past 50 clients.
{
"name": "TravelTech — New Customer Onboarding Drip",
"nodes": [
{
"id": "1",
"name": "New Client Trigger",
"type": "n8n-nodes-base.sheetsTrigger",
"parameters": {
"operation": "appendOrUpdate",
"event": "rowAdded"
},
"position": [
0,
0
]
},
{
"id": "2",
"name": "Classify Tier",
"type": "n8n-nodes-base.code",
"parameters": {
"jsCode": "const row = $json;\nconst rooms = parseInt(row.property_rooms || 0);\nconst tier = rooms >= 200 ? 'ENTERPRISE' : rooms >= 50 ? 'MID_MARKET' : rooms >= 10 ? 'BOUTIQUE' : 'INDEPENDENT';\nreturn [{ json: { ...row, tier, csm: tier === 'ENTERPRISE' ? row.enterprise_csm : row.smb_csm, day0_subject: `Welcome to [Product] — your ${tier} onboarding starts now` } }];"
},
"position": [
200,
0
]
},
{
"id": "3",
"name": "Day 0 — API Key + PMS Setup",
"type": "n8n-nodes-base.gmail",
"parameters": {
"operation": "send",
"toEmail": "={{ $json.contact_email }}",
"subject": "={{ $json.day0_subject }}",
"message": "Hi {{ $json.property_name }} team,\n\nWelcome! Your API key is ready: {{ $json.api_key }}\n\nNext step: connect your PMS in the dashboard → Settings → PMS Integration.\n\nBooking a 30-min setup call: {{ $json.calendly_link }}\n\nYour CSM: {{ $json.csm }}"
},
"position": [
400,
0
]
},
{
"id": "4",
"name": "Slack CSM Alert",
"type": "n8n-nodes-base.slack",
"parameters": {
"channel": "#new-clients",
"text": "New {{ $json.tier }} client: {{ $json.property_name }} ({{ $json.property_rooms }} rooms). CSM: {{ $json.csm }}. PMS: {{ $json.pms_type }}."
},
"position": [
400,
200
]
},
{
"id": "5",
"name": "Wait 3 days",
"type": "n8n-nodes-base.wait",
"parameters": {
"amount": 3,
"unit": "days"
},
"position": [
600,
0
]
},
{
"id": "6",
"name": "Day 3 — PMS Check-In",
"type": "n8n-nodes-base.gmail",
"parameters": {
"operation": "send",
"toEmail": "={{ $json.contact_email }}",
"subject": "Day 3 check-in — is your PMS syncing?",
"message": "Hi {{ $json.property_name }} team,\n\nIt's been 3 days since your API key was issued. Quick check:\n\n✅ PMS connected → you should see bookings flowing in the dashboard\n⚠️ Not connected yet → here's the 5-min setup guide: [link]\n\nAny issues? Reply here or reach {{ $json.csm }} directly."
},
"position": [
800,
0
]
},
{
"id": "7",
"name": "Wait 4 days",
"type": "n8n-nodes-base.wait",
"parameters": {
"amount": 4,
"unit": "days"
},
"position": [
1000,
0
]
},
{
"id": "8",
"name": "Day 7 — First Booking Milestone",
"type": "n8n-nodes-base.gmail",
"parameters": {
"operation": "send",
"toEmail": "={{ $json.contact_email }}",
"subject": "Your first 7 days with [Product] — what's next",
"message": "Hi {{ $json.property_name }} team,\n\nYou're one week in. If your PMS is connected, you should have your first automated booking confirmations running.\n\nWeek 2 goal: set up your rate rules and channel manager sync.\n\nHere's a 10-min walkthrough: [link]\n\nWe're here if you need us."
},
"position": [
1200,
0
]
},
{
"id": "9",
"name": "Log to Airtable",
"type": "n8n-nodes-base.airtable",
"parameters": {
"operation": "create",
"baseId": "YOUR_BASE_ID",
"tableId": "onboarding_log",
"fields": {
"property_name": "={{ $json.property_name }}",
"tier": "={{ $json.tier }}",
"onboarding_started": "={{ new Date().toISOString() }}",
"status": "drip_active"
}
},
"position": [
1400,
0
]
}
],
"connections": {
"New Client Trigger": {
"main": [
[
{
"node": "Classify Tier"
}
]
]
},
"Classify Tier": {
"main": [
[
{
"node": "Day 0 — API Key + PMS Setup"
},
{
"node": "Slack CSM Alert"
}
]
]
},
"Day 0 — API Key + PMS Setup": {
"main": [
[
{
"node": "Wait 3 days"
}
]
]
},
"Wait 3 days": {
"main": [
[
{
"node": "Day 3 — PMS Check-In"
}
]
]
},
"Day 3 — PMS Check-In": {
"main": [
[
{
"node": "Wait 4 days"
}
]
]
},
"Wait 4 days": {
"main": [
[
{
"node": "Day 7 — First Booking Milestone"
}
]
]
},
"Day 7 — First Booking Milestone": {
"main": [
[
{
"node": "Log to Airtable"
}
]
]
}
}
}
Why self-host: Your onboarding sequence contains property contact emails, API keys, and PMS credentials. Routing through Zapier or Make.com means those credentials transit an external sub-processor—GDPR Art. 28 requires a DPA, and PCI-DSS scope may expand if the PMS is payment-adjacent.
2. Booking API Health Monitor
Why it matters: A booking API that's down for 10 minutes during peak hours (Friday 5-7pm) can cost a mid-size OTA $50K+ in lost bookings. You need to know before your clients do.
{
"name": "TravelTech — Booking API Health Monitor",
"nodes": [
{
"id": "1",
"name": "Every 3 Minutes",
"type": "n8n-nodes-base.scheduleTrigger",
"parameters": {
"rule": {
"interval": [
{
"field": "minutes",
"minutesInterval": 3
}
]
}
},
"position": [
0,
0
]
},
{
"id": "2",
"name": "Poll API Endpoints",
"type": "n8n-nodes-base.httpRequest",
"parameters": {
"method": "GET",
"url": "https://api.yourplatform.com/health",
"timeout": 8000
},
"position": [
200,
0
]
},
{
"id": "3",
"name": "Check Response",
"type": "n8n-nodes-base.code",
"parameters": {
"jsCode": "const status = $json.status;\nconst latency = $json.latency_ms || 0;\nconst prev = $getWorkflowStaticData('global');\nconst prevStatus = prev.booking_api_status || 'UP';\nconst now = new Date().toISOString();\nlet alert = null;\nif (status !== 'UP' && prevStatus === 'UP') { alert = { type: 'DOWN', message: `Booking API DOWN at ${now}. Last status: ${prevStatus}` }; }\nif (latency > 2000 && prevStatus !== 'DEGRADED') { alert = { type: 'DEGRADED', message: `Booking API slow: ${latency}ms at ${now}` }; }\nprev.booking_api_status = status;\nprev.last_checked = now;\n$setWorkflowStaticData('global', prev);\nreturn [{ json: { alert, status, latency, prevStatus, now } }];"
},
"position": [
400,
0
]
},
{
"id": "4",
"name": "Alert?",
"type": "n8n-nodes-base.if",
"parameters": {
"conditions": {
"options": {
"caseSensitive": true
},
"conditions": [
{
"leftValue": "={{ $json.alert }}",
"operator": {
"type": "string",
"operation": "isNotEmpty"
}
}
]
}
},
"position": [
600,
0
]
},
{
"id": "5",
"name": "Slack #platform-ops",
"type": "n8n-nodes-base.slack",
"parameters": {
"channel": "#platform-ops",
"text": "🚨 {{ $json.alert.type }}: {{ $json.alert.message }}\n\nBooking API health: https://status.yourplatform.com"
},
"position": [
800,
-100
]
},
{
"id": "6",
"name": "Log to Postgres",
"type": "n8n-nodes-base.postgres",
"parameters": {
"operation": "insert",
"table": "api_incidents",
"columns": "type,message,status,latency_ms,occurred_at",
"values": "={{ $json.alert.type }},{{ $json.alert.message }},{{ $json.status }},{{ $json.latency }},{{ $json.now }}"
},
"position": [
800,
100
]
}
],
"connections": {
"Every 3 Minutes": {
"main": [
[
{
"node": "Poll API Endpoints"
}
]
]
},
"Poll API Endpoints": {
"main": [
[
{
"node": "Check Response"
}
]
]
},
"Check Response": {
"main": [
[
{
"node": "Alert?"
}
]
]
},
"Alert?": {
"main": [
[
{
"node": "Slack #platform-ops"
}
],
[
{
"node": "Log to Postgres"
}
]
]
}
}
}
Production note: The $getWorkflowStaticData call prevents alert storms—you only get one Slack ping when the API transitions from UP to DOWN, not one per 3-minute check. Extend to poll your GDS or NDC endpoints by duplicating the HTTP Request node.
3. OTA Rate Parity Violation Alert
Why it matters: Hotels and OTAs have rate parity agreements. A rate mismatch on your platform vs. a competitor's channel triggers chargebacks, partner complaints, and potential contract termination.
{
"name": "TravelTech — Rate Parity Monitor",
"nodes": [
{
"id": "1",
"name": "Every Hour",
"type": "n8n-nodes-base.scheduleTrigger",
"parameters": {
"rule": {
"interval": [
{
"field": "hours",
"hoursInterval": 1
}
]
}
},
"position": [
0,
0
]
},
{
"id": "2",
"name": "Get Rate Sheet",
"type": "n8n-nodes-base.postgres",
"parameters": {
"operation": "select",
"query": "SELECT property_id, property_name, channel, rate_usd, checkin_date FROM rate_snapshot WHERE snapshot_date = CURRENT_DATE ORDER BY property_id, checkin_date"
},
"position": [
200,
0
]
},
{
"id": "3",
"name": "Check Parity Violations",
"type": "n8n-nodes-base.code",
"parameters": {
"jsCode": "const rows = $input.all().map(r => r.json);\nconst byPropertyDate = {};\nfor (const row of rows) {\n const key = `${row.property_id}__${row.checkin_date}`;\n if (!byPropertyDate[key]) byPropertyDate[key] = { property_name: row.property_name, rates: {} };\n byPropertyDate[key].rates[row.channel] = parseFloat(row.rate_usd);\n}\nconst violations = [];\nfor (const [key, data] of Object.entries(byPropertyDate)) {\n const rates = Object.entries(data.rates);\n if (rates.length < 2) continue;\n const rateValues = rates.map(r => r[1]);\n const min = Math.min(...rateValues); const max = Math.max(...rateValues);\n const pctDiff = ((max - min) / min) * 100;\n if (pctDiff > 2) {\n violations.push({ property_name: data.property_name, checkin_date: key.split('__')[1], min_rate: min, max_rate: max, pct_diff: pctDiff.toFixed(1), channels: data.rates });\n }\n}\nif (violations.length === 0) return [{ json: { violations: [], count: 0 } }];\nreturn violations.map(v => ({ json: v }));"
},
"position": [
400,
0
]
},
{
"id": "4",
"name": "Any Violations?",
"type": "n8n-nodes-base.if",
"parameters": {
"conditions": {
"options": {
"caseSensitive": true
},
"conditions": [
{
"leftValue": "={{ $json.count }}",
"operator": {
"type": "number",
"operation": "notEquals",
"rightValue": 0
}
}
]
}
},
"position": [
600,
0
]
},
{
"id": "5",
"name": "Slack #rate-ops",
"type": "n8n-nodes-base.slack",
"parameters": {
"channel": "#rate-ops",
"text": "⚠️ Rate parity violation: {{ $json.property_name }} on {{ $json.checkin_date }}\nMin: ${{ $json.min_rate }} / Max: ${{ $json.max_rate }} (+{{ $json.pct_diff }}%)\nChannels: {{ JSON.stringify($json.channels) }}"
},
"position": [
800,
-100
]
},
{
"id": "6",
"name": "Log Violation",
"type": "n8n-nodes-base.postgres",
"parameters": {
"operation": "insert",
"table": "parity_violations",
"columns": "property_name,checkin_date,min_rate,max_rate,pct_diff,detected_at",
"values": "={{ $json.property_name }},{{ $json.checkin_date }},{{ $json.min_rate }},{{ $json.max_rate }},{{ $json.pct_diff }},{{ new Date().toISOString() }}"
},
"position": [
800,
100
]
}
],
"connections": {
"Every Hour": {
"main": [
[
{
"node": "Get Rate Sheet"
}
]
]
},
"Get Rate Sheet": {
"main": [
[
{
"node": "Check Parity Violations"
}
]
]
},
"Check Parity Violations": {
"main": [
[
{
"node": "Any Violations?"
}
]
]
},
"Any Violations?": {
"main": [
[
{
"node": "Slack #rate-ops"
},
{
"node": "Log Violation"
}
],
[]
]
}
}
}
Adapt for your stack: Replace the Postgres query with an HTTP Request to your internal rate API or channel manager webhook. The violation threshold (>2%) is configurable in the Code node.
4. PCI-DSS & Travel Regulation Compliance Deadline Tracker
Why it matters: TravelTech SaaS that handles payment cards must maintain PCI-DSS compliance. Missing a quarterly SAQ-D submission or annual penetration test can trigger fines and card brand audits. Add GDPR, PSD2, and IATA NDC certification deadlines and manual tracking becomes a full-time job.
{
"name": "TravelTech — Compliance Deadline Tracker",
"nodes": [
{
"id": "1",
"name": "Weekdays 8 AM",
"type": "n8n-nodes-base.scheduleTrigger",
"parameters": {
"rule": {
"interval": [
{
"field": "cronExpression",
"expression": "0 8 * * 1-5"
}
]
}
},
"position": [
0,
0
]
},
{
"id": "2",
"name": "Get Deadlines",
"type": "n8n-nodes-base.googleSheets",
"parameters": {
"operation": "read",
"spreadsheetId": "YOUR_SHEET_ID",
"sheetName": "travel_compliance"
},
"position": [
200,
0
]
},
{
"id": "3",
"name": "Classify Urgency",
"type": "n8n-nodes-base.code",
"parameters": {
"jsCode": "const items = $input.all().map(r => r.json);\nconst today = new Date();\nconst actionMap = {\n PCI_DSS_SAQ_D: 'Submit Self-Assessment Questionnaire at merchantlink.visa.com — engage QSA if needed',\n PCI_DSS_PENTEST: 'Schedule penetration test with approved ASV — book 6 weeks out',\n PCI_DSS_ASV_SCAN: 'Run quarterly ASV vulnerability scan — schedule via SecurityMetrics or Trustwave',\n GDPR_DPA_REVIEW: 'Review Data Processing Agreements with sub-processors — update Annex I/II if processors changed',\n PSD2_SCA_AUDIT: 'Audit Strong Customer Authentication implementation — verify 3DS2 flows',\n IATA_NDC_RECERT: 'Submit NDC Level 3/4 recertification to IATA — gather test case evidence',\n SOC2_EVIDENCE: 'Collect SOC 2 Type II evidence — coordinate with auditor on control testing period',\n GDPR_DPIR: 'File annual Data Protection Impact Review — update DPIA register'\n};\nconst result = [];\nfor (const item of items) {\n const deadline = new Date(item.deadline_date);\n const daysLeft = Math.floor((deadline - today) / (1000*60*60*24));\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 urgency = 'OK';\n if (urgency !== 'OK') result.push({ ...item, daysLeft, urgency, action: actionMap[item.regulation_key] || item.custom_action });\n}\nreturn result.map(r => ({ json: r }));"
},
"position": [
400,
0
]
},
{
"id": "4",
"name": "Route by Urgency",
"type": "n8n-nodes-base.switch",
"parameters": {
"mode": "expression",
"output": "={{ $json.urgency }}",
"options": [
{
"outputKey": "OVERDUE"
},
{
"outputKey": "CRITICAL"
},
{
"outputKey": "URGENT"
},
{
"outputKey": "WARNING"
}
]
},
"position": [
600,
0
]
},
{
"id": "5",
"name": "Slack @here OVERDUE/CRITICAL",
"type": "n8n-nodes-base.slack",
"parameters": {
"channel": "#compliance",
"text": "🚨 {{ $json.urgency }}: {{ $json.regulation_name }} — {{ $json.daysLeft < 0 ? Math.abs($json.daysLeft) + ' days OVERDUE' : $json.daysLeft + ' days left' }}\n\nAction: {{ $json.action }}\nOwner: {{ $json.owner }}"
},
"position": [
800,
-200
]
},
{
"id": "6",
"name": "Gmail Owner URGENT/WARNING",
"type": "n8n-nodes-base.gmail",
"parameters": {
"operation": "send",
"toEmail": "={{ $json.owner_email }}",
"subject": "={{ $json.urgency }}: {{ $json.regulation_name }} deadline in {{ $json.daysLeft }} days",
"message": "Hi {{ $json.owner }},\n\nCompliance deadline approaching: {{ $json.regulation_name }}\nDue: {{ $json.deadline_date }} ({{ $json.daysLeft }} days)\nUrgency: {{ $json.urgency }}\n\nRequired action:\n{{ $json.action }}\n\nPlease confirm completion in the compliance tracker."
},
"position": [
800,
200
]
}
],
"connections": {
"Weekdays 8 AM": {
"main": [
[
{
"node": "Get Deadlines"
}
]
]
},
"Get Deadlines": {
"main": [
[
{
"node": "Classify Urgency"
}
]
]
},
"Classify Urgency": {
"main": [
[
{
"node": "Route by Urgency"
}
]
]
},
"Route by Urgency": {
"main": [
[
{
"node": "Slack @here OVERDUE/CRITICAL"
}
],
[
{
"node": "Slack @here OVERDUE/CRITICAL"
}
],
[
{
"node": "Gmail Owner URGENT/WARNING"
}
],
[
{
"node": "Gmail Owner URGENT/WARNING"
}
]
]
}
}
}
Regulation keys included: PCI_DSS_SAQ_D (quarterly), PCI_DSS_PENTEST (annual), PCI_DSS_ASV_SCAN (quarterly), GDPR_DPA_REVIEW (annual), PSD2_SCA_AUDIT (annual), IATA_NDC_RECERT (annual), SOC2_EVIDENCE (ongoing), GDPR_DPIR (annual).
Why Zapier is wrong for this: Your compliance deadline sheet contains regulation names, due dates, and owner emails. Routing that through an external automation SaaS creates an undocumented third-party data processor relationship—which itself may require a GDPR DPA.
5. Weekly TravelTech KPI Dashboard
Why it matters: Your CEO and investors want a weekly pulse on bookings processed, API uptime, new property activations, and churn. Manual Looker pulls every Monday morning don't scale.
{
"name": "TravelTech — Weekly KPI Dashboard",
"nodes": [
{
"id": "1",
"name": "Monday 8 AM",
"type": "n8n-nodes-base.scheduleTrigger",
"parameters": {
"rule": {
"interval": [
{
"field": "cronExpression",
"expression": "0 8 * * 1"
}
]
}
},
"position": [
0,
0
]
},
{
"id": "2",
"name": "Query Booking Metrics",
"type": "n8n-nodes-base.postgres",
"parameters": {
"operation": "executeQuery",
"query": "SELECT\n COUNT(*) FILTER (WHERE created_at >= NOW() - INTERVAL '7 days') AS bookings_this_week,\n COUNT(*) FILTER (WHERE created_at >= NOW() - INTERVAL '14 days' AND created_at < NOW() - INTERVAL '7 days') AS bookings_last_week,\n SUM(total_value_usd) FILTER (WHERE created_at >= NOW() - INTERVAL '7 days') AS gmv_this_week,\n SUM(total_value_usd) FILTER (WHERE created_at >= NOW() - INTERVAL '14 days' AND created_at < NOW() - INTERVAL '7 days') AS gmv_last_week,\n COUNT(DISTINCT property_id) FILTER (WHERE created_at >= NOW() - INTERVAL '7 days') AS active_properties\nFROM bookings"
},
"position": [
200,
-100
]
},
{
"id": "3",
"name": "Query Client Metrics",
"type": "n8n-nodes-base.postgres",
"parameters": {
"operation": "executeQuery",
"query": "SELECT\n COUNT(*) FILTER (WHERE created_at >= NOW() - INTERVAL '7 days') AS new_clients_this_week,\n COUNT(*) FILTER (WHERE churned_at >= NOW() - INTERVAL '7 days') AS churned_this_week,\n COUNT(*) FILTER (WHERE status = 'active') AS total_active_clients\nFROM clients"
},
"position": [
200,
100
]
},
{
"id": "4",
"name": "Merge Results",
"type": "n8n-nodes-base.merge",
"parameters": {
"mode": "combine",
"combinationMode": "mergeByIndex"
},
"position": [
400,
0
]
},
{
"id": "5",
"name": "Build KPI Report",
"type": "n8n-nodes-base.code",
"parameters": {
"jsCode": "const bm = $input.all()[0].json;\nconst cm = $input.all()[1]?.json || {};\nconst prev = $getWorkflowStaticData('global');\nconst bWoW = bm.bookings_last_week > 0 ? (((bm.bookings_this_week - bm.bookings_last_week) / bm.bookings_last_week) * 100).toFixed(1) : 'N/A';\nconst gWoW = bm.gmv_last_week > 0 ? (((bm.gmv_this_week - bm.gmv_last_week) / bm.gmv_last_week) * 100).toFixed(1) : 'N/A';\nprev.last_week = { bookings: bm.bookings_this_week, gmv: bm.gmv_this_week };\n$setWorkflowStaticData('global', prev);\nconst html = `<h2>TravelTech Weekly KPI — ${new Date().toDateString()}</h2><table border='1' cellpadding='6'><tr><th>Metric</th><th>This Week</th><th>WoW</th></tr><tr><td>Bookings Processed</td><td>${bm.bookings_this_week?.toLocaleString()}</td><td>${bWoW}%</td></tr><tr><td>GMV ($USD)</td><td>$${parseFloat(bm.gmv_this_week || 0).toLocaleString()}</td><td>${gWoW}%</td></tr><tr><td>Active Properties</td><td>${bm.active_properties?.toLocaleString()}</td><td>—</td></tr><tr><td>New Clients</td><td>${cm.new_clients_this_week || 0}</td><td>—</td></tr><tr><td>Churned Clients</td><td>${cm.churned_this_week || 0}</td><td>—</td></tr><tr><td>Total Active Clients</td><td>${cm.total_active_clients?.toLocaleString()}</td><td>—</td></tr></table>`;\nreturn [{ json: { html, bookings_this_week: bm.bookings_this_week, gmv_this_week: bm.gmv_this_week, bWoW, gWoW } }];"
},
"position": [
600,
0
]
},
{
"id": "6",
"name": "Email CEO + CRO",
"type": "n8n-nodes-base.gmail",
"parameters": {
"operation": "send",
"toEmail": "ceo@yourplatform.com",
"additionalFields": {
"bcc": "cro@yourplatform.com,cto@yourplatform.com"
},
"subject": "=[Week of ] + new Date().toDateString() + [ — TravelTech KPI]",
"message": "={{ $json.html }}",
"additionalOptions": {
"bodyContentType": "html"
}
},
"position": [
800,
-100
]
},
{
"id": "7",
"name": "Slack #exec-kpis",
"type": "n8n-nodes-base.slack",
"parameters": {
"channel": "#exec-kpis",
"text": "📊 Weekly KPI: {{ $json.bookings_this_week?.toLocaleString() }} bookings ({{ $json.bWoW }}% WoW) | GMV ${{ parseFloat($json.gmv_this_week || 0).toLocaleString() }} ({{ $json.gWoW }}% WoW). Full report in email."
},
"position": [
800,
100
]
}
],
"connections": {
"Monday 8 AM": {
"main": [
[
{
"node": "Query Booking Metrics"
},
{
"node": "Query Client Metrics"
}
]
]
},
"Query Booking Metrics": {
"main": [
[
{
"node": "Merge Results"
}
]
]
},
"Query Client Metrics": {
"main": [
[
{
"node": "Merge Results"
}
]
]
},
"Merge Results": {
"main": [
[
{
"node": "Build KPI Report"
}
]
]
},
"Build KPI Report": {
"main": [
[
{
"node": "Email CEO + CRO"
},
{
"node": "Slack #exec-kpis"
}
]
]
}
}
}
$getWorkflowStaticData persists the previous week's numbers inside the workflow engine—no external DB required for WoW calculations.
Why TravelTech SaaS needs self-hosted automation
| Risk | Zapier/Make.com | Self-hosted n8n |
|---|---|---|
| PCI-DSS scope | Card-adjacent data transits external sub-processor | Stays inside your VPC |
| GDPR Art. 28 | Need DPA with every automation SaaS | One DPA: your hosting provider |
| PSD2 SCA audit trail | Execution logs on vendor's servers | Permanent log in your Postgres |
| IATA NDC cert data | NDC payloads on external servers | In-house processing only |
| Cost at scale | 50K bookings/day × triggers = $2,000+/mo | $30-80/mo VPS |
Get the complete workflow pack
All 5 workflows above are production-ready and import-ready. If you want 15 more n8n automation templates for SaaS operations—covering onboarding drips, KPI dashboards, health monitors, and compliance trackers—they're at stripeai.gumroad.com ($12–$29 each, or $97 for the full bundle).
Drop a comment below if you're building on n8n for TravelTech—happy to answer questions.
Top comments (0)