{
  "nodes": [
    {
      "id": "1cf40651-56a2-4a69-b4bf-9594aa74ad7a",
      "name": "\ud83d\udccb Template Overview",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -256,
        -704
      ],
      "parameters": {
        "width": 776,
        "height": 800,
        "content": "## \u2b50 Customer Review \u2192 Social Proof Instagram Post\n\n**What this workflow does:**\nOn a daily schedule, fetches the latest unposted 5-star review from an Airtable reviews base (populated via Typeform, Google Reviews sync, or Trustpilot webhook). Filters for quality, generates a branded quote card image via the Bannerbear API, uploads it via **Upload to URL** to get a public CDN link, then publishes to Instagram with a formatted caption. Marks the review as posted in Airtable and notifies the team on Slack.\n\n**Node Summary:**\n1. \u23f0 Schedule Trigger \u2014 runs daily at 10 AM\n2. \ud83d\udccb Airtable \u2014 fetch latest unposted 5-star review\n3. \ud83d\udd00 IF \u2014 filter: skip if no review found or rating < 5\n4. \u270d\ufe0f Code \u2014 build Bannerbear template data + caption\n5. \ud83c\udfa8 HTTP \u2014 Bannerbear: create image generation job\n6. \ud83d\udd01 HTTP \u2014 Bannerbear: poll until image is ready\n7. \ud83d\udd00 IF \u2014 check image status = completed\n8. \u2601\ufe0f Upload to URL \u2014 upload rendered card, get public CDN URL\n9. \ud83d\udcf8 HTTP \u2014 IG: create media container\n10. \u23f3 Wait \u2014 6s IG processing buffer\n11. \u2705 HTTP \u2014 IG: publish container\n12. \ud83d\udccb Airtable \u2014 mark review as posted\n13. \ud83d\udcac Slack \u2014 notify team with post details\n\n**Prerequisites:**\n- Airtable base with a Reviews table (fields: Reviewer Name, Review Text, Rating, Star Label, Posted)\n- Bannerbear API key + Template ID with quote card design\n- Instagram Graph API token + Business Account ID\n- Upload to URL node credentials\n- Slack API token (optional)"
      },
      "typeVersion": 1
    },
    {
      "id": "cb1b0811-6d88-4ac1-a0dc-f1ec303055d5",
      "name": "Sticky \u2014 Schedule + Fetch + Filter",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        576,
        112
      ],
      "parameters": {
        "color": 7,
        "width": 676,
        "height": 694,
        "content": "### \u23f0\ud83d\udccb Nodes 1\u20133 \u2014 Schedule, Fetch Review & Quality Filter\n\nSchedule Trigger: Automatically fires daily at 10:00 AM; cadence can be adjusted via cron expression.\n\nAirtable \u2014 Fetch Review: Retrieves the oldest 5-star, unposted record using a specific filter formula to prevent duplicates.\n\nIF \u2014 Has Valid Review?: Validates the data; the workflow exits gracefully if no new reviews are found and only proceeds when a 5-star review is ready."
      },
      "typeVersion": 1
    },
    {
      "id": "f426e713-91cd-4100-883e-47b6099292c7",
      "name": "Sticky \u2014 Build Data + Generate Image",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1296,
        128
      ],
      "parameters": {
        "color": 7,
        "width": 558,
        "height": 488,
        "content": "### \u270d\ufe0f\ud83c\udfa8 Nodes 4\u20135 \u2014 Build Template Data + Generate Image\nCode \u2014 Prepare Payload: Formats review data into a JSON body, mapping fields like name and truncated text to Bannerbear layers while generating the final Instagram caption.\n\nHTTP \u2014 Create Image Job: Submits the request to the Bannerbear API and retrieves a unique job uid for asynchronous processing."
      },
      "typeVersion": 1
    },
    {
      "id": "62af6596-2b92-44f2-b6ad-6e22272fd486",
      "name": "Sticky \u2014 Poll + Status Check",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1888,
        128
      ],
      "parameters": {
        "color": 7,
        "width": 484,
        "height": 668,
        "content": "### \ud83d\udd01\ud83d\udd00 Nodes 6\u20137 \u2014 Poll Image Status + Branch on Completion\n\nHTTP \u2014 Poll Status: Regularly checks the job status via the Bannerbear API to see if the rendering is finished.\n\nIF \u2014 Image Ready?: Confirms completion; if still processing, it triggers a \"Wait 3s + re-poll\" loop for up to 5 retries before passing the image_url forward."
      },
      "typeVersion": 1
    },
    {
      "id": "1699d27b-6d9e-4133-916e-a21c66af0873",
      "name": "Sticky \u2014 Upload to URL + IG Container",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2432,
        144
      ],
      "parameters": {
        "color": 7,
        "width": 878,
        "height": 640,
        "content": "### \u2601\ufe0f\ud83d\udcf8 Nodes 8\u20139 \u2014 Upload to URL + Create IG Container\n\n**Upload to URL** is the mandatory CDN bridge. It fetches the Bannerbear-rendered image binary and uploads it to the configured hosting endpoint, returning a stable **public URL**. This step is essential \u2014 Instagram's Graph API rejects base64 or binary payloads and requires a direct publicly accessible image URL.\n\nFilename is auto-set to `review_{reviewer_name}_{timestamp}.jpg` for clean asset tracking.\n\n**IG \u2014 Create Media Container** is Step 1 of the Instagram 2-step publish flow. POSTs to `/v19.0/{ig_account_id}/media` with the CDN URL and assembled caption. Returns a `container_id` which acts as a staging slot \u2014 Instagram validates and pre-processes the image before it goes live."
      },
      "typeVersion": 1
    },
    {
      "id": "a97fb561-2eb5-4651-87c6-6b5da1f51a41",
      "name": "Sticky \u2014 Publish + Log + Notify",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        3344,
        80
      ],
      "parameters": {
        "color": 7,
        "width": 878,
        "height": 748,
        "content": "### \u23f3\u2705\ud83d\udccb\ud83d\udcac Nodes 10\u201313 \u2014 Publish, Log & Notify\n\n**Wait \u2014 6s** gives Instagram time to finalise the media container before the publish call. Slightly longer than typical to account for image card complexity.\n\n**IG \u2014 Publish Container** calls `/media_publish` with the `container_id`. Returns the live Instagram Post ID confirming the post is on the feed.\n\n**Airtable \u2014 Mark as Posted** updates the original review record: sets `Posted = true` and writes back the Instagram Post ID and publish timestamp. This prevents the same review from being picked up on the next scheduled run.\n\n**Slack \u2014 Notify Team** sends a block message with reviewer name, a snippet of the review, the post ID, and the CDN card image URL so the team can preview exactly what went live."
      },
      "typeVersion": 1
    },
    {
      "id": "c9d84e35-431b-45ae-9946-3250a87b4492",
      "name": "Schedule \u2014 Daily 10AM",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        624,
        400
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "triggerAtHour": 10
            }
          ]
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "dcea69ad-78eb-4085-8fca-644cf949a09f",
      "name": "Airtable \u2014 Fetch 5-Star Review",
      "type": "n8n-nodes-base.airtable",
      "position": [
        816,
        400
      ],
      "parameters": {
        "base": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $env.AIRTABLE_BASE_ID }}"
        },
        "sort": {
          "property": [
            {
              "field": "Submitted At"
            }
          ]
        },
        "table": {
          "__rl": true,
          "mode": "name",
          "value": "Reviews"
        },
        "options": {},
        "operation": "search",
        "filterByFormula": "AND({Rating}=5, {Posted}=FALSE())"
      },
      "typeVersion": 2.1
    },
    {
      "id": "8566775a-0cd9-4296-8778-33ce8218e49a",
      "name": "IF \u2014 Has Valid Review?",
      "type": "n8n-nodes-base.if",
      "position": [
        1008,
        400
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "has-review",
              "operator": {
                "type": "string",
                "operation": "notEmpty"
              },
              "leftValue": "={{ $json.id }}",
              "rightValue": ""
            }
          ]
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "f2bfd247-a306-46f9-b897-054a8ad1d187",
      "name": "Code \u2014 Prepare Bannerbear Payload",
      "type": "n8n-nodes-base.code",
      "position": [
        1328,
        384
      ],
      "parameters": {
        "jsCode": "const review = $input.item.json;\nconst fields = review.fields || review;\n\nconst reviewerName = fields['Reviewer Name'] || fields.reviewer_name || 'A Happy Customer';\nconst reviewText = fields['Review Text'] || fields.review_text || '';\nconst rating = fields['Rating'] || 5;\nconst recordId = review.id || review.record_id;\n\n// Truncate for card display (Bannerbear layer max)\nconst cardText = reviewText.length > 180\n  ? reviewText.substring(0, 177) + '...'\n  : reviewText;\n\n// Build star string\nconst stars = '\u2b50'.repeat(Math.min(rating, 5));\n\n// Bannerbear modifications array\nconst modifications = [\n  { name: 'reviewer_name', text: `\u2014 ${reviewerName}` },\n  { name: 'review_text', text: `\"${cardText}\"` },\n  { name: 'star_label', text: stars }\n];\n\n// Build IG caption\nconst hashtagBlock = '#customerreview #5stars #testimonial #socialprrof #happycustomer #review';\nconst caption = [\n  `${stars} Real words from a real customer.`,\n  '',\n  `\"${reviewText.substring(0, 300)}\"`,\n  `\u2014 ${reviewerName}`,\n  '',\n  '\ud83d\udcac See why hundreds of customers trust us.',\n  '\ud83d\udc49 Link in bio.',\n  '.',\n  '.',\n  '.',\n  hashtagBlock\n].join('\\n');\n\nconst finalCaption = caption.length > 2200 ? caption.substring(0, 2196) + '...' : caption;\n\nreturn {\n  record_id: recordId,\n  reviewer_name: reviewerName,\n  review_text: reviewText,\n  card_text: cardText,\n  stars,\n  modifications,\n  final_caption: finalCaption,\n  ig_account_id: $env.IG_ACCOUNT_ID\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "a7d50d24-354b-42f8-aaa9-83f532cbb501",
      "name": "HTTP \u2014 Bannerbear: Create Image Job",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        1520,
        400
      ],
      "parameters": {
        "url": "https://api.bannerbear.com/v2/images",
        "method": "POST",
        "options": {},
        "jsonBody": "={\n  \"template\": \"{{ $env.BANNERBEAR_TEMPLATE_ID }}\",\n  \"modifications\": {{ JSON.stringify($json.modifications) }}\n}",
        "sendBody": true,
        "sendHeaders": true,
        "specifyBody": "json",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        }
      },
      "credentials": {
        "httpHeaderAuth": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "121fb882-159d-4f2a-8736-f67b87b8b9c1",
      "name": "Wait \u2014 5s Before Poll",
      "type": "n8n-nodes-base.wait",
      "position": [
        1696,
        400
      ],
      "parameters": {
        "unit": "seconds",
        "amount": 5
      },
      "typeVersion": 1
    },
    {
      "id": "4fcd889d-6a09-4186-871f-11d42d442c15",
      "name": "HTTP \u2014 Bannerbear: Poll Status",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        1936,
        384
      ],
      "parameters": {
        "url": "=https://api.bannerbear.com/v2/images/{{ $('HTTP \u2014 Bannerbear: Create Image Job').item.json.uid }}",
        "options": {},
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth"
      },
      "typeVersion": 4.2
    },
    {
      "id": "f44cedfc-a77c-4c57-b294-1fb1f1641cd8",
      "name": "IF \u2014 Image Ready?",
      "type": "n8n-nodes-base.if",
      "position": [
        2112,
        384
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "leftValue": "",
            "caseSensitive": false,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "image-ready",
              "operator": {
                "type": "string",
                "operation": "equals"
              },
              "leftValue": "={{ $json.status }}",
              "rightValue": "completed"
            }
          ]
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "8a20cba3-0d4a-4006-bfd1-217dc454012c",
      "name": "HTTP \u2014 Fetch Rendered Card",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        2496,
        368
      ],
      "parameters": {
        "url": "={{ $json.image_url }}",
        "options": {
          "response": {
            "response": {
              "responseFormat": "file",
              "outputPropertyName": "cardImage"
            }
          }
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "89e8581c-5b83-48ce-af36-962d8d81c891",
      "name": "Code \u2014 Merge Upload + Caption Data",
      "type": "n8n-nodes-base.code",
      "position": [
        2928,
        368
      ],
      "parameters": {
        "jsCode": "const uploadResult = $input.item.json;\nconst prepData = $('Code \u2014 Prepare Bannerbear Payload').item.json;\n\nreturn {\n  ...uploadResult,\n  public_image_url: uploadResult.public_url || uploadResult.url || uploadResult.file_url || uploadResult.cdn_url,\n  final_caption: prepData.final_caption,\n  ig_account_id: prepData.ig_account_id,\n  record_id: prepData.record_id,\n  reviewer_name: prepData.reviewer_name\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "e156d1ec-3dad-4ea3-ad4d-2484677c33f7",
      "name": "IG \u2014 Create Media Container",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        3136,
        368
      ],
      "parameters": {
        "url": "=https://graph.facebook.com/v19.0/{{ $json.ig_account_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": "={{ $credentials.instagramGraphApi.accessToken }}"
            }
          ]
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "c7383c43-9836-4ca9-ae28-c661c92cc02c",
      "name": "Wait \u2014 6s IG Buffer",
      "type": "n8n-nodes-base.wait",
      "position": [
        3424,
        320
      ],
      "parameters": {
        "unit": "seconds",
        "amount": 6
      },
      "typeVersion": 1
    },
    {
      "id": "c86ef75b-85eb-4e13-9403-ba0099c2f4de",
      "name": "IG \u2014 Publish Container",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        3632,
        320
      ],
      "parameters": {
        "url": "=https://graph.facebook.com/v19.0/{{ $('Code \u2014 Merge Upload + Caption Data').item.json.ig_account_id }}/media_publish",
        "method": "POST",
        "options": {},
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "creation_id",
              "value": "={{ $('IG \u2014 Create Media Container').item.json.id }}"
            },
            {
              "name": "access_token",
              "value": "={{ $credentials.instagramGraphApi.accessToken }}"
            }
          ]
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "523aa530-d3cf-494c-971b-79497871ef72",
      "name": "Airtable \u2014 Mark as Posted",
      "type": "n8n-nodes-base.airtable",
      "position": [
        3824,
        320
      ],
      "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 \u2014 Merge Upload + Caption Data').item.json.public_image_url }}",
            "Instagram Post ID": "={{ $('IG \u2014 Publish Container').item.json.id }}"
          },
          "schema": [],
          "mappingMode": "defineBelow",
          "matchingColumns": []
        },
        "options": {},
        "operation": "update"
      },
      "typeVersion": 2.1
    },
    {
      "id": "e1ef5605-5dda-4493-9c43-fd6c8607e765",
      "name": "Slack \u2014 Notify Team",
      "type": "n8n-nodes-base.slack",
      "position": [
        4032,
        320
      ],
      "parameters": {
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $env.SLACK_CHANNEL_ID }}"
        },
        "otherOptions": {}
      },
      "typeVersion": 2.3
    },
    {
      "id": "81cb8302-a0ad-4482-8bc0-efdebba00c74",
      "name": "No Review \u2014 Exit Gracefully",
      "type": "n8n-nodes-base.noOp",
      "position": [
        1072,
        624
      ],
      "parameters": {},
      "typeVersion": 1
    },
    {
      "id": "6fefaa4f-86fb-45be-b7da-81639b4cba2d",
      "name": "Image Not Ready \u2014 Exit with Alert",
      "type": "n8n-nodes-base.noOp",
      "position": [
        2192,
        640
      ],
      "parameters": {},
      "typeVersion": 1
    },
    {
      "id": "e97e43d4-57f8-4dc4-a9f2-c8663972300b",
      "name": "Upload a File",
      "type": "n8n-nodes-uploadtourl.uploadToUrl",
      "position": [
        2688,
        368
      ],
      "parameters": {},
      "credentials": {
        "uploadToUrlApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    }
  ],
  "connections": {
    "Upload a File": {
      "main": [
        [
          {
            "node": "Code \u2014 Merge Upload + Caption Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "IF \u2014 Image Ready?": {
      "main": [
        [
          {
            "node": "HTTP \u2014 Fetch Rendered Card",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Image Not Ready \u2014 Exit with Alert",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Wait \u2014 6s IG Buffer": {
      "main": [
        [
          {
            "node": "IG \u2014 Publish Container",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Schedule \u2014 Daily 10AM": {
      "main": [
        [
          {
            "node": "Airtable \u2014 Fetch 5-Star Review",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Wait \u2014 5s Before Poll": {
      "main": [
        [
          {
            "node": "HTTP \u2014 Bannerbear: Poll Status",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "IF \u2014 Has Valid Review?": {
      "main": [
        [
          {
            "node": "Code \u2014 Prepare Bannerbear Payload",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "No Review \u2014 Exit Gracefully",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "IG \u2014 Publish Container": {
      "main": [
        [
          {
            "node": "Airtable \u2014 Mark as Posted",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Airtable \u2014 Mark as Posted": {
      "main": [
        [
          {
            "node": "Slack \u2014 Notify Team",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "HTTP \u2014 Fetch Rendered Card": {
      "main": [
        [
          {
            "node": "Upload a File",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "IG \u2014 Create Media Container": {
      "main": [
        [
          {
            "node": "Wait \u2014 6s IG Buffer",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Airtable \u2014 Fetch 5-Star Review": {
      "main": [
        [
          {
            "node": "IF \u2014 Has Valid Review?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "HTTP \u2014 Bannerbear: Poll Status": {
      "main": [
        [
          {
            "node": "IF \u2014 Image Ready?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Code \u2014 Prepare Bannerbear Payload": {
      "main": [
        [
          {
            "node": "HTTP \u2014 Bannerbear: Create Image Job",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Code \u2014 Merge Upload + Caption Data": {
      "main": [
        [
          {
            "node": "IG \u2014 Create Media Container",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "HTTP \u2014 Bannerbear: Create Image Job": {
      "main": [
        [
          {
            "node": "Wait \u2014 5s Before Poll",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}