{
  "name": "Log Twilio delivery statuses to Google Sheets and alert Slack on failures",
  "nodes": [
    {
      "id": "1dbea7da-05da-4339-8065-e9e10c501e49",
      "name": "Receive Twilio Status Callback",
      "type": "n8n-nodes-base.webhook",
      "position": [
        240,
        768
      ],
      "parameters": {
        "path": "twilio-status-callback",
        "options": {},
        "httpMethod": "POST",
        "responseMode": "responseNode"
      },
      "typeVersion": 2.1
    },
    {
      "id": "18ab6cbf-682e-4bc9-8351-932d340a2f9b",
      "name": "Load Twilio Error Code Map",
      "type": "n8n-nodes-base.set",
      "position": [
        512,
        768
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "error-map",
              "name": "error_map",
              "type": "object",
              "value": "={{ { \"30003\": \"Handset unreachable, phone is off or out of coverage\", \"30005\": \"Unknown destination handset, the number may be inactive\", \"30006\": \"Landline or a carrier that cannot receive SMS\", \"30007\": \"Carrier filtered the message as spam\", \"30008\": \"Carrier rejected it without giving a reason\", \"21610\": \"Recipient replied STOP and is unsubscribed\" } }}"
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "f0adfa85-0526-4e55-b0b7-f736629754f7",
      "name": "Decode Status And Error Reason",
      "type": "n8n-nodes-base.set",
      "position": [
        768,
        768
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "received-at",
              "name": "received_at",
              "type": "string",
              "value": "={{ $now.toISO() }}"
            },
            {
              "id": "message-sid",
              "name": "message_sid",
              "type": "string",
              "value": "={{ $(\"Receive Twilio Status Callback\").item.json.body?.MessageSid ?? $(\"Receive Twilio Status Callback\").item.json.MessageSid ?? \"\" }}"
            },
            {
              "id": "to-number",
              "name": "to_number",
              "type": "string",
              "value": "={{ $(\"Receive Twilio Status Callback\").item.json.body?.To ?? $(\"Receive Twilio Status Callback\").item.json.To ?? \"\" }}"
            },
            {
              "id": "message-status",
              "name": "message_status",
              "type": "string",
              "value": "={{ ($(\"Receive Twilio Status Callback\").item.json.body?.MessageStatus ?? $(\"Receive Twilio Status Callback\").item.json.MessageStatus ?? \"\").toString().toLowerCase() }}"
            },
            {
              "id": "error-code",
              "name": "error_code",
              "type": "string",
              "value": "={{ ($(\"Receive Twilio Status Callback\").item.json.body?.ErrorCode ?? $(\"Receive Twilio Status Callback\").item.json.ErrorCode ?? \"\").toString() }}"
            },
            {
              "id": "failure-reason",
              "name": "failure_reason",
              "type": "string",
              "value": "={{ ($(\"Receive Twilio Status Callback\").item.json.body?.ErrorCode ?? $(\"Receive Twilio Status Callback\").item.json.ErrorCode) ? ($json.error_map[($(\"Receive Twilio Status Callback\").item.json.body?.ErrorCode ?? $(\"Receive Twilio Status Callback\").item.json.ErrorCode).toString()] ?? \"Unmapped Twilio error code\") : \"\" }}"
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "7072e77e-e4a5-4c4d-ae7d-4f4742d2317b",
      "name": "Append Delivery Ledger Row",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        1120,
        768
      ],
      "parameters": {
        "columns": {
          "value": {
            "to_number": "={{ $json.to_number }}",
            "error_code": "={{ $json.error_code }}",
            "message_sid": "={{ $json.message_sid }}",
            "received_at": "={{ $json.received_at }}",
            "failure_reason": "={{ $json.failure_reason }}",
            "message_status": "={{ $json.message_status }}"
          },
          "schema": [
            {
              "id": "received_at",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "received_at",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "message_sid",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "message_sid",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "to_number",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "to_number",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "message_status",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "message_status",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "error_code",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "error_code",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "failure_reason",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "failure_reason",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            }
          ],
          "mappingMode": "defineBelow"
        },
        "options": {
          "cellFormat": "RAW"
        },
        "operation": "append",
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "REPLACE_WITH_YOUR_SHEET_TAB"
        },
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": "",
          "cachedResultName": "REPLACE_WITH_YOUR_SPREADSHEET"
        }
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.7
    },
    {
      "id": "77f63bdf-d5b3-4593-a983-75ab2c4621f7",
      "name": "Check For Failed Delivery",
      "type": "n8n-nodes-base.if",
      "position": [
        1360,
        768
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": false,
            "typeValidation": "loose"
          },
          "combinator": "or",
          "conditions": [
            {
              "id": "is-failed",
              "operator": {
                "type": "string",
                "operation": "equals"
              },
              "leftValue": "={{ $(\"Decode Status And Error Reason\").item.json.message_status }}",
              "rightValue": "failed"
            },
            {
              "id": "is-undelivered",
              "operator": {
                "type": "string",
                "operation": "equals"
              },
              "leftValue": "={{ $(\"Decode Status And Error Reason\").item.json.message_status }}",
              "rightValue": "undelivered"
            }
          ]
        },
        "looseTypeValidation": true
      },
      "typeVersion": 2.3
    },
    {
      "id": "9e1a6648-3c0b-4850-9d37-45b756acf4a3",
      "name": "Post Failure Alert To Slack",
      "type": "n8n-nodes-base.slack",
      "onError": "continueRegularOutput",
      "position": [
        1744,
        752
      ],
      "parameters": {
        "text": "=*SMS delivery failed*\nTo: {{ $(\"Decode Status And Error Reason\").item.json.to_number }}\nStatus: {{ $(\"Decode Status And Error Reason\").item.json.message_status }}\nError code: {{ $(\"Decode Status And Error Reason\").item.json.error_code || \"none reported\" }}\nReason: {{ $(\"Decode Status And Error Reason\").item.json.failure_reason || \"no reason available\" }}\nMessage SID: {{ $(\"Decode Status And Error Reason\").item.json.message_sid }}",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "name",
          "value": "REPLACE_WITH_YOUR_SLACK_CHANNEL"
        },
        "otherOptions": {
          "mrkdwn": true,
          "includeLinkToWorkflow": false
        }
      },
      "credentials": {
        "slackApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.5
    },
    {
      "id": "3e2d1b9c-2a08-49f3-96de-573953a23ffa",
      "name": "Return 200 To Twilio",
      "type": "n8n-nodes-base.respondToWebhook",
      "position": [
        2000,
        784
      ],
      "parameters": {
        "options": {
          "responseCode": 200
        },
        "respondWith": "noData"
      },
      "typeVersion": 1.5
    },
    {
      "id": "d0f713e7-73a5-4aaf-9b47-51f5117100eb",
      "name": "Overview",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -160,
        -192
      ],
      "parameters": {
        "width": 992,
        "height": 608,
        "content": "## Log Twilio delivery statuses to Google Sheets and alert Slack on failures\n\n### How it works\n\n1. Twilio POSTs a status callback to the webhook every time a message changes state.\n2. `Load Twilio Error Code Map` holds a static map of Twilio error codes to plain sentences.\n3. `Decode Status And Error Reason` pulls the SID, recipient, status and error code off the callback and resolves the code to a reason.\n4. `Append Delivery Ledger Row` writes every event to Google Sheets, successes included.\n5. `Check For Failed Delivery` passes only failed and undelivered on to Slack, and both branches answer Twilio with a 200.\n\n### Setup steps\n\n- [ ] Add a Google Sheets credential on `Append Delivery Ledger Row` and a Slack credential on `Post Failure Alert To Slack`.\n- [ ] Create a sheet with the header row received_at, message_sid, to_number, message_status, error_code, failure_reason.\n- [ ] Pick that sheet and tab in `Append Delivery Ledger Row`, and the alert channel in `Post Failure Alert To Slack`.\n- [ ] Activate, copy the production webhook URL, and set it as the StatusCallback on your Twilio messages.\n\n### Customization\n\nAdd a code to the map in `Load Twilio Error Code Map` to explain more failures, widen the IF in `Check For Failed Delivery` to catch other statuses, or swap Slack for email if that is where your team looks."
      },
      "typeVersion": 1
    },
    {
      "id": "ffefe8c6-ef63-4af7-98ae-bda3aceefbc8",
      "name": "Warning Validate Twilio Signature",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        896,
        -192
      ],
      "parameters": {
        "color": 3,
        "width": 448,
        "height": 208,
        "content": "## Validate the Twilio signature\n\nThe webhook is open by default. Twilio signs every callback with an X-Twilio-Signature header, so validate it before you trust the payload, or restrict the path to Twilio IP ranges."
      },
      "typeVersion": 1
    },
    {
      "id": "b0b5c5eb-ae72-4cd6-bcdb-c4136fff8e28",
      "name": "Section 1 Receive And Decode",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        176,
        544
      ],
      "parameters": {
        "color": 7,
        "width": 768,
        "height": 448,
        "content": "## Take in the callback and decode it\n\nTwilio POSTs a form-encoded payload on every message state change. One Set node holds the static error code map, the next one pulls the SID, recipient, status and error code off the callback, then resolves the code to a sentence a person can read."
      },
      "typeVersion": 1
    },
    {
      "id": "21685de6-36f3-4cc6-98f8-b02daef10e65",
      "name": "Section 2 Log And Route",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1024,
        544
      ],
      "parameters": {
        "color": 7,
        "width": 576,
        "height": 448,
        "content": "## Log every event, then route\n\nGoogle Sheets appends one row per callback, successes included, so the ledger is complete. Only after the write does the IF check the status, and only failed and undelivered continue."
      },
      "typeVersion": 1
    },
    {
      "id": "7504e6fa-e00a-4f91-a164-a8db63c677a0",
      "name": "Section 3 Alert And Respond",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1664,
        544
      ],
      "parameters": {
        "color": 7,
        "width": 544,
        "height": 448,
        "content": "## Alert Slack and answer Twilio\n\nSlack gets the recipient, the status, the raw code and the decoded reason. Both branches end at the same response node so Twilio always sees a 200 and stops retrying."
      },
      "typeVersion": 1
    }
  ],
  "settings": {
    "executionOrder": "v1"
  },
  "connections": {
    "Check For Failed Delivery": {
      "main": [
        [
          {
            "node": "Post Failure Alert To Slack",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Return 200 To Twilio",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Append Delivery Ledger Row": {
      "main": [
        [
          {
            "node": "Check For Failed Delivery",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Load Twilio Error Code Map": {
      "main": [
        [
          {
            "node": "Decode Status And Error Reason",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Post Failure Alert To Slack": {
      "main": [
        [
          {
            "node": "Return 200 To Twilio",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Decode Status And Error Reason": {
      "main": [
        [
          {
            "node": "Append Delivery Ledger Row",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Receive Twilio Status Callback": {
      "main": [
        [
          {
            "node": "Load Twilio Error Code Map",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}