{
  "meta": {
    "templateCredsSetupCompleted": true
  },
  "nodes": [
    {
      "id": "ddbe3e51-4161-460d-99a1-a6e483e0d881",
      "name": "Upload to URL",
      "type": "n8n-nodes-uploadtourl.uploadToUrl",
      "position": [
        8288,
        3424
      ],
      "parameters": {
        "binaryPropertyName": "cardImage"
      },
      "typeVersion": 1
    },
    {
      "id": "c9d602f5-fa62-4ceb-867c-c43e44731ae3",
      "name": "Template Overview1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        5728,
        2384
      ],
      "parameters": {
        "width": 480,
        "height": 800,
        "content": "##  Branded Social Proof Automation\n### Bannerbear + Upload to URL  Instagram\n\n**What this does:**\nDaily schedule fetches the oldest unposted 5-star review from Airtable, generates a branded quote card via Bannerbear, uploads it via Upload to URL to get a public CDN link, then publishes to Instagram. Marks the review as posted and notifies Slack.\n\n**Flow:**\n1.  Schedule -- daily at 10 AM\n2.  Airtable -- fetch oldest unposted 5 review\n3.  IF -- skip gracefully if none found\n4.  Code -- build Bannerbear payload + IG caption\n5.  HTTP -- Bannerbear: submit image generation job\n6.  Wait 5s -- initial render buffer\n7.  HTTP -- Bannerbear: poll for completed status\n8.  IF -- completed vs still pending\n9.  HTTP -- fetch rendered image as binary\n10.  Upload to URL -- upload binary, get public CDN URL\n11.  Code -- merge CDN URL + caption fields\n12.  HTTP -- IG: create media container\n13.  Wait 6s -- IG processing buffer\n14.  HTTP -- IG: publish container\n15.  Airtable -- mark review as posted\n16.  Slack -- notify team\n\n**Required env vars:**\n'IG_USER_ID' 'IG_ACCESS_TOKEN'\n'BANNERBEAR_API_KEY' 'BANNERBEAR_TEMPLATE_ID'\n'AIRTABLE_BASE_ID' 'SLACK_CHANNEL_ID'\n\n**Airtable Reviews table fields:**\n'Reviewer Name' . 'Review Text' . 'Rating'\n'Posted' (checkbox) . 'Submitted At' (date)\n'Instagram Post ID' . 'Posted At' . 'Card Image URL'\n\n**Bannerbear template layer names:**\n'reviewer_name' . 'review_text' . 'star_label'"
      },
      "typeVersion": 1
    },
    {
      "id": "cdf0a485-7827-4883-a7ba-0941a8694c60",
      "name": "Sticky Nodes 1-",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        6320,
        3200
      ],
      "parameters": {
        "color": 7,
        "width": 560,
        "height": 380,
        "content": "###  Nodes 1-3: Schedule, Fetch & Gate\n\n**Schedule Trigger** fires daily at 10 AM.\n\n**Airtable -- Fetch Review** uses 'filterByFormula' to retrieve only the oldest unposted 5-star record. 'AND({Rating}=5, {Posted}=FALSE())' + sort by 'Submitted At asc' + 'maxRecords: 1' ensures FIFO queue dispatch.\n\n**IF -- Has Valid Review?** exits cleanly on false branch if no records are found."
      },
      "typeVersion": 1
    },
    {
      "id": "d26ae5a7-904b-4468-bf14-e4d9df09508f",
      "name": "Sticky Nodes 4-",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        6960,
        3232
      ],
      "parameters": {
        "color": 7,
        "width": 408,
        "height": 340,
        "content": "###  Nodes 4-5: Build Payload & Generate Image\n\n**Code -- Prepare Payload** formats review data into Bannerbear modifications array, truncates card text to 180 chars, and builds the full Instagram caption (max 2200 chars).\n\n**HTTP -- Bannerbear Create Job** POSTs to '/v2/images'. Returns a 'uid' for polling. Generation is async -- initial response is 'pending'."
      },
      "typeVersion": 1
    },
    {
      "id": "78dc78cc-4973-435c-92b9-9fe61abb279a",
      "name": "Sticky Nodes 6-",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        7408,
        3200
      ],
      "parameters": {
        "color": 7,
        "width": 500,
        "height": 380,
        "content": "###  Nodes 6-8: Poll Loop\n\n**Wait 5s** -- initial render buffer.\n\n**HTTP -- Poll Status** calls 'GET /v2/images/{uid}'. Returns 'status: pending' or 'status: completed'.\n\n**IF -- Image Ready?** branches on 'completed'. False branch exits -- extend with a loop back to Wait for full retry logic.\n\nWhen completed, 'image_url' is the rendered PNG CDN link."
      },
      "typeVersion": 1
    },
    {
      "id": "05441a5f-7a54-4e80-81d0-5b63feb34c98",
      "name": "Sticky Nodes 9-",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        8032,
        3184
      ],
      "parameters": {
        "color": 7,
        "width": 540,
        "height": 420,
        "content": "###  Nodes 9-11: Fetch Binary  Upload to URL  Merge\n\n**HTTP Fetch Rendered Card** downloads the Bannerbear image as a binary ('responseFormat: file', stored in 'cardImage').\n\n**Upload to URL** -- the mandatory CDN bridge. Instagram's API requires a direct public HTTPS image URL; it rejects base64 and binary. This node uploads the binary and returns a 'public_url'.\n\n**Code -- Merge** combines the CDN URL with caption and metadata from the earlier Code node."
      },
      "typeVersion": 1
    },
    {
      "id": "b1a9d493-79ac-46d2-8c66-1ab34a00d87c",
      "name": "Sticky Nodes 12-",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        8720,
        3152
      ],
      "parameters": {
        "color": 7,
        "width": 1068,
        "height": 440,
        "content": "###  Nodes 12-16: Publish, Log & Notify\n\n**IG Create Media Container** POSTs to '/media' with CDN URL + caption. Returns 'container_id'.\n\n**Wait 6s** -- IG processing buffer.\n\n**IG Publish Container** calls '/media_publish' with 'creation_id'. Returns live Post ID.\n\n**Airtable -- Mark as Posted** updates record: 'Posted=true', Post ID, timestamp, card URL. Prevents re-posting.\n\n**Slack -- Notify Team** sends reviewer name, review snippet, Post ID, and card URL."
      },
      "typeVersion": 1
    },
    {
      "id": "682ccf50-9530-4130-9c32-4265737b0f1b",
      "name": "Schedule Trigger1",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        6352,
        3424
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "triggerAtHour": 10
            }
          ]
        }
      },
      "typeVersion": 1.1
    },
    {
      "id": "67376d29-bd02-478a-94e8-8e524264d847",
      "name": "Airtable Fetch Review1",
      "type": "n8n-nodes-base.airtable",
      "position": [
        6528,
        3424
      ],
      "parameters": {
        "base": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $env.AIRTABLE_BASE_ID }}"
        },
        "table": {
          "__rl": true,
          "mode": "name",
          "value": "Reviews"
        },
        "operation": "list"
      },
      "typeVersion": 2
    },
    {
      "id": "74751a43-093e-4ec5-bad5-e51d981bcfd7",
      "name": "IF Has Valid Review1",
      "type": "n8n-nodes-base.if",
      "position": [
        6752,
        3424
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "cond-has-review",
              "operator": {
                "type": "string",
                "operation": "notEmpty"
              },
              "leftValue": "={{ $json.id }}",
              "rightValue": ""
            }
          ]
        }
      },
      "typeVersion": 2
    },
    {
      "id": "c6ac9392-9df0-41c3-babf-2abb3004fbe7",
      "name": "Code Prepare Bannerbear Payload1",
      "type": "n8n-nodes-base.code",
      "position": [
        7008,
        3424
      ],
      "parameters": {
        "jsCode": "//  Prepare Bannerbear payload + IG caption \nconst review = $input.first().json;\nconst fields = review.fields || review;\n\nconst reviewerName = fields['Reviewer Name'] || 'A Happy Customer';\nconst reviewText   = fields['Review Text']   || '';\nconst rating       = Number(fields['Rating'] || 5);\nconst recordId     = review.id;\n\n// Truncate review text for card layer (180 char max)\nconst cardText = reviewText.length > 180\n  ? reviewText.substring(0, 177) + '...'\n  : reviewText;\n\n// Stars string for card layer\nconst stars = ''.repeat(Math.min(rating, 5));\n\n// Bannerbear modifications  layer names must match your template\nconst modifications = [\n  { name: 'reviewer_name', text: ' ' + reviewerName },\n  { name: 'review_text',   text: '\"' + cardText + '\"' },\n  { name: 'star_label',    text: stars },\n];\n\n// Full Instagram caption (2200 char limit)\nconst captionReview = reviewText.length > 300\n  ? reviewText.substring(0, 297) + '...'\n  : reviewText;\n\nconst hashtagBlock = '#customerreview #5stars #testimonial #socialproof #happycustomer #review #customerexperience';\n\nlet finalCaption = [\n  stars + ' Real words from a real customer.',\n  '',\n  '\"' + captionReview + '\"',\n  ' ' + reviewerName,\n  '',\n  ' See why hundreds of customers trust us.',\n  ' Link in bio.',\n  '',\n  hashtagBlock,\n].join('\\n').trim();\n\nif (finalCaption.length > 2200) {\n  finalCaption = finalCaption.substring(0, 2196) + '...';\n}\n\nconsole.log('[Payload] Reviewer:', reviewerName, '| Record:', recordId);\n\nreturn [{\n  json: {\n    record_id:              recordId,\n    reviewer_name:          reviewerName,\n    review_text:            reviewText,\n    card_text:              cardText,\n    stars:                  stars,\n    modifications:          modifications,\n    final_caption:          finalCaption,\n    ig_user_id:             $env.IG_USER_ID,\n    bannerbear_template_id: $env.BANNERBEAR_TEMPLATE_ID,\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "4770b12d-463e-49b1-b90f-495d66f4e105",
      "name": "HTTP Bannerbear Create Job1",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        7232,
        3424
      ],
      "parameters": {
        "url": "https://api.bannerbear.com/v2/images",
        "body": "={{ JSON.stringify({ template: $json.bannerbear_template_id, modifications: $json.modifications }) }}",
        "method": "POST",
        "options": {},
        "sendBody": true,
        "contentType": "raw",
        "sendHeaders": true,
        "rawContentType": "application/json",
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "=Bearer {{ $env.BANNERBEAR_API_KEY }}"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        }
      },
      "typeVersion": 4.1
    },
    {
      "id": "861f9015-8ce5-45aa-a5cb-5c00b8502552",
      "name": "Wait Before Poll1",
      "type": "n8n-nodes-base.wait",
      "position": [
        7440,
        3424
      ],
      "parameters": {
        "unit": "seconds",
        "amount": 5
      },
      "typeVersion": 1
    },
    {
      "id": "1cb0e82b-e0cb-4d7a-8ca2-14023323a997",
      "name": "HTTP Bannerbear Poll Status1",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        7600,
        3424
      ],
      "parameters": {
        "url": "=https://api.bannerbear.com/v2/images/{{ $('HTTP Bannerbear Create Job1').first().json.uid }}",
        "options": {},
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "=Bearer {{ $env.BANNERBEAR_API_KEY }}"
            }
          ]
        }
      },
      "typeVersion": 4.1
    },
    {
      "id": "d0edef0c-82cb-4abc-97e5-399940d387b1",
      "name": "IF Image Ready1",
      "type": "n8n-nodes-base.if",
      "position": [
        7792,
        3424
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "leftValue": "",
            "caseSensitive": false,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "cond-image-ready",
              "operator": {
                "type": "string",
                "operation": "equals"
              },
              "leftValue": "={{ $json.status }}",
              "rightValue": "completed"
            }
          ]
        }
      },
      "typeVersion": 2
    },
    {
      "id": "2b1a94a8-57d9-47a9-8c30-65cc33059a66",
      "name": "HTTP Fetch Rendered Card1",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        8096,
        3424
      ],
      "parameters": {
        "url": "={{ $json.image_url }}",
        "options": {
          "response": {
            "response": {
              "responseFormat": "file",
              "outputPropertyName": "cardImage"
            }
          }
        }
      },
      "typeVersion": 4.1
    },
    {
      "id": "67a627e7-1f27-4c92-82e6-d101da62b3a6",
      "name": "Code Merge Upload and Caption1",
      "type": "n8n-nodes-base.code",
      "position": [
        8448,
        3424
      ],
      "parameters": {
        "jsCode": "//  Merge CDN URL from Upload to URL + caption from Code node \nconst uploadResult = $input.first().json;\nconst prepData     = $('Code Prepare Bannerbear Payload1').first().json;\n\n// Upload to URL returns the public URL under one of these field names\nconst publicImageUrl =\n  uploadResult.public_url   ||\n  uploadResult.url          ||\n  uploadResult.file_url     ||\n  uploadResult.cdn_url      ||\n  uploadResult.imageUrl     ||\n  '';\n\nif (!publicImageUrl) {\n  throw new Error(\n    'Upload to URL returned no public URL. Available fields: ' +\n    JSON.stringify(Object.keys(uploadResult))\n  );\n}\n\nconsole.log('[Merge] CDN URL:', publicImageUrl);\n\nreturn [{\n  json: {\n    public_image_url: publicImageUrl,\n    final_caption:    prepData.final_caption,\n    ig_user_id:       prepData.ig_user_id,\n    record_id:        prepData.record_id,\n    reviewer_name:    prepData.reviewer_name,\n    review_text:      prepData.review_text,\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "4a29c68e-c112-4e18-b6dc-ef91dd6442b8",
      "name": "IG Create Media Container1",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        8768,
        3424
      ],
      "parameters": {
        "url": "=https://graph.facebook.com/v19.0/{{ $json.ig_user_id }}/media",
        "method": "POST",
        "options": {},
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "image_url",
              "value": "={{ $json.public_image_url }}"
            },
            {
              "name": "caption",
              "value": "={{ $json.final_caption }}"
            },
            {
              "name": "access_token",
              "value": "={{ $env.IG_ACCESS_TOKEN }}"
            }
          ]
        }
      },
      "typeVersion": 4.1
    },
    {
      "id": "82c6802c-24f9-4527-b60c-c6a9527bd24b",
      "name": "Wait IG Buffer1",
      "type": "n8n-nodes-base.wait",
      "position": [
        8992,
        3424
      ],
      "parameters": {
        "unit": "seconds",
        "amount": 6
      },
      "typeVersion": 1
    },
    {
      "id": "a6ab9a7b-8e90-4ab9-be81-4d030915d784",
      "name": "IG Publish Container1",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        9200,
        3424
      ],
      "parameters": {
        "url": "=https://graph.facebook.com/v19.0/{{ $('Code Merge Upload and Caption1').first().json.ig_user_id }}/media_publish",
        "method": "POST",
        "options": {},
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "creation_id",
              "value": "={{ $('IG Create Media Container1').first().json.id }}"
            },
            {
              "name": "access_token",
              "value": "={{ $env.IG_ACCESS_TOKEN }}"
            }
          ]
        }
      },
      "typeVersion": 4.1
    },
    {
      "id": "7b4c4b8b-84af-4ddc-9631-e8ae20a401ac",
      "name": "Airtable Mark as Posted1",
      "type": "n8n-nodes-base.airtable",
      "position": [
        9424,
        3424
      ],
      "parameters": {
        "base": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $env.AIRTABLE_BASE_ID }}"
        },
        "table": {
          "__rl": true,
          "mode": "name",
          "value": "Reviews"
        },
        "columns": {
          "value": {
            "Posted": true,
            "Posted At": "={{ new Date().toISOString() }}",
            "Card Image URL": "={{ $('Code Merge Upload and Caption1').first().json.public_image_url }}",
            "Instagram Post ID": "={{ $('IG Publish Container1').first().json.id }}"
          },
          "schema": [],
          "mappingMode": "defineBelow",
          "matchingColumns": [
            "id"
          ]
        },
        "options": {},
        "operation": "update"
      },
      "typeVersion": 2
    },
    {
      "id": "8b7e37af-9d23-4128-a245-b02757edde95",
      "name": "Slack Notify Team1",
      "type": "n8n-nodes-base.slack",
      "position": [
        9648,
        3424
      ],
      "parameters": {
        "text": "=[5-star] *Social Proof Post Published!*\n\n*Reviewer:* {{ $('Code Merge Upload and Caption1').first().json.reviewer_name }}\n*Post ID:* {{ $('IG Publish Container1').first().json.id }}\n*Posted At:* {{ new Date().toLocaleString() }}\n\n*Review:* {{ $('Code Merge Upload and Caption1').first().json.review_text.substring(0, 120) }}...\n\n*Card URL:* {{ $('Code Merge Upload and Caption1').first().json.public_image_url }}",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $env.SLACK_CHANNEL_ID }}"
        },
        "otherOptions": {},
        "authentication": "oAuth2"
      },
      "typeVersion": 2.2
    }
  ],
  "connections": {
    "Upload to URL": {
      "main": [
        [
          {
            "node": "Code Merge Upload and Caption1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "IF Image Ready1": {
      "main": [
        [
          {
            "node": "HTTP Fetch Rendered Card1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Wait IG Buffer1": {
      "main": [
        [
          {
            "node": "IG Publish Container1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Schedule Trigger1": {
      "main": [
        [
          {
            "node": "Airtable Fetch Review1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Wait Before Poll1": {
      "main": [
        [
          {
            "node": "HTTP Bannerbear Poll Status1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "IF Has Valid Review1": {
      "main": [
        [
          {
            "node": "Code Prepare Bannerbear Payload1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "IG Publish Container1": {
      "main": [
        [
          {
            "node": "Airtable Mark as Posted1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Airtable Fetch Review1": {
      "main": [
        [
          {
            "node": "IF Has Valid Review1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Airtable Mark as Posted1": {
      "main": [
        [
          {
            "node": "Slack Notify Team1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "HTTP Fetch Rendered Card1": {
      "main": [
        [
          {
            "node": "Upload to URL",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "IG Create Media Container1": {
      "main": [
        [
          {
            "node": "Wait IG Buffer1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "HTTP Bannerbear Create Job1": {
      "main": [
        [
          {
            "node": "Wait Before Poll1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "HTTP Bannerbear Poll Status1": {
      "main": [
        [
          {
            "node": "IF Image Ready1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Code Merge Upload and Caption1": {
      "main": [
        [
          {
            "node": "IG Create Media Container1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Code Prepare Bannerbear Payload1": {
      "main": [
        [
          {
            "node": "HTTP Bannerbear Create Job1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}