{
  "id": "I63nrAoNWJcm3T72",
  "meta": {
    "templateCredsSetupCompleted": true
  },
  "name": "Twilio Voice Donation Extraction",
  "tags": [
    {
      "id": "oufe6ROEtWIl02g4",
      "name": "open-source",
      "createdAt": "2026-06-25T18:55:08.441Z",
      "updatedAt": "2026-07-13T19:25:56.333Z"
    }
  ],
  "nodes": [
    {
      "id": "abde3623-b8fa-4c5f-9a88-39c74ad5ae0e",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2960,
        1152
      ],
      "parameters": {
        "color": 4,
        "width": 1104,
        "height": 832,
        "content": "## See it in action\n\n@[youtube](LK5PCBBWyDM)\n\nNote: The video shows the workflow running end to end on a real Twilio recording callback."
      },
      "typeVersion": 1
    },
    {
      "id": "495a3910-f37b-4141-af62-4e42980cc04d",
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1488,
        1152
      ],
      "parameters": {
        "color": 7,
        "width": 640,
        "height": 320,
        "content": "## Receive call metadata\n\nHandles the incoming Twilio webhook, extracts the relevant call and recording identifiers, and checks that the recording duration is long enough to process."
      },
      "typeVersion": 1
    },
    {
      "id": "24c83d26-3b81-4cdb-a3b0-522d6b5c6fa5",
      "name": "Sticky Note3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1520,
        1504
      ],
      "parameters": {
        "color": 7,
        "width": 544,
        "height": 448,
        "content": "## Extract structured donation\n\nUses the nearby LLM chain and attached chat model nodes to interpret the transcription, then parses the model output into structured JSON fields."
      },
      "typeVersion": 1
    },
    {
      "id": "2c8343e0-cab5-402f-a8dc-6c73dd3facc0",
      "name": "Sticky Note4",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2144,
        1536
      ],
      "parameters": {
        "color": 7,
        "width": 416,
        "height": 400,
        "content": "## Check sheet duplicates\n\nLooks up the parsed recording in Google Sheets and branches based on whether the donation data is already present."
      },
      "typeVersion": 1
    },
    {
      "id": "979d4d1b-46b0-41de-b4e4-a4a9102b3875",
      "name": "Sticky Note5",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2592,
        1536
      ],
      "parameters": {
        "color": 7,
        "width": 256,
        "height": 432,
        "content": "## Save donation record\n\nAppends the newly extracted, non-duplicate donation details to Google Sheets."
      },
      "typeVersion": 1
    },
    {
      "id": "75446555-c353-427c-b36a-f32f5f7b804a",
      "name": "Chain LLM Process",
      "type": "@n8n/n8n-nodes-langchain.chainLlm",
      "position": [
        1536,
        1760
      ],
      "parameters": {
        "text": "=You are the extraction engine for a Twilio voice-donation workflow.\n...\n\nCALL TRANSCRIPT TO PROCESS:\n{{ $json.text }}\n\nYour job is to extract only the donation details...\n\nMUST RETURN:\n- donation_amount: string, digits only if possible, or \"\" if unknown\n- fee_amount: string, digits only if mentioned, or \"\" if unknown\n- beneficiary_name: string, or \"\" if unknown\n- currency: string, or \"\" if unknown\n- needs_review: string, use \"yes\" or \"no\"\n- summary: string, short plain-English summary of what the caller said\n\n- Normalize currency to standard codes when clear:\n  - INR\n  - USD\n  - GBP\n  - EUR\n- Otherwise use \"\".\n\nRULES:\n- Return ONLY valid JSON.\n- Do not include markdown, code fences, comments, or explanations.\n- Every key must ALWAYS be present.\n- Never omit a key.\n- Use empty string \"\" for unknown values.\n- Do not guess.\n- Do not invent amounts, names, or currency.\n- If the caller says the amount in words, convert it to a numeric string.\n- If fee is not mentioned, keep fee_amount as \"\".\nIf donation_amount is present but beneficiary_name is empty, set needs_review to \"yes\".\n- If the transcript is noisy, incomplete, or ambiguous, set needs_review to \"yes\".\n- Otherwise set needs_review to \"no\".\n- Keep summary short and factual.\n- Extract only what is explicitly stated or clearly implied by the transcript.\n- If an amount is approximate (e.g. \"around\", \"about\", \"roughly\", \"I think\"), extract the best numeric value and set needs_review to \"yes\".\n- Only extract beneficiary_name if the organization or beneficiary is clearly identifiable.\n- If uncertain, use \"\" and set needs_review to \"yes\".\n- Keep summary under 120 characters, one sentence only.\n- If currency is clearly stated but not in the list above, preserve the caller's exact term. Attempt ISO 4217 format if recognizable.\n- If multiple distinct amounts are mentioned and it is not clear which is final, use the last stated amount and set needs_review to yes.\n- If the transcript clearly does not contain a donation intent, set all extraction fields to empty string and summary to 'No donation intent detected'. Set needs_review to yes.\n- If the transcript is empty, return all fields as empty string and needs_review as yes.\n- If multiple beneficiaries are mentioned, record the first clearly stated one and set needs_review to yes.\n\nReturn JSON only in this format:\n{\n  \"donation_amount\": \"\",\n  \"fee_amount\": \"\",\n  \"beneficiary_name\": \"\",\n  \"currency\": \"\",\n  \"needs_review\": \"\",\n  \"summary\": \"\"\n}\n\nEvery key must always be present. Use empty string for unknown values. Never omit a key.\n\nReturn ONLY valid JSON.\n\nDo not wrap the JSON in markdown.\nDo not use ```json.\nDo not add explanations before or after the JSON.\n\nIf a value is unknown, return an empty string.\n\nYour response must be parseable by JSON.parse().",
        "batching": {},
        "promptType": "define",
        "needsFallback": true
      },
      "typeVersion": 1.9
    },
    {
      "id": "fdb4f5b5-e4a2-4284-bb4e-44c05b6b8bee",
      "name": "Google Gemini Chat",
      "type": "@n8n/n8n-nodes-langchain.lmChatGoogleGemini",
      "position": [
        1456,
        1984
      ],
      "parameters": {
        "options": {
          "temperature": 0,
          "maxOutputTokens": 1500
        }
      },
      "credentials": {
        "googlePalmApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1.1
    },
    {
      "id": "0ca9a51f-1974-44b0-9b76-24ee70be0e82",
      "name": "Parse JSON Text",
      "type": "n8n-nodes-base.code",
      "position": [
        1872,
        1760
      ],
      "parameters": {
        "jsCode": "const raw = ($json.text || '')\n  .replace(/```json\\s*/gi, '')\n  .replace(/```\\s*/gi, '')\n  .trim();\n\nlet parsed;\ntry {\n  parsed = JSON.parse(raw);\n} catch (e) {\n  return [{\n    json: {\n      reply: \"\",\n  donation_amount: '',\n  fee_amount: '',\n  beneficiary_name: '',\n  currency: '',\n  needs_review: 'yes',\n  summary: `EXTRACTION FAILED -  ${e.message}`\n    }\n  }];\n}\n\nreturn [{\n  json: {\n    ...parsed\n  }\n}];\n      "
      },
      "typeVersion": 2
    },
    {
      "id": "34211674-9f11-4c9d-a2fa-998caa58ea85",
      "name": "Set Session Context",
      "type": "n8n-nodes-base.set",
      "position": [
        1744,
        1328
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "9c084c02-d8ae-4252-b958-c2fe5c2ccc8a",
              "name": "CallSid",
              "type": "string",
              "value": "={{ $json.body.CallSid }}"
            },
            {
              "id": "f2f1aa80-1fe0-496b-8896-b869d93a6d16",
              "name": "CallerPhone",
              "type": "string",
              "value": "={{ $json.body.From }}"
            },
            {
              "id": "67be67c8-58a1-4ac5-b52c-88f09e176075",
              "name": "RecordingUrl",
              "type": "string",
              "value": "={{ $json.body.RecordingUrl }}"
            },
            {
              "id": "8ba190d6-9691-4676-8e18-b17dbe448848",
              "name": "RecordingSid",
              "type": "string",
              "value": "={{ $json.body.RecordingSid }}"
            },
            {
              "id": "1f71c1aa-8111-40f4-b325-b2b45c8847e8",
              "name": "RecordingDuration",
              "type": "string",
              "value": "={{ $json.body.RecordingDuration }}"
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "a679326f-fbae-4d5c-a7b7-33b5ab314ae8",
      "name": "Wait 4 Seconds",
      "type": "n8n-nodes-base.wait",
      "position": [
        2192,
        1328
      ],
      "parameters": {
        "amount": 4
      },
      "typeVersion": 1.1
    },
    {
      "id": "6966f674-4cf5-4dfd-9620-11fccff401a7",
      "name": "Groq Chat Integration",
      "type": "@n8n/n8n-nodes-langchain.lmChatGroq",
      "position": [
        1728,
        1984
      ],
      "parameters": {
        "model": "llama-3.3-70b-versatile",
        "options": {
          "temperature": 0,
          "maxTokensToSample": 512
        }
      },
      "credentials": {
        "groqApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "1fb2b6cf-97d5-48d8-834d-be973e69d8ec",
      "name": "Check Data in Sheets",
      "type": "n8n-nodes-base.if",
      "position": [
        2416,
        1760
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 3,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "24d813be-804f-4065-97f8-148f36d81f3c",
              "operator": {
                "type": "string",
                "operation": "exists",
                "singleValue": true
              },
              "leftValue": "={{ $json.CallSid }}",
              "rightValue": ""
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "385e06e9-e093-4e36-bdc5-8e8c78d98131",
      "name": "Post Groq Transcription Request",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        2640,
        1328
      ],
      "parameters": {
        "url": "https://api.groq.com/openai/v1/audio/transcriptions",
        "method": "POST",
        "options": {},
        "sendBody": true,
        "contentType": "multipart-form-data",
        "sendHeaders": true,
        "authentication": "predefinedCredentialType",
        "bodyParameters": {
          "parameters": [
            {
              "name": "model",
              "value": "whisper-large-v3"
            },
            {
              "name": "file",
              "parameterType": "formBinaryData",
              "inputDataFieldName": "data"
            }
          ]
        },
        "headerParameters": {
          "parameters": []
        },
        "nodeCredentialType": "openAiApi"
      },
      "credentials": {
        "openAiApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.4
    },
    {
      "id": "1d2b81ea-2a46-44d4-8a2a-bbdae5ae722a",
      "name": "If Recording Over 2 Seconds",
      "type": "n8n-nodes-base.if",
      "position": [
        1968,
        1328
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 3,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "loose"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "c96c2f87-114a-413b-a72c-eb5311def835",
              "operator": {
                "type": "number",
                "operation": "gte"
              },
              "leftValue": "={{ $('Set Session Context').item.json.RecordingDuration }}",
              "rightValue": 2
            },
            {
              "id": "5faaeeb6-72cf-4d1f-9bb4-fff9cef9f82d",
              "operator": {
                "type": "string",
                "operation": "exists",
                "singleValue": true
              },
              "leftValue": "={{ $json.RecordingUrl }}",
              "rightValue": ""
            }
          ]
        },
        "looseTypeValidation": true
      },
      "typeVersion": 2.3
    },
    {
      "id": "2fa809cb-54e9-416f-9702-2045f4cbdb8a",
      "name": "Incoming Webhook Trigger",
      "type": "n8n-nodes-base.webhook",
      "position": [
        1520,
        1328
      ],
      "parameters": {
        "path": "528ba475-fd49-4a5e-8c5e-f7ce2abe0888",
        "options": {
          "responseCode": {
            "values": {}
          },
          "responseData": "<Response/>",
          "responseHeaders": {
            "entries": [
              {
                "name": "Content-Type",
                "value": "text/xml"
              }
            ]
          }
        },
        "httpMethod": "POST"
      },
      "typeVersion": 2.1
    },
    {
      "id": "0b8f8ce7-8c81-43db-9eb1-2ef1742d7bd6",
      "name": "Fetch Recording File",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        2416,
        1328
      ],
      "parameters": {
        "url": "={{$json.RecordingUrl}}.mp3",
        "options": {
          "response": {
            "response": {
              "responseFormat": "file"
            }
          }
        },
        "authentication": "genericCredentialType",
        "genericAuthType": "httpBasicAuth"
      },
      "credentials": {
        "httpBasicAuth": {
          "name": "<your credential>"
        }
      },
      "retryOnFail": true,
      "typeVersion": 4.4,
      "waitBetweenTries": 2000
    },
    {
      "id": "e2c9fdc0-3671-46fa-9483-96aba30d4037",
      "name": "Read Google Sheet",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        2192,
        1760
      ],
      "parameters": {
        "options": {},
        "filtersUI": {
          "values": [
            {
              "lookupValue": "={{ $('Set Session Context').item.json.CallSid }}",
              "lookupColumn": "CallSid"
            },
            {
              "lookupValue": "={{ $('Set Session Context').item.json.RecordingUrl }}",
              "lookupColumn": "RecordingUrl"
            }
          ]
        },
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": "gid=0",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/spreadsheet_id/edit#gid=0",
          "cachedResultName": "Sheet1"
        },
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": "spreadsheet_id",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/spreadsheet_id/edit?usp=drivesdk",
          "cachedResultName": " Twilio Voice Donation Project"
        }
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "retryOnFail": true,
      "typeVersion": 4.7,
      "alwaysOutputData": true
    },
    {
      "id": "f829fefe-2d5c-4225-a83c-ce2a34b23016",
      "name": "Sticky Note6",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        752,
        1168
      ],
      "parameters": {
        "width": 640,
        "height": 816,
        "content": "## Twilio Voice Donation Extraction\n\n### How it works\n\nThis workflow receives Twilio voice recording callbacks, prepares call metadata, and ignores recordings that are too short. It waits for the recording to become available, downloads and transcribes the audio, then uses an LLM to extract structured donation information. The parsed result is checked against Google Sheets to prevent duplicate recording entries before saving new donation data.\n\n### Setup steps\n\n- Configure the Webhook URL in Twilio so recording status callbacks send CallSid, caller phone, RecordingUrl, RecordingSid, and RecordingDuration fields to this workflow.\n- Add credentials for Twilio or ensure the recording download HTTP request can access Twilio recording URLs as configured in the workflow.\n- Configure the Groq API key for audio transcription and the Groq and/or Google Gemini chat model credentials used by the LLM chain.\n- Connect Google Sheets credentials and set the spreadsheet, sheet, lookup columns, and append mapping for duplicate checks and saving donation records.\n\n### Customization\n\nAdjust the minimum recording duration threshold, wait time, LLM extraction prompt, target transcription model, and Google Sheets columns to match the donation fields you want to capture."
      },
      "typeVersion": 1
    },
    {
      "id": "a53033fd-f9ff-4a54-9bbe-364fdbfef6fb",
      "name": "Sticky Note7",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2144,
        1152
      ],
      "parameters": {
        "color": 7,
        "width": 688,
        "height": 352,
        "content": "## Fetch and transcribe audio\n\nWaits for the Twilio recording to become available, downloads the MP3 file, and sends it to Groq for speech-to-text transcription."
      },
      "typeVersion": 1
    },
    {
      "id": "dc567007-4f15-4dbe-b318-29ee32f34150",
      "name": "Append Data to Sheet",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        2640,
        1760
      ],
      "parameters": {
        "columns": {
          "value": {
            "CallSid": "={{ $('Set Session Context').item.json.CallSid }}",
            "Summary": "={{ $('Parse JSON Text').item.json.summary }}",
            "Currency": "={{ $('Parse JSON Text').item.json.currency }}",
            "Timestamp": "={{ DateTime.now().setZone('Asia/Kolkata').toFormat('dd-MM-yyyy hh:mm a') }}",
            "CallerPhone": "={{ $('Set Session Context').item.json.CallerPhone }}",
            "Needs Review": "={{ $('Parse JSON Text').item.json.needs_review }}",
            "RecordingUrl": "={{ $('Set Session Context').item.json.RecordingUrl }}",
            "Donation amount": "={{ $('Parse JSON Text').item.json.donation_amount }}",
            "Full transcript": "={{ $('Post Groq Transcription Request').item.json.text }}",
            "RecordingDuration": "={{ $('Set Session Context').item.json.RecordingDuration }}",
            "Fee amount (if any)": "={{ $('Parse JSON Text').item.json.fee_amount }}",
            "Beneficiary/organization name": "={{ $('Parse JSON Text').item.json.beneficiary_name }}"
          },
          "schema": [
            {
              "id": "Donation amount",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Donation amount",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Fee amount (if any)",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Fee amount (if any)",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Beneficiary/organization name",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Beneficiary/organization name",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Currency",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Currency",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Needs Review",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Needs Review",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Summary",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Summary",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Timestamp",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Timestamp",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "CallerPhone",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "CallerPhone",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "CallSid",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "CallSid",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "RecordingUrl",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "RecordingUrl",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "RecordingDuration",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "RecordingDuration",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Full transcript",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Full transcript",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            }
          ],
          "mappingMode": "defineBelow",
          "matchingColumns": [],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {},
        "operation": "append",
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": "gid=0",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/spreadsheet_id/edit#gid=0",
          "cachedResultName": "Sheet1"
        },
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": "spreadsheet_id",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/spreadsheet_id/edit?usp=drivesdk",
          "cachedResultName": " Twilio Voice Donation Project"
        }
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "retryOnFail": true,
      "typeVersion": 4.7
    }
  ],
  "active": false,
  "settings": {
    "binaryMode": "separate",
    "availableInMCP": false,
    "executionOrder": "v1"
  },
  "versionId": "dd6e1cd3-d9bc-45d1-8520-5f0985b968bc",
  "nodeGroups": [],
  "connections": {
    "Wait 4 Seconds": {
      "main": [
        [
          {
            "node": "Fetch Recording File",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse JSON Text": {
      "main": [
        [
          {
            "node": "Read Google Sheet",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Chain LLM Process": {
      "main": [
        [
          {
            "node": "Parse JSON Text",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read Google Sheet": {
      "main": [
        [
          {
            "node": "Check Data in Sheets",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Google Gemini Chat": {
      "ai_languageModel": [
        [
          {
            "node": "Chain LLM Process",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "Set Session Context": {
      "main": [
        [
          {
            "node": "If Recording Over 2 Seconds",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check Data in Sheets": {
      "main": [
        [],
        [
          {
            "node": "Append Data to Sheet",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Recording File": {
      "main": [
        [
          {
            "node": "Post Groq Transcription Request",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Groq Chat Integration": {
      "ai_languageModel": [
        [
          {
            "node": "Chain LLM Process",
            "type": "ai_languageModel",
            "index": 1
          }
        ]
      ]
    },
    "Incoming Webhook Trigger": {
      "main": [
        [
          {
            "node": "Set Session Context",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "If Recording Over 2 Seconds": {
      "main": [
        [
          {
            "node": "Wait 4 Seconds",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Post Groq Transcription Request": {
      "main": [
        [
          {
            "node": "Chain LLM Process",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}