DEV Community

Alex Kane
Alex Kane

Posted on

n8n for Travel Tech & OTAs: 5 Automations That Fill Seats and Cut Ops Cost (Free Workflow JSON)

If you run an OTA, booking platform, or travel tech company, you're managing a relentless stream of booking events, price signals, cancellations, and traveler communications — all on razor-thin margins.

Zapier and Make are cloud-only, meaning traveler PII, payment data, and booking details flow through a third-party server. Self-hosted n8n keeps everything inside your infrastructure — PCI DSS, GDPR, CCPA compliant by design.

Here are 5 production-ready workflows with full import-ready JSON.


1. Booking Confirmation & Itinerary Builder

Trigger: Webhook (new booking created)

Stack: Webhook → Code → Gmail → Google Sheets → Slack

When a booking lands, this workflow:

  • Extracts passenger names, routes, dates, booking reference, and fare class
  • Builds a formatted HTML itinerary email
  • Logs the booking to a tracking sheet
  • Pings Slack #bookings with a one-liner summary
{
  "name": "Booking Confirmation & Itinerary Builder",
  "nodes": [
    {
      "parameters": { "httpMethod": "POST", "path": "booking-confirmation", "responseMode": "lastNode", "options": {} },
      "name": "Webhook", "type": "n8n-nodes-base.webhook", "position": [240, 300]
    },
    {
      "parameters": {
        "jsCode": "const booking = $json.body;\nconst passengerName = booking.passenger_name || 'Traveler';\nconst origin = booking.origin;\nconst destination = booking.destination;\nconst departureDate = booking.departure_date;\nconst bookingRef = booking.booking_ref;\nconst fareName = booking.fare_class || 'Standard';\nconst totalPrice = booking.total_price_usd;\n\nconst htmlItinerary = `\n<div style=\"font-family: Arial, sans-serif; max-width: 600px;\">\n  <h2 style=\"color: #1a73e8;\">Your Booking is Confirmed!</h2>\n  <p>Hi ${passengerName},</p>\n  <p>Your booking is confirmed. Here are your travel details:</p>\n  <table style=\"width:100%;border-collapse:collapse;\">\n    <tr><td style=\"padding:8px;background:#f5f5f5;font-weight:bold;\">Booking Reference</td><td style=\"padding:8px;\">${bookingRef}</td></tr>\n    <tr><td style=\"padding:8px;font-weight:bold;\">Route</td><td style=\"padding:8px;\">${origin} → ${destination}</td></tr>\n    <tr><td style=\"padding:8px;background:#f5f5f5;font-weight:bold;\">Departure</td><td style=\"padding:8px;\">${departureDate}</td></tr>\n    <tr><td style=\"padding:8px;font-weight:bold;\">Total Paid</td><td style=\"padding:8px;\">$${totalPrice}</td></tr>\n  </table>\n  <p>Safe travels,<br>The Booking Team</p>\n</div>\n`;\n\nreturn [{ json: { ...booking, htmlItinerary, passengerName, origin, destination, departureDate, bookingRef, totalPrice } }];"
      },
      "name": "Build Itinerary", "type": "n8n-nodes-base.code", "position": [460, 300]
    },
    {
      "parameters": {
        "fromEmail": "bookings@yourdomain.com",
        "toEmail": "={{ $json.passenger_email }}",
        "subject": "Booking Confirmed: {{ $json.bookingRef }} | {{ $json.origin }} → {{ $json.destination }}",
        "emailType": "html", "message": "={{ $json.htmlItinerary }}"
      },
      "name": "Send Confirmation Email", "type": "n8n-nodes-base.gmail", "position": [680, 300]
    },
    {
      "parameters": { "operation": "append", "documentId": "YOUR_SHEET_ID", "sheetName": "Bookings", "columns": { "mappingMode": "autoMapInputData" } },
      "name": "Log to Sheets", "type": "n8n-nodes-base.googleSheets", "position": [900, 300]
    },
    {
      "parameters": { "channel": "#bookings", "text": "New booking: {{ $json.passengerName }} | {{ $json.origin }} → {{ $json.destination }} | {{ $json.departureDate }} | Ref: {{ $json.bookingRef }} | ${{ $json.totalPrice }}" },
      "name": "Slack Notification", "type": "n8n-nodes-base.slack", "position": [900, 460]
    }
  ],
  "connections": {
    "Webhook": { "main": [[{ "node": "Build Itinerary", "type": "main", "index": 0 }]] },
    "Build Itinerary": { "main": [[{ "node": "Send Confirmation Email", "type": "main", "index": 0 }, { "node": "Log to Sheets", "type": "main", "index": 0 }]] },
    "Send Confirmation Email": { "main": [[{ "node": "Slack Notification", "type": "main", "index": 0 }]] }
  }
}
Enter fullscreen mode Exit fullscreen mode

2. Price Drop Alert Pipeline

Trigger: Schedule (hourly)

Stack: Schedule → Google Sheets → HTTP Request → Code → IF → Gmail

For platforms that let users set price alerts, this workflow:

  • Reads a sheet of watched routes (user email, origin, destination, target price, last price)
  • Polls a flight pricing API for current fares
  • Calculates the price delta percentage
  • Sends a personalized alert email when the price drops ≥3% or hits the target
  • Updates the last_price column
{
  "name": "Price Drop Alert Pipeline",
  "nodes": [
    {
      "parameters": { "rule": { "interval": [{ "field": "hours", "hoursInterval": 1 }] } },
      "name": "Every Hour", "type": "n8n-nodes-base.scheduleTrigger", "position": [240, 300]
    },
    {
      "parameters": { "operation": "readRows", "documentId": "YOUR_SHEET_ID", "sheetName": "PriceAlerts", "options": {} },
      "name": "Read Price Alerts", "type": "n8n-nodes-base.googleSheets", "position": [460, 300]
    },
    {
      "parameters": {
        "url": "https://partners.api.skyscanner.net/apiservices/v3/flights/indicative/search",
        "sendHeaders": true,
        "headerParameters": { "parameters": [{ "name": "api-key", "value": "YOUR_API_KEY" }] },
        "sendQueryParameters": true,
        "queryParameters": { "parameters": [{ "name": "origin", "value": "={{ $json.origin }}" }, { "name": "destination", "value": "={{ $json.destination }}" }] }
      },
      "name": "Get Current Price", "type": "n8n-nodes-base.httpRequest", "position": [680, 300]
    },
    {
      "parameters": {
        "jsCode": "const currentPrice = parseFloat($json.best_price || 0);\nconst alert = $input.first().json;\nconst lastPrice = parseFloat(alert.last_price || currentPrice);\nconst deltaPercent = lastPrice > 0 ? ((lastPrice - currentPrice) / lastPrice) * 100 : 0;\nconst isPriceDrop = deltaPercent >= 3 || currentPrice <= parseFloat(alert.target_price || 0);\nreturn [{ json: { userEmail: alert.user_email, origin: alert.origin, destination: alert.destination, travelDate: alert.travel_date, currentPrice, lastPrice, deltaPercent: Math.round(deltaPercent * 10) / 10, targetPrice: alert.target_price, isPriceDrop, savings: Math.round(lastPrice - currentPrice) } }];"
      },
      "name": "Calculate Delta", "type": "n8n-nodes-base.code", "position": [900, 300]
    },
    {
      "parameters": { "conditions": { "boolean": [{ "value1": "={{ $json.isPriceDrop }}", "value2": true }] } },
      "name": "Price Dropped?", "type": "n8n-nodes-base.if", "position": [1120, 300]
    },
    {
      "parameters": {
        "toEmail": "={{ $json.userEmail }}",
        "subject": "Price drop: {{ $json.origin }} → {{ $json.destination }} now ${{ $json.currentPrice }}",
        "message": "Good news! A fare you're watching just dropped.\n\nRoute: {{ $json.origin }} → {{ $json.destination }}\nNew price: ${{ $json.currentPrice }} (was ${{ $json.lastPrice }})\nYou save: ${{ $json.savings }} ({{ $json.deltaPercent }}% drop)\n\nBook now before it goes back up."
      },
      "name": "Send Alert Email", "type": "n8n-nodes-base.gmail", "position": [1340, 200]
    }
  ],
  "connections": {
    "Every Hour": { "main": [[{ "node": "Read Price Alerts", "type": "main", "index": 0 }]] },
    "Read Price Alerts": { "main": [[{ "node": "Get Current Price", "type": "main", "index": 0 }]] },
    "Get Current Price": { "main": [[{ "node": "Calculate Delta", "type": "main", "index": 0 }]] },
    "Calculate Delta": { "main": [[{ "node": "Price Dropped?", "type": "main", "index": 0 }]] },
    "Price Dropped?": { "main": [[{ "node": "Send Alert Email", "type": "main", "index": 0 }], []] }
  }
}
Enter fullscreen mode Exit fullscreen mode

3. Cancellation Recovery Pipeline

Trigger: Webhook (booking cancelled)

Stack: Webhook → Code → Slack → Wait → Gmail → Google Sheets

When a booking is cancelled, this workflow:

  • Immediately alerts your revenue ops team on Slack
  • Waits 2 hours (cooling-off period)
  • Sends a recovery email with a rebooking credit code
  • Logs the recovery attempt
{
  "name": "Cancellation Recovery Pipeline",
  "nodes": [
    {
      "parameters": { "httpMethod": "POST", "path": "booking-cancelled", "responseMode": "lastNode", "options": {} },
      "name": "Cancellation Webhook", "type": "n8n-nodes-base.webhook", "position": [240, 300]
    },
    {
      "parameters": {
        "jsCode": "const b = $json.body;\nconst bookingValue = parseFloat(b.total_price_usd || 0);\nconst creditAmount = Math.round(bookingValue * 0.1);\nreturn [{ json: { passengerName: b.passenger_name || 'Traveler', origin: b.origin, destination: b.destination, bookingValue, bookingRef: b.booking_ref, passengerEmail: b.passenger_email, creditAmount } }];"
      },
      "name": "Extract Data", "type": "n8n-nodes-base.code", "position": [460, 300]
    },
    {
      "parameters": { "channel": "#revenue-ops", "text": "Cancellation: {{ $json.passengerName }} | {{ $json.origin }} → {{ $json.destination }} | ${{ $json.bookingValue }} | Recovery email in 2h" },
      "name": "Alert Revenue Ops", "type": "n8n-nodes-base.slack", "position": [680, 300]
    },
    { "parameters": { "amount": 2, "unit": "hours" }, "name": "Wait 2 Hours", "type": "n8n-nodes-base.wait", "position": [900, 300] },
    {
      "parameters": {
        "toEmail": "={{ $json.passengerEmail }}",
        "subject": "We've added ${{ $json.creditAmount }} travel credit to your account",
        "message": "Hi {{ $json.passengerName }},\n\nWe noticed you cancelled your trip from {{ $json.origin }} to {{ $json.destination }}. Plans change — we get it.\n\nAs a thank-you for booking with us, we've added ${{ $json.creditAmount }} in travel credit to your account, valid for 90 days on any route.\n\nCode: COMEBACK{{ $json.bookingRef }}\n\nWhen you're ready to travel again, we'd love to have you back."
      },
      "name": "Recovery Email", "type": "n8n-nodes-base.gmail", "position": [1120, 300]
    },
    {
      "parameters": { "operation": "append", "documentId": "YOUR_SHEET_ID", "sheetName": "CancellationRecovery", "columns": { "mappingMode": "autoMapInputData" } },
      "name": "Log Recovery", "type": "n8n-nodes-base.googleSheets", "position": [1340, 300]
    }
  ],
  "connections": {
    "Cancellation Webhook": { "main": [[{ "node": "Extract Data", "type": "main", "index": 0 }]] },
    "Extract Data": { "main": [[{ "node": "Alert Revenue Ops", "type": "main", "index": 0 }]] },
    "Alert Revenue Ops": { "main": [[{ "node": "Wait 2 Hours", "type": "main", "index": 0 }]] },
    "Wait 2 Hours": { "main": [[{ "node": "Recovery Email", "type": "main", "index": 0 }]] },
    "Recovery Email": { "main": [[{ "node": "Log Recovery", "type": "main", "index": 0 }]] }
  }
}
Enter fullscreen mode Exit fullscreen mode

4. Post-Trip NPS & Review Collection

Trigger: Webhook (trip completed)

Stack: Webhook → Wait 24h → Gmail → Google Sheets (mark sent) → Wait 5d → Check status → IF → Gmail follow-up

Timing drives review volume. This workflow:

  • Triggers when a trip is marked complete
  • Waits 24 hours for the traveler to arrive home
  • Sends a personalized review request
  • Waits 5 days
  • If no review received, sends one gentle follow-up
{
  "name": "Post-Trip NPS & Review Collection",
  "nodes": [
    {
      "parameters": { "httpMethod": "POST", "path": "trip-completed", "responseMode": "lastNode", "options": {} },
      "name": "Trip Completed", "type": "n8n-nodes-base.webhook", "position": [240, 300]
    },
    { "parameters": { "amount": 1, "unit": "days" }, "name": "Wait 24h", "type": "n8n-nodes-base.wait", "position": [460, 300] },
    {
      "parameters": {
        "toEmail": "={{ $json.body.passenger_email }}",
        "subject": "How was your trip to {{ $json.body.destination }}?",
        "message": "Hi {{ $json.body.passenger_name }},\n\nHope you had a great trip to {{ $json.body.destination }}!\n\nWe'd love to hear your feedback — takes 2 minutes:\n\nhttps://yourplatform.com/review?ref={{ $json.body.booking_ref }}\n\nThanks for traveling with us!"
      },
      "name": "Send Review Request", "type": "n8n-nodes-base.gmail", "position": [680, 300]
    },
    {
      "parameters": {
        "operation": "update", "documentId": "YOUR_SHEET_ID", "sheetName": "Bookings",
        "filtersUI": { "values": [{ "lookupColumn": "booking_ref", "lookupValue": "={{ $json.body.booking_ref }}" }] },
        "fieldsUi": { "values": [{ "column": "review_requested", "fieldValue": "TRUE" }, { "column": "review_requested_at", "fieldValue": "={{ $now.toISO() }}" }] }
      },
      "name": "Mark Requested", "type": "n8n-nodes-base.googleSheets", "position": [900, 300]
    },
    { "parameters": { "amount": 5, "unit": "days" }, "name": "Wait 5 Days", "type": "n8n-nodes-base.wait", "position": [1120, 300] },
    {
      "parameters": {
        "operation": "readRows", "documentId": "YOUR_SHEET_ID", "sheetName": "Bookings",
        "options": { "filters": { "values": [{ "column": "booking_ref", "value": "={{ $json.body.booking_ref }}" }] } }
      },
      "name": "Check Review Status", "type": "n8n-nodes-base.googleSheets", "position": [1340, 300]
    },
    {
      "parameters": { "conditions": { "string": [{ "value1": "={{ $json.review_received }}", "operation": "notEqual", "value2": "TRUE" }] } },
      "name": "No Review?", "type": "n8n-nodes-base.if", "position": [1560, 300]
    },
    {
      "parameters": {
        "toEmail": "={{ $json.passenger_email }}",
        "subject": "Still time to share your {{ $json.destination }} experience",
        "message": "Hi {{ $json.passenger_name }},\n\nJust a quick reminder — we'd still love to hear about your trip to {{ $json.destination }}.\n\nhttps://yourplatform.com/review?ref={{ $json.booking_ref }}\n\nOnly takes 60 seconds. Thanks!"
      },
      "name": "Follow-up", "type": "n8n-nodes-base.gmail", "position": [1780, 200]
    }
  ],
  "connections": {
    "Trip Completed": { "main": [[{ "node": "Wait 24h", "type": "main", "index": 0 }]] },
    "Wait 24h": { "main": [[{ "node": "Send Review Request", "type": "main", "index": 0 }]] },
    "Send Review Request": { "main": [[{ "node": "Mark Requested", "type": "main", "index": 0 }]] },
    "Mark Requested": { "main": [[{ "node": "Wait 5 Days", "type": "main", "index": 0 }]] },
    "Wait 5 Days": { "main": [[{ "node": "Check Review Status", "type": "main", "index": 0 }]] },
    "Check Review Status": { "main": [[{ "node": "No Review?", "type": "main", "index": 0 }]] },
    "No Review?": { "main": [[{ "node": "Follow-up", "type": "main", "index": 0 }], []] }
  }
}
Enter fullscreen mode Exit fullscreen mode

5. Daily Revenue & Booking Ops Report

Trigger: Schedule (daily 7 AM)

Stack: Schedule → Google Sheets → Code → Gmail + Slack

Every morning, this workflow delivers a formatted HTML report with:

  • Total bookings and revenue
  • Average order value (AOV)
  • Cancellation rate
  • Top 3 routes by revenue
  • Week-over-week change
{
  "name": "Daily Travel Ops Report",
  "nodes": [
    {
      "parameters": { "rule": { "interval": [{ "field": "cronExpression", "expression": "0 7 * * *" }] } },
      "name": "7 AM Daily", "type": "n8n-nodes-base.scheduleTrigger", "position": [240, 300]
    },
    {
      "parameters": { "operation": "readRows", "documentId": "YOUR_SHEET_ID", "sheetName": "Bookings", "options": {} },
      "name": "Read Bookings", "type": "n8n-nodes-base.googleSheets", "position": [460, 300]
    },
    {
      "parameters": {
        "jsCode": "const today = new Date();\nconst yesterday = new Date(today);\nyesterday.setDate(today.getDate() - 1);\nconst yStr = yesterday.toISOString().split('T')[0];\nconst wAgoStr = new Date(today.getTime() - 7*86400000).toISOString().split('T')[0];\nconst all = $input.all().map(i => i.json);\nconst rows = all.filter(r => r.booking_date === yStr);\nconst lastWeekRows = all.filter(r => r.booking_date === wAgoStr);\nconst active = rows.filter(r => r.status !== 'cancelled');\nconst bookings = active.length;\nconst cancels = rows.filter(r => r.status === 'cancelled').length;\nconst revenue = active.reduce((s, r) => s + parseFloat(r.total_price_usd || 0), 0);\nconst aov = bookings > 0 ? revenue / bookings : 0;\nconst cancelRate = rows.length > 0 ? cancels / rows.length * 100 : 0;\nconst lwRev = lastWeekRows.filter(r => r.status !== 'cancelled').reduce((s, r) => s + parseFloat(r.total_price_usd || 0), 0);\nconst wowPct = lwRev > 0 ? (revenue - lwRev) / lwRev * 100 : 0;\nconst routeMap = {};\nactive.forEach(r => {\n  const k = `${r.origin} → ${r.destination}`;\n  if (!routeMap[k]) routeMap[k] = { count: 0, rev: 0 };\n  routeMap[k].count++;\n  routeMap[k].rev += parseFloat(r.total_price_usd || 0);\n});\nconst topRoutes = Object.entries(routeMap).sort((a, b) => b[1].rev - a[1].rev).slice(0, 3);\nconst routeRows = topRoutes.map(([route, d]) => `<tr><td style='padding:6px;'>${route}</td><td style='padding:6px;text-align:center;'>${d.count}</td><td style='padding:6px;text-align:right;'>$${Math.round(d.rev)}</td></tr>`).join('');\nconst wowColor = wowPct >= 0 ? 'green' : 'red';\nconst html = `<div style='font-family:Arial;max-width:680px;'><h2 style='color:#1a73e8;'>Travel Ops Report — ${yStr}</h2><table style='width:100%;border-collapse:collapse;margin-bottom:20px;'><tr style='background:#f5f5f5;'><td style='padding:8px;font-weight:bold;'>Bookings</td><td style='padding:8px;'>${bookings}</td></tr><tr><td style='padding:8px;font-weight:bold;'>Revenue</td><td style='padding:8px;'>$${Math.round(revenue)} <span style='color:${wowColor};font-size:12px;'>(${wowPct >= 0 ? '+' : ''}${Math.round(wowPct)}% WoW)</span></td></tr><tr style='background:#f5f5f5;'><td style='padding:8px;font-weight:bold;'>AOV</td><td style='padding:8px;'>$${Math.round(aov)}</td></tr><tr><td style='padding:8px;font-weight:bold;'>Cancellations</td><td style='padding:8px;'>${cancels} (${Math.round(cancelRate)}%)</td></tr></table><h3>Top Routes by Revenue</h3><table style='width:100%;border-collapse:collapse;'><tr style='background:#e8f0fe;'><th style='padding:6px;text-align:left;'>Route</th><th style='padding:6px;'>Bookings</th><th style='padding:6px;'>Revenue</th></tr>${routeRows || '<tr><td colspan=3 style=padding:6px;>No data</td></tr>'}</table></div>`;\nreturn [{ json: { html, bookings, revenue: Math.round(revenue), aov: Math.round(aov), cancelRate: Math.round(cancelRate), wowPct: Math.round(wowPct), yStr } }];"
      },
      "name": "Build Report", "type": "n8n-nodes-base.code", "position": [680, 300]
    },
    {
      "parameters": {
        "toEmail": "ops@yourdomain.com",
        "subject": "Travel Ops Report — {{ $json.yStr }} | ${{ $json.revenue }} | {{ $json.bookings }} bookings",
        "emailType": "html", "message": "={{ $json.html }}"
      },
      "name": "Email Report", "type": "n8n-nodes-base.gmail", "position": [900, 260]
    },
    {
      "parameters": { "channel": "#ops-reporting", "text": "Daily ({{ $json.yStr }}): {{ $json.bookings }} bookings | ${{ $json.revenue }} revenue | AOV ${{ $json.aov }} | Cancel {{ $json.cancelRate }}%" },
      "name": "Slack Summary", "type": "n8n-nodes-base.slack", "position": [900, 420]
    }
  ],
  "connections": {
    "7 AM Daily": { "main": [[{ "node": "Read Bookings", "type": "main", "index": 0 }]] },
    "Read Bookings": { "main": [[{ "node": "Build Report", "type": "main", "index": 0 }]] },
    "Build Report": { "main": [[{ "node": "Email Report", "type": "main", "index": 0 }, { "node": "Slack Summary", "type": "main", "index": 0 }]] }
  }
}
Enter fullscreen mode Exit fullscreen mode

Why self-hosted n8n for Travel Tech?

Requirement n8n (self-hosted) Zapier Make.com
Traveler PII stays in your infra
PCI DSS / payment data control
GDPR / CCPA by design Partial Partial
Webhook processing volume Unlimited Capped Capped
Custom pricing logic (JS/Python) Limited Limited
Cost at scale ~$0 (self-hosted) $$$$ $$$

For OTAs and booking platforms, the data sovereignty argument is compelling: traveler names, passport numbers, payment details, and booking records cannot flow through a third-party automation cloud without significant compliance risk.


Ready-to-use workflow templates

These 5 workflows — plus 10 more covering demand forecasting, partner API integrations, affiliate payouts, and customer win-back pipelines — are available as tested, import-ready JSON templates at FlowKit — n8n Automation Templates.

Drop the ZIP into n8n (File → Import → From File), swap in your API keys, and have it running in under 10 minutes.


Running a travel tech automation that didn't make this list? Drop it in the comments — I add new workflows every week.

Top comments (0)