{
  "meta": {
    "templateCredsSetupCompleted": true
  },
  "nodes": [
    {
      "id": "f133eb2f-a75f-4891-8adb-b8809a3703a0",
      "name": "\ud83d\udccb Overview",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -2256,
        -928
      ],
      "parameters": {
        "width": 560,
        "height": 468,
        "content": "AI Social Media Auto-Scheduler via UploadToURL\nAutomate the bridge between local media files and live social posts. This workflow hosts your assets via UploadToURL, generates platform-specific copy with AI, and schedules the results via Buffer.\n\n\u2699\ufe0f How it works\nWebhook receives a file (Binary or URL) and platform preferences.\n\nUploadToURL hosts the asset and returns a public CDN link.\n\nOpenAI generates a platform-optimized caption, hashtags, and alt-text.\n\nBuffer schedules the post to Twitter, Instagram, or LinkedIn.\n\n\ud83d\udd27 Setup\nInstall n8n-nodes-uploadtourl community node.\n\nAdd UploadToURL, OpenAI, and Buffer credentials.\n\nSet variables: BUFFER_PROFILE_TWITTER, BUFFER_PROFILE_INSTAGRAM, BUFFER_PROFILE_LINKEDIN."
      },
      "typeVersion": 1
    },
    {
      "id": "efc15edf-aa70-4916-8862-a9ccf488650f",
      "name": "Section 1 \u2014 Intake",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1328,
        -368
      ],
      "parameters": {
        "color": 7,
        "width": 420,
        "height": 616,
        "content": "## 1 \u2014 Intake & validation\n\n**Webhook \u2192 Validate & Enrich \u2192 Has Remote URL?**\n\nThe webhook accepts a POST with `fileUrl` or binary data plus `platform`, `tone`, `brand`, `campaignContext`, and optional `scheduleTime`.\n\nThe code node checks that at least a filename or URL is present, validates the platform against the allowed list, maps per-platform character limits (Twitter 280, Instagram 2200, LinkedIn 3000), detects content type from the file extension, and cleans the filename into readable words for the AI prompt.\n\nThe IF node then routes to the correct upload path based on whether `fileUrl` is set."
      },
      "typeVersion": 1
    },
    {
      "id": "7019ee99-0415-4fe0-bde9-2dc1968482e3",
      "name": "Section 2 \u2014 Upload",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -816,
        -400
      ],
      "parameters": {
        "color": 7,
        "width": 420,
        "height": 656,
        "content": "## 2 \u2014 File hosting via UploadToURL\n\n**Upload to URL (\u00d72) \u2192 Extract clean URL**\n\nTwo parallel branches handle remote URLs and binary uploads using the native **UploadToURL community node** \u2014 no custom HTTP request needed.\n\nThe code node after both branches normalises the response (different field names across API versions) and throws a descriptive error if no URL is returned, so failures are easy to debug in the execution log."
      },
      "typeVersion": 1
    },
    {
      "id": "dac3623b-f83e-4c52-ba1e-07f1fb18e8b3",
      "name": "Section 3 \u2014 Caption",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -288,
        -352
      ],
      "parameters": {
        "color": 7,
        "width": 420,
        "height": 548,
        "content": "## 3 \u2014 AI caption generation\n\n**OpenAI \u2192 Assemble post payload**\n\nOpenAI GPT-4.1 mini receives the platform, tone, brand, campaign context, and character limit. The response format is set to `json_object` so the output is always structured \u2014 no parsing of freetext.\n\nThe returned fields are: `caption`, `hashtags[]`, `altText`, `hook`, `bestPostingTimeUTC`, and `estimatedEngagement`.\n\nThe assemble node appends hashtags, checks the character limit, and calculates the schedule time \u2014 using the AI's UTC hour suggestion if none was provided in the original request."
      },
      "typeVersion": 1
    },
    {
      "id": "b40c1ab1-1bbd-4d4a-8f3a-8a9f9a76b8d8",
      "name": "Section 4 \u2014 Scheduling",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        272,
        -528
      ],
      "parameters": {
        "color": 7,
        "width": 420,
        "height": 772,
        "content": "## 4 \u2014 Platform routing & scheduling\n\n**Switch \u2192 Buffer (Twitter / Instagram / LinkedIn) \u2192 Response**\n\nThe Switch node routes on the `platform` value. Each Buffer branch is slightly different: Instagram includes the `photo` field, LinkedIn includes a `title`, and Twitter enforces the 280-character limit set earlier.\n\nAll three branches merge into the same response node, which returns `scheduleId`, `assetUrl`, `caption`, `hashtags`, and `estimatedEngagement`.\n\nTo add more platforms (Facebook, TikTok), add a new output on the Switch node and duplicate one of the Buffer nodes."
      },
      "typeVersion": 1
    },
    {
      "id": "864f54db-ad25-43da-88e2-f8d532ca009a",
      "name": "Webhook - Receive Asset",
      "type": "n8n-nodes-base.webhook",
      "position": [
        -1296,
        0
      ],
      "parameters": {
        "path": "social-asset-upload",
        "options": {
          "allowedOrigins": "*"
        },
        "httpMethod": "POST",
        "responseMode": "responseNode"
      },
      "typeVersion": 2
    },
    {
      "id": "884d58fc-3087-4b7a-ae3f-6e726cb0731e",
      "name": "Validate & Enrich Payload",
      "type": "n8n-nodes-base.code",
      "position": [
        -1072,
        0
      ],
      "parameters": {
        "jsCode": "const body = $input.first().json.body || $input.first().json;\nconst allowedPlatforms = ['instagram', 'twitter', 'linkedin', 'facebook'];\nconst platform = (body.platform || 'instagram').toLowerCase();\n\nif (!body.filename && !body.fileUrl) {\n  throw new Error('Missing required fields: provide filename or fileUrl');\n}\nif (!allowedPlatforms.includes(platform)) {\n  throw new Error(`Invalid platform. Must be one of: ${allowedPlatforms.join(', ')}`);\n}\n\nconst charLimits = { twitter: 280, instagram: 2200, linkedin: 3000, facebook: 63206 };\nconst filename = body.filename || body.fileUrl?.split('/').pop() || 'asset';\nconst ext = filename.split('.').pop()?.toLowerCase();\nconst isVideo = ['mp4', 'mov', 'avi', 'webm'].includes(ext);\nconst isImage = ['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(ext);\nconst cleanName = filename\n  .replace(/\\.[^.]+$/, '')\n  .replace(/[-_]/g, ' ')\n  .replace(/([a-z])([A-Z])/g, '$1 $2')\n  .toLowerCase().trim();\n\nreturn [{\n  json: {\n    filename,\n    cleanName,\n    fileUrl: body.fileUrl || null,\n    platform,\n    tone: body.tone || 'professional',\n    includeHashtags: body.hashtags !== false,\n    charLimit: charLimits[platform],\n    contentType: isVideo ? 'video' : isImage ? 'image' : 'file',\n    brand: body.brand || '',\n    campaignContext: body.campaignContext || '',\n    scheduleTime: body.scheduleTime || null,\n    timestamp: new Date().toISOString()\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "db7db58d-7cf7-4e62-b803-f63391f1ad04",
      "name": "Has Remote URL?",
      "type": "n8n-nodes-base.if",
      "position": [
        -848,
        0
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "caseSensitive": false,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "cond-has-url",
              "operator": {
                "type": "string",
                "operation": "notEmpty"
              },
              "leftValue": "={{ $json.fileUrl }}",
              "rightValue": ""
            }
          ]
        }
      },
      "typeVersion": 2
    },
    {
      "id": "72d26911-946b-48e2-a036-5e17b33cb72b",
      "name": "Upload to URL - From Remote URL",
      "type": "n8n-nodes-uploadtourl.uploadToUrl",
      "position": [
        -624,
        -128
      ],
      "parameters": {},
      "credentials": {
        "uploadToUrlApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "802f6c34-2f99-456a-aa49-51e158d8a539",
      "name": "Upload to URL - From Binary",
      "type": "n8n-nodes-uploadtourl.uploadToUrl",
      "position": [
        -624,
        112
      ],
      "parameters": {
        "operation": "uploadFile"
      },
      "credentials": {
        "uploadToUrlApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "05fd0d9d-9d64-4b79-b01c-e9b320df2bac",
      "name": "Extract Clean URL",
      "type": "n8n-nodes-base.code",
      "position": [
        -416,
        0
      ],
      "parameters": {
        "jsCode": "const uploadResponse = $input.first().json;\nconst meta = $('Validate & Enrich Payload').first().json;\n\nconst cleanUrl =\n  uploadResponse.url ||\n  uploadResponse.link ||\n  uploadResponse.data?.url ||\n  uploadResponse.file?.url ||\n  uploadResponse.shortUrl;\n\nif (!cleanUrl) {\n  throw new Error('Upload to URL did not return a valid public URL. Response: ' + JSON.stringify(uploadResponse));\n}\n\nreturn [{\n  json: {\n    ...meta,\n    cleanUrl,\n    uploadId: uploadResponse.id || uploadResponse.data?.id || null,\n    fileSize: uploadResponse.size || uploadResponse.data?.size || null\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "110bd7b7-39bd-4253-9270-1e5b4709d6c1",
      "name": "OpenAI - Generate Caption",
      "type": "@n8n/n8n-nodes-langchain.openAi",
      "position": [
        -192,
        0
      ],
      "parameters": {
        "modelId": {
          "__rl": true,
          "mode": "list",
          "value": "gpt-4.1-mini",
          "cachedResultName": "GPT-4.1-MINI"
        },
        "options": {
          "maxTokens": 800,
          "temperature": 0.75
        },
        "messages": {
          "values": [
            {
              "role": "system",
              "content": "You are an expert social media copywriter. Always return a valid JSON object only \u2014 no markdown, no preamble."
            },
            {
              "content": "=Generate a social media caption.\n\n**Platform:** {{ $json.platform }}\n**Asset Name:** {{ $json.cleanName }}\n**Content Type:** {{ $json.contentType }}\n**Tone:** {{ $json.tone }}\n**Brand:** {{ $json.brand || 'Not specified' }}\n**Campaign Context:** {{ $json.campaignContext || 'General post' }}\n**Character Limit:** {{ $json.charLimit }}\n**Include Hashtags:** {{ $json.includeHashtags }}\n\nReturn ONLY this JSON:\n{\n  \"caption\": \"Main caption text under the char limit\",\n  \"hashtags\": [\"relevant\", \"hashtags\", \"without\", \"hash\"],\n  \"altText\": \"Accessibility description of the image or video\",\n  \"hook\": \"First-line hook for A/B testing\",\n  \"emojiUsage\": \"low|medium|high\",\n  \"bestPostingTimeUTC\": \"e.g. 14:00-16:00\",\n  \"estimatedEngagement\": \"low|medium|high\"\n}"
            }
          ]
        }
      },
      "credentials": {
        "openAiApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1.5
    },
    {
      "id": "2c2f4205-31a3-44f9-8a99-b498bd1cd094",
      "name": "Assemble Post Payload",
      "type": "n8n-nodes-base.code",
      "position": [
        112,
        0
      ],
      "parameters": {
        "jsCode": "const openAIRaw = $input.first().json;\nconst assetData = $('Extract Clean URL').first().json;\n\nlet captionData;\ntry {\n  const raw =\n    openAIRaw.message?.content ||\n    openAIRaw.choices?.[0]?.message?.content ||\n    openAIRaw.content ||\n    openAIRaw.text;\n  captionData = typeof raw === 'string' ? JSON.parse(raw) : raw;\n} catch (e) {\n  throw new Error('Failed to parse OpenAI caption JSON: ' + e.message);\n}\n\nconst hashtagString =\n  assetData.includeHashtags && captionData.hashtags?.length\n    ? '\\n\\n' + captionData.hashtags.map(h => (h.startsWith('#') ? h : '#' + h)).join(' ')\n    : '';\n\nconst fullCaption = captionData.caption + hashtagString;\n\nif (fullCaption.length > assetData.charLimit) {\n  console.warn(\n    `Caption ${fullCaption.length} chars exceeds ${assetData.platform} limit of ${assetData.charLimit}`\n  );\n}\n\nlet scheduleAt = assetData.scheduleTime;\nif (!scheduleAt) {\n  const suggestedHour = parseInt(\n    (captionData.bestPostingTimeUTC || '14:00').split(':')[0],\n    10\n  );\n  const next = new Date();\n  next.setUTCHours(suggestedHour, 0, 0, 0);\n  if (next <= new Date()) next.setUTCDate(next.getUTCDate() + 1);\n  scheduleAt = next.toISOString();\n}\n\nreturn [{\n  json: {\n    assetUrl: assetData.cleanUrl,\n    filename: assetData.filename,\n    contentType: assetData.contentType,\n    platform: assetData.platform,\n    caption: captionData.caption,\n    hashtags: captionData.hashtags || [],\n    fullCaption,\n    altText: captionData.altText || '',\n    hook: captionData.hook || '',\n    emojiUsage: captionData.emojiUsage,\n    scheduleAt,\n    suggestedPostTime: captionData.bestPostingTimeUTC,\n    estimatedEngagement: captionData.estimatedEngagement,\n    uploadId: assetData.uploadId,\n    generatedAt: new Date().toISOString()\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "5e6319f4-40e9-44e2-8ec3-b743a6d6fa4d",
      "name": "Route by Platform",
      "type": "n8n-nodes-base.switch",
      "position": [
        256,
        0
      ],
      "parameters": {
        "rules": {
          "values": [
            {
              "outputKey": "Twitter/X",
              "conditions": {
                "options": {
                  "caseSensitive": false,
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    },
                    "leftValue": "={{ $json.platform }}",
                    "rightValue": "twitter"
                  }
                ]
              },
              "renameOutput": true
            },
            {
              "outputKey": "Instagram",
              "conditions": {
                "options": {
                  "caseSensitive": false,
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    },
                    "leftValue": "={{ $json.platform }}",
                    "rightValue": "instagram"
                  }
                ]
              },
              "renameOutput": true
            },
            {
              "outputKey": "LinkedIn",
              "conditions": {
                "options": {
                  "caseSensitive": false,
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    },
                    "leftValue": "={{ $json.platform }}",
                    "rightValue": "linkedin"
                  }
                ]
              },
              "renameOutput": true
            }
          ]
        },
        "options": {
          "fallbackOutput": "extra"
        }
      },
      "typeVersion": 3
    },
    {
      "id": "dcc70bd8-774f-4579-9cbc-81abd7fd848e",
      "name": "Buffer - Schedule Twitter/X",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        480,
        -160
      ],
      "parameters": {
        "url": "https://api.bufferapp.com/1/updates/create.json",
        "method": "POST",
        "options": {},
        "jsonBody": "={\"text\": {{ JSON.stringify($json.fullCaption) }}, \"media\": {\"link\": {{ JSON.stringify($json.assetUrl) }}, \"description\": {{ JSON.stringify($json.altText) }}}, \"scheduled_at\": {{ JSON.stringify($json.scheduleAt) }}, \"profile_ids\": [\"{{ $vars.BUFFER_PROFILE_TWITTER }}\"]}",
        "sendBody": true,
        "specifyBody": "json",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth"
      },
      "credentials": {
        "httpHeaderAuth": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "0e273c5b-45f8-4daa-9ab9-e523bae33f05",
      "name": "Buffer - Schedule Instagram",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        480,
        16
      ],
      "parameters": {
        "url": "https://api.bufferapp.com/1/updates/create.json",
        "method": "POST",
        "options": {},
        "jsonBody": "={\"text\": {{ JSON.stringify($json.fullCaption) }}, \"media\": {\"link\": {{ JSON.stringify($json.assetUrl) }}, \"description\": {{ JSON.stringify($json.altText) }}, \"photo\": {{ JSON.stringify($json.contentType === 'image' ? $json.assetUrl : '') }}}, \"scheduled_at\": {{ JSON.stringify($json.scheduleAt) }}, \"profile_ids\": [\"{{ $vars.BUFFER_PROFILE_INSTAGRAM }}\"]}",
        "sendBody": true,
        "specifyBody": "json",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth"
      },
      "credentials": {
        "httpHeaderAuth": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "7bd22a5a-b101-4811-b559-96d93269384d",
      "name": "Buffer - Schedule LinkedIn",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        480,
        160
      ],
      "parameters": {
        "url": "https://api.bufferapp.com/1/updates/create.json",
        "method": "POST",
        "options": {},
        "jsonBody": "={\"text\": {{ JSON.stringify($json.fullCaption) }}, \"media\": {\"link\": {{ JSON.stringify($json.assetUrl) }}, \"title\": {{ JSON.stringify($json.filename) }}, \"description\": {{ JSON.stringify($json.altText) }}}, \"scheduled_at\": {{ JSON.stringify($json.scheduleAt) }}, \"profile_ids\": [\"{{ $vars.BUFFER_PROFILE_LINKEDIN }}\"]}",
        "sendBody": true,
        "specifyBody": "json",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth"
      },
      "credentials": {
        "httpHeaderAuth": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "2ad95a6f-8c6b-4649-b2c3-e0b8fdfbab58",
      "name": "Build Final Response",
      "type": "n8n-nodes-base.code",
      "position": [
        704,
        0
      ],
      "parameters": {
        "jsCode": "const bufferResponse = $input.first().json;\nconst postData = $('Assemble Post Payload').first().json;\nconst success = !bufferResponse.error && bufferResponse.success !== false;\n\nreturn [{\n  json: {\n    success,\n    message: success\n      ? `Post scheduled on ${postData.platform} for ${postData.scheduleAt}`\n      : `Buffer scheduling failed: ${bufferResponse.error || 'Unknown error'}`,\n    scheduleId: bufferResponse.update?.id || bufferResponse.data?.id || null,\n    assetUrl: postData.assetUrl,\n    platform: postData.platform,\n    scheduledAt: postData.scheduleAt,\n    caption: postData.caption,\n    hook: postData.hook,\n    hashtags: postData.hashtags,\n    altText: postData.altText,\n    estimatedEngagement: postData.estimatedEngagement,\n    bufferError: bufferResponse.error || null\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "6b130b72-a224-4a00-920c-be81955b6abe",
      "name": "Respond to Webhook",
      "type": "n8n-nodes-base.respondToWebhook",
      "position": [
        912,
        0
      ],
      "parameters": {
        "options": {
          "responseCode": 200,
          "responseHeaders": {
            "entries": [
              {
                "name": "Content-Type",
                "value": "application/json"
              }
            ]
          }
        },
        "respondWith": "json",
        "responseBody": "={{ $json }}"
      },
      "typeVersion": 1.1
    }
  ],
  "connections": {
    "Has Remote URL?": {
      "main": [
        [
          {
            "node": "Upload to URL - From Remote URL",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Upload to URL - From Binary",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract Clean URL": {
      "main": [
        [
          {
            "node": "OpenAI - Generate Caption",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Route by Platform": {
      "main": [
        [
          {
            "node": "Buffer - Schedule Twitter/X",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Buffer - Schedule Instagram",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Buffer - Schedule LinkedIn",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Final Response": {
      "main": [
        [
          {
            "node": "Respond to Webhook",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Assemble Post Payload": {
      "main": [
        [
          {
            "node": "Route by Platform",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Webhook - Receive Asset": {
      "main": [
        [
          {
            "node": "Validate & Enrich Payload",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "OpenAI - Generate Caption": {
      "main": [
        [
          {
            "node": "Assemble Post Payload",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Validate & Enrich Payload": {
      "main": [
        [
          {
            "node": "Has Remote URL?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Buffer - Schedule LinkedIn": {
      "main": [
        [
          {
            "node": "Build Final Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Buffer - Schedule Instagram": {
      "main": [
        [
          {
            "node": "Build Final Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Buffer - Schedule Twitter/X": {
      "main": [
        [
          {
            "node": "Build Final Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Upload to URL - From Binary": {
      "main": [
        [
          {
            "node": "Extract Clean URL",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Upload to URL - From Remote URL": {
      "main": [
        [
          {
            "node": "Extract Clean URL",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}