{
  "nodes": [
    {
      "id": "17c1d5d4-e36e-4a9b-b940-00c41735e55e",
      "name": "\ud83d\udccb Overview",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -48,
        96
      ],
      "parameters": {
        "width": 560,
        "height": 520,
        "content": "## Share time-limited preview links with UploadToURL, SendGrid, and Google Sheets\nThe Problem: Standard email attachments stay accessible forever, creating security risks and offering no tracking for sensitive agency drafts.\nThe Solution: A \"burner link\" delivery system that hosts files via UploadToURL, emails them via SendGrid, and automatically updates the link status to \"Expired\" in Google Sheets after a set time.\n\n\u2699\ufe0f How it Works\nWebhook: Receives a file (Binary or URL) and an expiry duration (e.g., 24 hours).\n\nUploadToURL: Hosts the asset instantly and returns a public CDN link.\n\nSendGrid: Emails the branded preview link to the client with an expiry notice.\n\nWait & Expire: The workflow pauses for the set duration, then updates Google Sheets to mark the link as expired.\n\n\ud83d\udd10 Credentials & Setup\nNode: Install n8n-nodes-uploadtourl via Community Nodes.\n\nAPIs: UploadToURL, SendGrid, and Google Sheets.\n\nVariables: Set GSHEET_SPREADSHEET_ID and DEFAULT_EXPIRY_HOURS"
      },
      "typeVersion": 1
    },
    {
      "id": "f3a0fb13-3e1d-42ea-8502-0ce4b5685712",
      "name": "Section 1 \u2014 Upload",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        688,
        640
      ],
      "parameters": {
        "color": 7,
        "width": 920,
        "height": 493,
        "content": "## 1 \u2014 Upload & link generation\n\n**Webhook \u2192 Validate \u2192 Has Remote URL? \u2192 Upload to URL (\u00d72) \u2192 Extract CDN URL \u2192 Generate Link Record**\n\nValidates recipient email format and expiry hours (1\u2013168 range). UploadToURL hosts the file via the native community node. The Generate node creates a unique `token` (hex ID), computes `expiresAt` from `expiryHours`, and assembles the full delivery record before anything is sent."
      },
      "typeVersion": 1
    },
    {
      "id": "351cb3b9-79c6-45fd-8eac-815e2f593050",
      "name": "Section 2 \u2014 Log & Send",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1632,
        704
      ],
      "parameters": {
        "color": 7,
        "width": 440,
        "height": 419,
        "content": "## 2 \u2014 Log & send\n\n**Sheets - Log Active Link \u2192 SendGrid - Send Preview Email**\n\nGoogle Sheets records the link immediately as `active` before the email is sent \u2014 so there's always a record even if SendGrid fails. The email is an HTML-formatted preview with the file name, expiry time, project name, and a clear CTA button. The agency receives a BCC copy."
      },
      "typeVersion": 1
    },
    {
      "id": "cdfd8480-5a9f-4729-a72a-8951574114c0",
      "name": "Section 3 \u2014 Wait & Expire",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2544,
        544
      ],
      "parameters": {
        "color": 7,
        "width": 712,
        "height": 690,
        "content": "## 3 \u2014 Wait & expire\n\n**Wait Node (configurable duration) \u2192 Sheets - Mark Expired \u2192 SendGrid - Expiry Notice**\n\nThe Wait node pauses this execution instance for the exact `expiryHours` value. After resuming, the sheet row is updated to `expired` with an `Expired At` timestamp. Two emails fire: a polite expiry notice to the client, and a delivery summary to the agency confirming the link lifecycle is complete."
      },
      "typeVersion": 1
    },
    {
      "id": "82015827-6144-492b-abff-bee0b98eb1c2",
      "name": "Wait node note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2096,
        688
      ],
      "parameters": {
        "color": 7,
        "width": 356,
        "height": 430,
        "content": "## \u2699\ufe0f Wait node note\n\nThis workflow uses n8n's **Wait** node to pause execution between send and expiry. Each webhook submission runs as its own execution instance \u2014 multiple links can be in-flight simultaneously. Requires n8n to be running (cloud or self-hosted with executions enabled). The wait duration is set dynamically from the `expiryHours` field in the webhook payload."
      },
      "typeVersion": 1
    },
    {
      "id": "4571c524-549e-4598-a56f-0e15afbf052e",
      "name": "Webhook - Create Burner Link",
      "type": "n8n-nodes-base.webhook",
      "position": [
        688,
        928
      ],
      "parameters": {
        "path": "burner-link",
        "options": {
          "allowedOrigins": "*"
        },
        "httpMethod": "POST",
        "responseMode": "responseNode"
      },
      "typeVersion": 2
    },
    {
      "id": "a72350ee-bfc4-4799-9566-cefec13ed12e",
      "name": "Validate Payload",
      "type": "n8n-nodes-base.code",
      "position": [
        832,
        928
      ],
      "parameters": {
        "jsCode": "const body = $input.first().json.body || $input.first().json;\n\n// \u2500\u2500 Required field checks \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nif (!body.recipientEmail) {\n  throw new Error('recipientEmail is required.');\n}\nif (!body.fileUrl && !body.filename) {\n  throw new Error('Provide either fileUrl (remote file) or filename (binary upload).');\n}\n\n// \u2500\u2500 Email format validation \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst emailRegex = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;\nif (!emailRegex.test(body.recipientEmail)) {\n  throw new Error(`Invalid recipientEmail: ${body.recipientEmail}`);\n}\n\n// \u2500\u2500 Expiry hours \u2014 clamp between 1 and 168 (1 week) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst requestedHours = parseFloat(body.expiryHours);\nconst expiryHours = isNaN(requestedHours)\n  ? parseFloat($vars.DEFAULT_EXPIRY_HOURS || '24')\n  : Math.min(168, Math.max(1, requestedHours));\n\n// \u2500\u2500 Filename & extension \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst filename = body.filename ||\n  body.fileUrl?.split('?')[0].split('/').pop() ||\n  'preview.pdf';\nconst ext = filename.split('.').pop()?.toLowerCase() || 'pdf';\nconst allowedExts = ['pdf', 'jpg', 'jpeg', 'png', 'docx', 'pptx', 'mp4', 'zip'];\nif (!allowedExts.includes(ext)) {\n  throw new Error(`File type .${ext} not supported. Accepted: ${allowedExts.join(', ')}`);\n}\n\n// \u2500\u2500 Sanitise strings \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst s = v => String(v || '').trim().slice(0, 255);\n\nreturn [{\n  json: {\n    fileUrl: body.fileUrl || null,\n    filename,\n    ext,\n    recipientEmail: body.recipientEmail.toLowerCase().trim(),\n    recipientName: s(body.recipientName) || 'Valued Client',\n    projectName: s(body.projectName) || 'Project Preview',\n    senderName: s(body.senderName) || s($vars.AGENCY_NAME) || 'The Team',\n    agencyEmail: s(body.agencyEmail) || s($vars.AGENCY_EMAIL),\n    expiryHours,\n    message: s(body.message),\n    receivedAt: new Date().toISOString()\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "765d6d43-5953-41cb-8fb9-78749f4c4ca2",
      "name": "Has Remote URL?",
      "type": "n8n-nodes-base.if",
      "position": [
        976,
        928
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "caseSensitive": false,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "cond-url",
              "operator": {
                "type": "string",
                "operation": "notEmpty"
              },
              "leftValue": "={{ $json.fileUrl }}",
              "rightValue": ""
            }
          ]
        }
      },
      "typeVersion": 2
    },
    {
      "id": "4c1525aa-a68f-4ff4-978a-f114f70da134",
      "name": "Upload to URL - Remote",
      "type": "n8n-nodes-uploadtourl.uploadToUrl",
      "position": [
        1152,
        816
      ],
      "parameters": {
        "operation": "uploadFile"
      },
      "credentials": {
        "uploadToUrlApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "995e4b82-2dde-4a1f-9be7-5ba662423746",
      "name": "Upload to URL - Binary",
      "type": "n8n-nodes-uploadtourl.uploadToUrl",
      "position": [
        1152,
        992
      ],
      "parameters": {
        "operation": "uploadFile"
      },
      "credentials": {
        "uploadToUrlApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "d10b01dc-82f6-4a59-8204-ab2b51d3ed6a",
      "name": "Extract CDN URL",
      "type": "n8n-nodes-base.code",
      "position": [
        1328,
        928
      ],
      "parameters": {
        "jsCode": "const uploadResp = $input.first().json;\nconst meta = $('Validate Payload').first().json;\n\nconst cdnUrl =\n  uploadResp.url ||\n  uploadResp.link ||\n  uploadResp.data?.url ||\n  uploadResp.file?.url ||\n  uploadResp.shortUrl;\n\nif (!cdnUrl) {\n  throw new Error('Upload to URL returned no public URL. Raw: ' + JSON.stringify(uploadResp).slice(0, 300));\n}\n\nreturn [{\n  json: {\n    ...meta,\n    cdnUrl: cdnUrl.replace(/^http:\\/\\//, 'https://'),\n    uploadId: uploadResp.id || uploadResp.data?.id || null,\n    fileSizeBytes: uploadResp.size || uploadResp.data?.size || null\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "b8c5df77-1f69-4c7f-a708-52a1e11f5346",
      "name": "Generate Link Record & Email HTML",
      "type": "n8n-nodes-base.code",
      "notes": "Generates a unique hex token and expiry timestamp. Pre-builds all three HTML emails (preview, client expiry notice, agency summary) in one pass so downstream nodes just reference the pre-rendered strings.",
      "position": [
        1456,
        928
      ],
      "parameters": {
        "jsCode": "const data = $input.first().json;\n\n// \u2500\u2500 Generate unique token \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Hex token built from timestamp + random component\nconst randomPart = Math.random().toString(16).slice(2, 10).toUpperCase();\nconst timePart = Date.now().toString(16).toUpperCase();\nconst token = `${timePart}-${randomPart}`;\n\n// \u2500\u2500 Compute expiry timestamp \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst sentAt = new Date();\nconst expiresAt = new Date(sentAt.getTime() + data.expiryHours * 60 * 60 * 1000);\n\n// \u2500\u2500 Human-readable expiry string for emails \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst formatDate = d => d.toUTCString().replace('GMT', 'UTC');\n\n// \u2500\u2500 Compute wait duration in milliseconds for Wait node \u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst waitMs = data.expiryHours * 60 * 60 * 1000;\n\n// \u2500\u2500 Build branded preview email HTML \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst previewEmailHtml = `\n<!DOCTYPE html>\n<html>\n<body style=\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 600px; margin: 0 auto; padding: 32px; color: #1a1a1a;\">\n  <div style=\"border-bottom: 3px solid #4F46E5; padding-bottom: 16px; margin-bottom: 24px;\">\n    <h1 style=\"margin: 0; font-size: 22px; color: #4F46E5;\">${data.senderName}</h1>\n    <p style=\"margin: 4px 0 0; color: #6b7280; font-size: 14px;\">Secure File Preview</p>\n  </div>\n  <p style=\"font-size: 16px;\">Hi ${data.recipientName},</p>\n  <p>You have a new file available for preview:</p>\n  <div style=\"background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 8px; padding: 20px; margin: 20px 0;\">\n    <p style=\"margin: 0 0 8px; font-weight: 600; font-size: 18px;\">\ud83d\udcc4 ${data.filename}</p>\n    <p style=\"margin: 0; color: #6b7280; font-size: 14px;\">Project: ${data.projectName}</p>\n  </div>\n  ${data.message ? `<p style=\"background: #eff6ff; border-left: 4px solid #4F46E5; padding: 12px 16px; border-radius: 0 6px 6px 0; font-style: italic;\">${data.message}</p>` : ''}\n  <div style=\"text-align: center; margin: 32px 0;\">\n    <a href=\"${data.cdnUrl}\" style=\"background: #4F46E5; color: white; padding: 14px 32px; border-radius: 8px; text-decoration: none; font-weight: 600; font-size: 16px; display: inline-block;\">View File</a>\n  </div>\n  <div style=\"background: #fef3c7; border: 1px solid #fbbf24; border-radius: 6px; padding: 12px 16px; font-size: 13px; color: #92400e;\">\n    \u23f1\ufe0f <strong>This link expires on ${formatDate(expiresAt)}</strong> (${data.expiryHours} hours from now). After that, you will no longer be able to access this file.\n  </div>\n  <p style=\"font-size: 13px; color: #9ca3af; margin-top: 32px;\">Sent securely via ${data.senderName} \u00b7 Token: ${token}</p>\n</body>\n</html>`;\n\n// \u2500\u2500 Build expiry notification HTML (to client) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst expiryClientHtml = `\n<!DOCTYPE html>\n<html>\n<body style=\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 600px; margin: 0 auto; padding: 32px; color: #1a1a1a;\">\n  <div style=\"border-bottom: 3px solid #ef4444; padding-bottom: 16px; margin-bottom: 24px;\">\n    <h1 style=\"margin: 0; font-size: 22px; color: #ef4444;\">Preview Link Expired</h1>\n  </div>\n  <p>Hi ${data.recipientName},</p>\n  <p>The preview link for <strong>${data.filename}</strong> (Project: ${data.projectName}) has now expired and is no longer accessible.</p>\n  <p>If you need continued access to this file, please contact <strong>${data.senderName}</strong>.</p>\n  <p style=\"font-size: 13px; color: #9ca3af;\">Token: ${token} \u00b7 Expired: ${formatDate(expiresAt)}</p>\n</body>\n</html>`;\n\n// \u2500\u2500 Build agency summary HTML \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst agencySummaryHtml = `\n<!DOCTYPE html>\n<html>\n<body style=\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 600px; margin: 0 auto; padding: 32px; color: #1a1a1a;\">\n  <h2>\ud83d\udd12 Link Lifecycle Complete</h2>\n  <table style=\"width: 100%; border-collapse: collapse; font-size: 14px;\">\n    <tr style=\"border-bottom: 1px solid #e5e7eb;\"><td style=\"padding: 8px; color: #6b7280;\">Token</td><td style=\"padding: 8px; font-weight: 600;\">${token}</td></tr>\n    <tr style=\"border-bottom: 1px solid #e5e7eb;\"><td style=\"padding: 8px; color: #6b7280;\">File</td><td style=\"padding: 8px;\">${data.filename}</td></tr>\n    <tr style=\"border-bottom: 1px solid #e5e7eb;\"><td style=\"padding: 8px; color: #6b7280;\">Project</td><td style=\"padding: 8px;\">${data.projectName}</td></tr>\n    <tr style=\"border-bottom: 1px solid #e5e7eb;\"><td style=\"padding: 8px; color: #6b7280;\">Recipient</td><td style=\"padding: 8px;\">${data.recipientName} &lt;${data.recipientEmail}&gt;</td></tr>\n    <tr style=\"border-bottom: 1px solid #e5e7eb;\"><td style=\"padding: 8px; color: #6b7280;\">Sent At</td><td style=\"padding: 8px;\">${formatDate(sentAt)}</td></tr>\n    <tr style=\"border-bottom: 1px solid #e5e7eb;\"><td style=\"padding: 8px; color: #6b7280;\">Expired At</td><td style=\"padding: 8px;\">${formatDate(expiresAt)}</td></tr>\n    <tr><td style=\"padding: 8px; color: #6b7280;\">CDN URL</td><td style=\"padding: 8px; word-break: break-all; font-size: 12px;\">${data.cdnUrl}</td></tr>\n  </table>\n</body>\n</html>`;\n\nreturn [{\n  json: {\n    ...data,\n    token,\n    sentAt: sentAt.toISOString(),\n    expiresAt: expiresAt.toISOString(),\n    expiresAtHuman: formatDate(expiresAt),\n    waitMs,\n    linkStatus: 'active',\n    previewEmailHtml,\n    expiryClientHtml,\n    agencySummaryHtml\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "2407e4af-6584-4f00-a08c-94e36d36284a",
      "name": "Sheets - Log Active Link",
      "type": "n8n-nodes-base.googleSheets",
      "notes": "Logs the link record BEFORE sending the email \u2014 ensures a row always exists even if SendGrid fails.",
      "position": [
        1712,
        928
      ],
      "parameters": {
        "columns": {
          "value": {
            "Token": "={{ $json.token }}",
            "Sender": "={{ $json.senderName }}",
            "Status": "active",
            "CDN URL": "={{ $json.cdnUrl }}",
            "Sent At": "={{ $json.sentAt }}",
            "File Name": "={{ $json.filename }}",
            "Expires At": "={{ $json.expiresAt }}",
            "Expiry Hours": "={{ $json.expiryHours }}",
            "Project Name": "={{ $json.projectName }}",
            "Recipient Name": "={{ $json.recipientName }}",
            "File Size Bytes": "={{ $json.fileSizeBytes }}",
            "Recipient Email": "={{ $json.recipientEmail }}"
          },
          "mappingMode": "defineBelow"
        },
        "options": {},
        "operation": "append",
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "BurnerLinks"
        },
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $vars.GSHEET_SPREADSHEET_ID }}"
        }
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.5
    },
    {
      "id": "aa27043d-01a4-4efa-ada6-81ce4ce6a8ff",
      "name": "SendGrid - Send Preview Email",
      "type": "n8n-nodes-base.sendGrid",
      "notes": "Sends a branded HTML preview email to the client with expiry notice. Agency email is BCC'd for records.",
      "position": [
        1920,
        928
      ],
      "parameters": {},
      "typeVersion": 1
    },
    {
      "id": "b9cbd2f6-b0af-4f65-a6a0-2f7c09258e14",
      "name": "Respond to Webhook",
      "type": "n8n-nodes-base.respondToWebhook",
      "notes": "Returns immediately after the email is sent \u2014 before the Wait node fires. The caller gets confirmation without waiting 24 hours for the workflow to complete.",
      "position": [
        2192,
        928
      ],
      "parameters": {
        "options": {
          "responseCode": 201,
          "responseHeaders": {
            "entries": [
              {
                "name": "Content-Type",
                "value": "application/json"
              }
            ]
          }
        },
        "respondWith": "json",
        "responseBody": "={\n  \"success\": true,\n  \"message\": \"Preview link sent to {{ $json.recipientEmail }}\",\n  \"token\": \"{{ $json.token }}\",\n  \"recipientEmail\": \"{{ $json.recipientEmail }}\",\n  \"cdnUrl\": \"{{ $json.cdnUrl }}\",\n  \"expiresAt\": \"{{ $json.expiresAt }}\",\n  \"expiryHours\": {{ $json.expiryHours }},\n  \"projectName\": \"{{ $json.projectName }}\",\n  \"filename\": \"{{ $json.filename }}\"\n}"
      },
      "typeVersion": 1.1
    },
    {
      "id": "8807a34e-c45c-4b40-9af5-81c5904704ac",
      "name": "Wait - Until Link Expires",
      "type": "n8n-nodes-base.wait",
      "notes": "Pauses this execution instance for the exact expiry duration. Set dynamically from expiryHours. n8n cloud persists the execution automatically; self-hosted requires execution persistence enabled.",
      "position": [
        2624,
        928
      ],
      "parameters": {
        "unit": "milliseconds",
        "amount": "={{ $('Generate Link Record & Email HTML').first().json.waitMs }}"
      },
      "typeVersion": 1.1
    },
    {
      "id": "6ddd47c7-58c0-4094-9834-965a45279237",
      "name": "Sheets - Mark Link Expired",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        2832,
        928
      ],
      "parameters": {
        "columns": {
          "value": {
            "Token": "={{ $('Generate Link Record & Email HTML').first().json.token }}",
            "Status": "expired",
            "Expired At": "={{ new Date().toISOString() }}"
          },
          "mappingMode": "defineBelow"
        },
        "options": {},
        "operation": "update",
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "BurnerLinks"
        },
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $vars.GSHEET_SPREADSHEET_ID }}"
        }
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.5
    },
    {
      "id": "95a593f7-9ccf-4fbf-b5bf-be34023acc05",
      "name": "SendGrid - Notify Client Expired",
      "type": "n8n-nodes-base.sendGrid",
      "position": [
        3056,
        816
      ],
      "parameters": {},
      "typeVersion": 1
    },
    {
      "id": "a4a50667-1ff6-4330-8b6f-8cbb28a1e69c",
      "name": "SendGrid - Agency Summary Email",
      "type": "n8n-nodes-base.sendGrid",
      "notes": "Sends the agency a full lifecycle summary: token, file, recipient, sent time, expired time, and CDN URL.",
      "position": [
        3072,
        1040
      ],
      "parameters": {},
      "typeVersion": 1
    }
  ],
  "connections": {
    "Extract CDN URL": {
      "main": [
        [
          {
            "node": "Generate Link Record & Email HTML",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Has Remote URL?": {
      "main": [
        [
          {
            "node": "Upload to URL - Remote",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Upload to URL - Binary",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Validate Payload": {
      "main": [
        [
          {
            "node": "Has Remote URL?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Respond to Webhook": {
      "main": [
        [
          {
            "node": "Wait - Until Link Expires",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Upload to URL - Binary": {
      "main": [
        [
          {
            "node": "Extract CDN URL",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Upload to URL - Remote": {
      "main": [
        [
          {
            "node": "Extract CDN URL",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Sheets - Log Active Link": {
      "main": [
        [
          {
            "node": "SendGrid - Send Preview Email",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Wait - Until Link Expires": {
      "main": [
        [
          {
            "node": "Sheets - Mark Link Expired",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Sheets - Mark Link Expired": {
      "main": [
        [
          {
            "node": "SendGrid - Notify Client Expired",
            "type": "main",
            "index": 0
          },
          {
            "node": "SendGrid - Agency Summary Email",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Webhook - Create Burner Link": {
      "main": [
        [
          {
            "node": "Validate Payload",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "SendGrid - Send Preview Email": {
      "main": [
        [
          {
            "node": "Respond to Webhook",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Generate Link Record & Email HTML": {
      "main": [
        [
          {
            "node": "Sheets - Log Active Link",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}