{
  "id": "6tqe8rVRa6LNgmPa",
  "name": "Campaign Send-Time Optimizer",
  "tags": [],
  "nodes": [
    {
      "id": "013ab571-615d-4a19-badb-7e4f72eb402b",
      "name": "\ud83d\udcca Send-Time Optimizer \u2014 Main",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1072,
        288
      ],
      "parameters": {
        "color": 4,
        "width": 900,
        "height": 484,
        "content": "## \ud83d\udcca Campaign Send-Time Optimizer\n\n**Goal:** Analyse your Klaviyo email campaign history to identify which send times produce the highest open and click rates per audience segment. Delivers a ranked HTML report every Monday at 8:00 AM.\n\n### How it works\n1. **Schedule** fires every Monday at 8 AM (or trigger manually for on-demand runs).\n2. **Config** holds the central settings \u2014 lookback window (90 days) and minimum campaigns per slot (3).\n3. **Get Sent Campaigns** paginates the Klaviyo Campaigns API to pull every email campaign.\n4. **Flatten Campaigns** filters to Sent status within the window, extracts the UTC send hour, and builds an audience-segment key.\n5. **Fetch Lists** retrieves all list names for human-readable labels in the final report.\n6. **Get Campaign Stats** posts a single bulk request to the campaign-values-reports endpoint and returns open + click rates.\n7. **Aggregate by Hour** joins metadata with stats, groups into (audience \u00d7 hour) buckets, and averages each.\n8. **Format Email Report** ranks segments by best open rate, adds medal icons for the top 3 slots per segment.\n9. **Gmail \u2014 Send Report** delivers the styled HTML email to your configured inbox.\n\n"
      },
      "typeVersion": 1
    },
    {
      "id": "5e33f838-4a9b-4b45-891d-d3a3d340334b",
      "name": "\u26a0\ufe0f Critical Setup",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1072,
        864
      ],
      "parameters": {
        "color": 2,
        "width": 940,
        "height": 580,
        "content": "## \u26a0\ufe0f Critical \u2014 Do before activation\n\n**The workflow will fail or send to the wrong recipient if you skip these:**\n\n### \ud83d\udd11 Credentials\n- **Klaviyo API Key** \u2014 required on three HTTP Request nodes (`Get Sent Campaigns`, `Fetch Lists`, `Get Campaign Stats`). Generate at *Klaviyo \u2192 Account \u2192 Settings \u2192 API Keys*. Use a Private API Key with `campaigns:read` + `lists:read` + `metrics:read` scopes.\n- **Gmail OAuth2** \u2014 required on both Gmail nodes. The Error Alert uses a separate credential from the report sender so a failure in the sender's auth doesn't suppress its own error message.\n\n### \ud83d\udce7 Recipient addresses\n- **Gmail \u2014 Send Report** \u2192 currently `user@example.com`. Update to your inbox.\n- **Gmail \u2014 Error Alert** \u2192 currently `user@example.com`. Update to your monitoring inbox (can be same as above).\n\n### \ud83c\udfaf conversion_metric_id\nThe body of **Get Campaign Stats** hardcodes `X7ghUW` (Klaviyo's default Shopify *Placed Order* metric). If your store uses Shopify, you're fine. If not, replace with your account's primary conversion metric ID \u2014 find it at *Klaviyo \u2192 Account \u2192 Settings \u2192 Custom Metrics*.\n\n### \u23f1\ufe0f Timezone\nAll times in the final report are **UTC** by design. If you want local time, edit the `getUTCHours()` call in *Flatten Campaigns* and the label format in *Aggregate by Hour*."
      },
      "typeVersion": 1
    },
    {
      "id": "7ffc952b-9d7d-40b7-b956-94b962f6ee00",
      "name": "Section \u2014 Setup & Klaviyo Data Pull",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        16,
        224
      ],
      "parameters": {
        "color": 7,
        "width": 1116,
        "height": 572,
        "content": "### \ud83d\udce5 Setup & Klaviyo Data Pull\n\nTriggers on the weekly schedule, loads the central config (lookback window + confidence threshold), then makes three Klaviyo API calls in sequence \u2014 first to fetch all email campaigns with cursor pagination, then to fetch list names for readable labels, finally a bulk POST to the campaign-values-reports endpoint for open and click rates."
      },
      "typeVersion": 1
    },
    {
      "id": "45c48c39-1d93-4c67-8305-6010a725dbe5",
      "name": "Section \u2014 Analysis & Report Delivery",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1168,
        224
      ],
      "parameters": {
        "color": 7,
        "width": 728,
        "height": 428,
        "content": "### \ud83d\udce4 Analysis & Report Delivery\n\nJoins campaign metadata with stats, groups them into (audience \u00d7 send_hour) buckets and averages each metric. Slots with fewer than 3 campaigns are flagged as statistically thin. The formatter ranks each segment by best open rate, awards medal icons to the top 3 slots, and the Gmail node delivers the styled HTML report."
      },
      "typeVersion": 1
    },
    {
      "id": "c96436f7-2d1b-49b6-8585-3205cdc74c75",
      "name": "Schedule \u2014 Weekly Monday",
      "type": "n8n-nodes-base.scheduleTrigger",
      "notes": "Entry point. Fires every Monday at 8:00 AM automatically. Trigger manually from the canvas to run on demand at any time.",
      "position": [
        64,
        384
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "weeks",
              "triggerAtDay": [
                1
              ],
              "triggerAtHour": 8
            }
          ]
        }
      },
      "typeVersion": 1.1
    },
    {
      "id": "039b5965-30f3-43cc-bc0d-39012a85fa06",
      "name": "Config",
      "type": "n8n-nodes-base.set",
      "notes": "Central configuration. Edit lookback_days (default 90) or min_campaigns_per_slot (default 3) here to adjust the analysis window and confidence threshold without touching any code nodes.",
      "position": [
        288,
        384
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "cfg-1",
              "name": "lookback_days",
              "type": "number",
              "value": 90
            },
            {
              "id": "cfg-2",
              "name": "min_campaigns_per_slot",
              "type": "number",
              "value": 3
            },
            {
              "id": "cfg-3",
              "name": "start_date",
              "type": "string",
              "value": "={{ $now.minus({days: 90}).toUTC().toISO() }}"
            },
            {
              "id": "cfg-4",
              "name": "report_date",
              "type": "string",
              "value": "={{ $now.toFormat('MMMM d, yyyy') }}"
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "26e958bc-b1f5-4cce-ae24-bb363e0c6e88",
      "name": "Get Sent Campaigns",
      "type": "n8n-nodes-base.httpRequest",
      "notes": "Klaviyo Campaigns API \u2014 fetches all email campaigns using cursor-based pagination (follows links.next automatically, up to 20 pages). Filters to email channel at the API level. Requires: Klaviyo API Key credential.",
      "position": [
        512,
        384
      ],
      "parameters": {
        "url": "={{ 'https://a.klaviyo.com/api/campaigns/?fields%5Bcampaign%5D=name,scheduled_at,send_strategy,audiences,status&filter=' + encodeURIComponent(\"equals(messages.channel,'email')\") }}",
        "options": {
          "pagination": {}
        },
        "sendHeaders": true,
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "headerParameters": {
          "parameters": [
            {
              "name": "revision",
              "value": "2024-02-15"
            }
          ]
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "3bd90a46-07dd-4a59-9f34-a6515dea299b",
      "name": "Flatten Campaigns",
      "type": "n8n-nodes-base.code",
      "notes": "Parses the paginated Klaviyo response (handles n8n double-serialization). Filters to status=Sent campaigns within the 90-day window. Extracts UTC send hour from scheduled_at. Builds audience segment key from included list IDs. Throws a descriptive error if no campaigns pass the filter.",
      "position": [
        736,
        384
      ],
      "parameters": {
        "jsCode": "// Collect all pages returned by the paginating HTTP Request node,\n// filter to Sent email campaigns within the last 90 days,\n// and extract the send hour (UTC) and segment key from each.\n\nconst allItems = $input.all();\nconst cutoffMs = Date.now() - 90 * 24 * 60 * 60 * 1000;\n\nconst campaigns = [];\n\nfor (const item of allItems) {\n  const page = item.json;\n\n  let parsed = page;\n  if (typeof page.data === 'string') {\n    try { parsed = JSON.parse(page.data); } catch (e) { parsed = page; }\n  }\n\n  let rows;\n  if (Array.isArray(parsed)) {\n    rows = parsed;\n  } else if (Array.isArray(parsed.data)) {\n    rows = parsed.data;\n  } else {\n    rows = [];\n  }\n\n  for (const row of rows) {\n    const attrs = row.attributes || {};\n    const status = (attrs.status || '').toLowerCase();\n    if (status !== 'sent') continue;\n\n    const dateStr = attrs.scheduled_at || attrs.send_strategy?.options_static?.datetime || '';\n    const sentDate = dateStr ? new Date(dateStr) : null;\n    if (sentDate && !isNaN(sentDate.getTime()) && sentDate.getTime() < cutoffMs) continue;\n    const sendHour = (sentDate && !isNaN(sentDate.getTime())) ? sentDate.getUTCHours() : 9;\n\n    const audiences = attrs.audiences || {};\n    const listIds = audiences.included || [];\n    const segmentKey = listIds.length > 0 ? listIds.join(',') : 'all';\n\n    campaigns.push({id: row.id, name: attrs.name || 'Unknown', send_hour: sendHour, segment_key: segmentKey, scheduled_at: dateStr});\n  }\n}\n\nif (campaigns.length === 0) {\n  const firstPage = allItems[0]?.json || {};\n  throw new Error(`No sent campaigns found after parsing. Items received: ${allItems.length}. First item keys: ${Object.keys(firstPage).join(', ')}. data type: ${typeof firstPage.data}`);\n}\n\nreturn [{ json: { campaigns, total: campaigns.length } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "83107e6c-6dc5-419c-98c6-75ded6fc9427",
      "name": "Fetch Lists",
      "type": "n8n-nodes-base.httpRequest",
      "notes": "Klaviyo Lists API \u2014 retrieves all list names in a single call. Converts raw IDs (e.g. RQVVZj) into human-readable names (e.g. VIP Customers) in the final report. Requires: Klaviyo API Key credential.",
      "position": [
        944,
        384
      ],
      "parameters": {
        "url": "https://a.klaviyo.com/api/lists/?fields%5Blist%5D=name",
        "options": {},
        "sendHeaders": true,
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "headerParameters": {
          "parameters": [
            {
              "name": "revision",
              "value": "2024-02-15"
            }
          ]
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "2811836f-536a-4cbb-b54a-3e4be669265d",
      "name": "Get Campaign Stats",
      "type": "n8n-nodes-base.httpRequest",
      "notes": "Single bulk POST to Klaviyo campaign-values-reports endpoint. Returns open_rate and click_rate per campaign for the last 90 days. conversion_metric_id X7ghUW is the Shopify Placed Order metric \u2014 required by Klaviyo's reporting API.",
      "position": [
        960,
        592
      ],
      "parameters": {
        "url": "https://a.klaviyo.com/api/campaign-values-reports/",
        "method": "POST",
        "options": {},
        "jsonBody": "{\n  \"data\": {\n    \"type\": \"campaign-values-report\",\n    \"attributes\": {\n      \"statistics\": [\n        \"open_rate\",\n        \"click_rate\"\n      ],\n      \"timeframe\": {\n        \"key\": \"last_90_days\"\n      },\n      \"conversion_metric_id\": \"X7ghUW\"\n    }\n  }\n}",
        "sendBody": true,
        "sendHeaders": true,
        "specifyBody": "json",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "headerParameters": {
          "parameters": [
            {
              "name": "revision",
              "value": "2024-02-15"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "18ab8dca-f16d-4485-823e-3caa3024414a",
      "name": "Aggregate by Hour",
      "type": "n8n-nodes-base.code",
      "notes": "Core analysis node. Joins campaign metadata (from Flatten Campaigns) with stats (from Get Campaign Stats). Builds a list name lookup from Fetch Lists. Groups campaigns into (audience x send_hour) buckets, averages open rates and click rates. Flags slots with fewer than 3 campaigns as statistically thin.",
      "position": [
        1232,
        384
      ],
      "parameters": {
        "jsCode": "// Join campaign metadata with open-rate stats, aggregate into per-segment-per-hour buckets.\n\nconst MIN_CAMPAIGNS = 3;\n\nconst flattenedData = $('Flatten Campaigns').first().json;\nconst campaigns = flattenedData.campaigns || [];\n\nlet listsRaw = $('Fetch Lists').first().json;\nif (typeof listsRaw.data === 'string') {\n  try { listsRaw = JSON.parse(listsRaw.data); } catch(e) {}\n}\nconst listItems = Array.isArray(listsRaw) ? listsRaw : (Array.isArray(listsRaw?.data) ? listsRaw.data : []);\nconst nameMap = {};\nfor (const l of listItems) {\n  if (l.id && l.attributes?.name) nameMap[l.id] = l.attributes.name;\n}\n\nlet statsRaw = $input.first().json;\nif (typeof statsRaw.data === 'string') {\n  try { statsRaw = JSON.parse(statsRaw.data); } catch (e) {}\n}\nconst results = (statsRaw?.data?.attributes?.results || statsRaw?.attributes?.results || statsRaw?.results || []);\n\nconst statsMap = {};\nfor (const r of results) {\n  const campaignId = r.campaign_id || (r.groupings && r.groupings.campaign_id) || r.id;\n  if (!campaignId) continue;\n  const stats = r.statistics || {};\n  statsMap[campaignId] = {open_rate: parseFloat(stats.open_rate ?? 0), click_rate: parseFloat(stats.click_rate ?? 0)};\n}\n\nconst buckets = {};\nfor (const c of campaigns) {\n  const stats = statsMap[c.id];\n  if (!stats) continue;\n  const key = `${c.segment_key}__${c.send_hour}`;\n  if (!buckets[key]) {\n    buckets[key] = {segment_key: c.segment_key, send_hour: c.send_hour, label: `${String(c.send_hour).padStart(2, '0')}:00 UTC`, total_open_rate: 0, total_click_rate: 0, count: 0};\n  }\n  buckets[key].total_open_rate += stats.open_rate;\n  buckets[key].total_click_rate += stats.click_rate;\n  buckets[key].count += 1;\n}\n\nconst allBuckets = Object.values(buckets).map(b => ({...b, avg_open_rate: b.count > 0 ? b.total_open_rate / b.count : 0, avg_click_rate: b.count > 0 ? b.total_click_rate / b.count : 0}));\n\nconst qualified = allBuckets.filter(b => b.count >= MIN_CAMPAIGNS);\nconst warning = qualified.length === 0 ? `No time slots had ${MIN_CAMPAIGNS}+ campaigns yet. Showing all available data instead.` : null;\nconst outputBuckets = qualified.length > 0 ? qualified : allBuckets;\n\nreturn [{json: {buckets: outputBuckets, total_campaigns_analysed: campaigns.length, total_with_stats: results.length, warning, nameMap}}];"
      },
      "typeVersion": 2
    },
    {
      "id": "11b6b623-ae13-4b8c-a2e4-d6c01dec8706",
      "name": "Format Email Report",
      "type": "n8n-nodes-base.code",
      "notes": "Builds the ranked HTML email. Sorts audience segments by best open rate first. Within each segment shows up to 5 time slots with medal icons for top 3. Resolves raw audience IDs to list names using the nameMap from Aggregate by Hour.",
      "position": [
        1472,
        384
      ],
      "parameters": {
        "jsCode": "const { buckets, total_campaigns_analysed, total_with_stats, warning, nameMap = {} } = $input.first().json;\n\nfunction resolveSegmentLabel(segId) {\n  if (!segId || segId === 'all') return 'All Subscribers';\n  return segId.split(',').map(id => nameMap[id] || id).join(' + ');\n}\n\nconst bySegment = {};\nfor (const b of buckets) {\n  const seg = b.segment_key || 'all';\n  if (!bySegment[seg]) bySegment[seg] = [];\n  bySegment[seg].push(b);\n}\n\nfor (const slots of Object.values(bySegment)) {\n  slots.sort((a, b) => b.avg_open_rate - a.avg_open_rate);\n}\n\nconst reportDate = new Date().toLocaleDateString('en-US', {weekday: 'long', year: 'numeric', month: 'long', day: 'numeric'});\nconst medals = ['\ud83e\udd47', '\ud83e\udd48', '\ud83e\udd49'];\nconst rankColors = [{bg: '#fff8e1', border: '#f5a623'}, {bg: '#f5f5f5', border: '#9b9b9b'}, {bg: '#fdf6ee', border: '#c97c3a'}];\n\nconst segmentSections = Object.entries(bySegment)\n  .sort(([, slotsA], [, slotsB]) => (slotsB[0]?.avg_open_rate || 0) - (slotsA[0]?.avg_open_rate || 0))\n  .map(([segId, slots]) => {\n    const segLabel = resolveSegmentLabel(segId);\n    const rows = slots.slice(0, 5).map((slot, i) => {\n      const medal = medals[i] || `${i + 1}.`;\n      const openRateStr = (slot.avg_open_rate * 100).toFixed(1);\n      const clickRateStr = (slot.avg_click_rate * 100).toFixed(1);\n      const color = rankColors[i] || {bg: '#fafafa', border: '#ddd'};\n      return `<tr><td style=\"padding:0 0 6px 0;\"><div style=\"padding:12px 16px;background:${color.bg};border-left:4px solid ${color.border};border-radius:4px;\"><span style=\"font-size:16px;font-weight:bold;\">${medal} ${slot.label}</span><span style=\"color:#555;font-size:14px;\"> &mdash; <strong>${openRateStr}%</strong> open rate &nbsp;&bull;&nbsp; <strong>${clickRateStr}%</strong> click rate &nbsp;&bull;&nbsp; ${slot.count} campaign${slot.count !== 1 ? 's' : ''}</span></div></td></tr>`;\n    }).join('');\n    return `<h2 style=\"color:#333;border-bottom:2px solid #eee;padding-bottom:8px;margin-top:28px;\">${segLabel}</h2><table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"border-collapse:collapse;\">${rows}</table>`;\n  }).join('');\n\nconst warningHtml = warning ? `<div style=\"background:#fff3cd;border:1px solid #ffc107;padding:12px 16px;border-radius:4px;margin-bottom:20px;font-size:14px;\">\u26a0\ufe0f ${warning}</div>` : '';\nconst noDataHtml = buckets.length === 0 ? '<p style=\"color:#666;\">No data available yet. Campaigns will appear here after they are sent.</p>' : '';\n\nconst html = `<!DOCTYPE html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"></head><body style=\"font-family:Arial,Helvetica,sans-serif;max-width:680px;margin:0 auto;padding:24px;color:#1a1a1a;\"><table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"background:#4f46e5;border-radius:8px;margin-bottom:24px;\"><tr><td style=\"padding:20px 24px;\"><h1 style=\"margin:0;color:#fff;font-size:22px;\">\ud83d\udcca Campaign Send-Time Report</h1><p style=\"margin:4px 0 0;color:#c7d2fe;font-size:14px;\">Week of ${reportDate}</p></td></tr></table><p style=\"color:#555;font-size:14px;margin-bottom:6px;\"><strong>${total_campaigns_analysed}</strong> campaigns analysed &nbsp;&bull;&nbsp; <strong>${total_with_stats}</strong> with open-rate data &nbsp;&bull;&nbsp; Last 90 days &nbsp;&bull;&nbsp; Times shown in UTC</p>${warningHtml}${noDataHtml}${segmentSections}<hr style=\"border:none;border-top:1px solid #eee;margin:32px 0 16px;\"><p style=\"color:#999;font-size:12px;\">Generated automatically every Monday by your n8n Campaign Send-Time Optimizer.<br>Tip: if you see audience IDs instead of names, map them in the Aggregate by Hour node.</p></body></html>`;\nconst subject = `\ud83d\udcca Send-Time Report \u2014 ${reportDate}`;\nreturn [{ json: { subject, html, reportDate } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "e8d75745-7a76-4502-8910-6d781e822fd6",
      "name": "Gmail \u2014 Send Report",
      "type": "n8n-nodes-base.gmail",
      "notes": "Delivers the weekly HTML send-time report to the configured inbox. Update the Send To address before activating the workflow.",
      "position": [
        1680,
        384
      ],
      "parameters": {
        "sendTo": "user@example.com",
        "message": "={{ $json.html }}",
        "options": {
          "appendAttribution": false
        },
        "subject": "={{ $json.subject }}"
      },
      "typeVersion": 2.1
    },
    {
      "id": "04756b4d-b1ce-41c5-afc5-cf7f8e95b975",
      "name": "Gmail \u2014 Error Alert",
      "type": "n8n-nodes-base.gmail",
      "notes": "Sends a failure notification email with the error message, the name of the failing node, and the execution start time. Update the Send To address before activating the workflow.",
      "position": [
        336,
        1040
      ],
      "parameters": {
        "sendTo": "user@example.com",
        "message": "=<p>The <strong>Campaign Send-Time Optimizer</strong> workflow failed.</p>\n<table style=\"font-family:Arial,sans-serif;font-size:14px;border-collapse:collapse;\">\n  <tr><td style=\"padding:4px 12px 4px 0;color:#555;\"><strong>Error:</strong></td><td>{{ $json.execution.error.message }}</td></tr>\n  <tr><td style=\"padding:4px 12px 4px 0;color:#555;\"><strong>Node:</strong></td><td>{{ $json.execution.error.node.name }}</td></tr>\n  <tr><td style=\"padding:4px 12px 4px 0;color:#555;\"><strong>Started:</strong></td><td>{{ $json.execution.startedAt }}</td></tr>\n</table>\n<p>Please check your n8n instance for the full execution log.</p>",
        "options": {
          "appendAttribution": false
        },
        "subject": "\u26a0\ufe0f Send-Time Optimizer Failed"
      },
      "typeVersion": 2.1
    },
    {
      "id": "afe02e01-3c08-4229-8d89-ff8585978601",
      "name": "Error Trigger",
      "type": "n8n-nodes-base.errorTrigger",
      "notes": "Catches any unhandled error thrown by any node in the main workflow chain. Passes error details \u2014 message, node name, execution time \u2014 to Gmail Error Alert.",
      "position": [
        112,
        1040
      ],
      "parameters": {},
      "typeVersion": 1
    },
    {
      "id": "da68681d-b39b-45f6-a34a-1b6ede855bb8",
      "name": "Section \u2014 Error Handling Branch",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        32,
        848
      ],
      "parameters": {
        "color": 7,
        "width": 992,
        "height": 448,
        "content": "### \ud83d\udedf Error Handling Branch\n\nCatches any unhandled failure from the main workflow chain and emails a structured alert with the error message, the failing node's name, and the execution start time \u2014 so you know within minutes that Monday morning's report didn't arrive."
      },
      "typeVersion": 1
    }
  ],
  "active": false,
  "settings": {
    "binaryMode": "separate",
    "executionOrder": "v1"
  },
  "versionId": "a9406359-1147-470b-b027-abc095607b97",
  "connections": {
    "Config": {
      "main": [
        [
          {
            "node": "Get Sent Campaigns",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Lists": {
      "main": [
        [
          {
            "node": "Get Campaign Stats",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Error Trigger": {
      "main": [
        [
          {
            "node": "Gmail \u2014 Error Alert",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Aggregate by Hour": {
      "main": [
        [
          {
            "node": "Format Email Report",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Flatten Campaigns": {
      "main": [
        [
          {
            "node": "Fetch Lists",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get Campaign Stats": {
      "main": [
        [
          {
            "node": "Aggregate by Hour",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get Sent Campaigns": {
      "main": [
        [
          {
            "node": "Flatten Campaigns",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Format Email Report": {
      "main": [
        [
          {
            "node": "Gmail \u2014 Send Report",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Schedule \u2014 Weekly Monday": {
      "main": [
        [
          {
            "node": "Config",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}