This workflow corresponds to n8n.io template #13634 — we link there as the canonical source.
The workflow JSON
Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →
{
"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} <${data.recipientEmail}></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
}
]
]
}
}
}
Credentials you'll need
Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.
googleSheetsOAuth2ApiuploadToUrlApi
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
Stop sending sensitive agency drafts as permanent email attachments. This workflow creates a "self-destructing" delivery system that hosts files via UploadToURL, sends branded previews via SendGrid, and automatically expires access after a set duration while logging the entire…
Source: https://n8n.io/workflows/13634/ — original creator credit. Request a take-down →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
A complete email campaign automation system featuring dual-mode access control (Demo/Pro), usage tracking, and professional email delivery. Perfect for SaaS products, marketing agencies, or anyone bui
Automate WhatsApp communication for recruitment agencies with an interactive, structured customer experience. This workflow handles pricing inquiries, request submissions, tracking, complaints, and hu
Code. Uses googleSheets, gmail, supabase, stickyNote. Webhook trigger; 51 nodes.
This template turns Podium's conversation inbox into a full sales CRM with a custom funnel, AI message classification, automated drip follow-ups, daily admin reports, and a live Kanban dashboard. Six
Ticketing Backend automates registration, QR-ticket generation, email delivery, and check-in validation using Google Sheets, Gmail, and a webhook scanner — reducing manual ticket prep from ~3 hours to