The workflow JSON
Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →
{
"name": "list_songs_webhook",
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "b91a0495-0e7d-4715-ab08-241eb48313a0",
"responseMode": "responseNode",
"options": {}
},
"type": "n8n-nodes-base.webhook",
"typeVersion": 2.1,
"position": [
0,
0
],
"id": "7d5fe275-7a3f-4e12-b411-dc0683f1e109",
"name": "Webhook - List Songs"
},
{
"parameters": {
"jsCode": "const item = $input.first().json;\n\nconst rawQuery =\n typeof item.body?.query === \"string\" ? item.body.query :\n typeof item.query === \"string\" ? item.query :\n typeof item.song === \"string\" ? item.song :\n typeof item.text === \"string\" ? item.text :\n \"\";\n\nconst rawLimit =\n typeof item.body?.limit === \"number\" ? item.body.limit :\n typeof item.limit === \"number\" ? item.limit :\n 25;\n\nconst limit = Math.max(1, Math.min(100, Number(rawLimit) || 25));\n\nreturn [\n {\n json: {\n originalQuery: rawQuery.trim(),\n normalizedQuery: rawQuery.trim().toLowerCase(),\n limit\n }\n }\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
208,
0
],
"id": "705bc39c-d545-493d-9dee-3f08bd641e1c",
"name": "Prepare List Query"
},
{
"parameters": {
"method": "POST",
"url": "http://host.docker.internal:7474/db/neo4j/query/v2",
"authentication": "genericCredentialType",
"genericAuthType": "httpBasicAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Accept",
"value": "application/json"
}
]
},
"sendBody": true,
"contentType": "raw",
"rawContentType": "=application/json",
"body": "={{$json.rawBody}}",
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
640,
0
],
"id": "7c8afa31-1932-4904-b7d1-f2fac66954c1",
"name": "Query Neo4j List",
"credentials": {
"httpBasicAuth": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"jsCode": "const item = $input.first().json;\nconst fields = item.data?.fields || [];\nconst values = item.data?.values || [];\nconst originalQuery = $('Prepare List Query').first().json.originalQuery || \"\";\nconst normalizedQuery = $('Prepare List Query').first().json.normalizedQuery || \"\";\nconst limit = $('Prepare List Query').first().json.limit || 25;\n\nfunction mapRow(row) {\n const mapped = {};\n fields.forEach((field, index) => {\n mapped[field] = row[index];\n });\n return mapped;\n}\n\nfunction normalizeCatalogQuery(query) {\n return String(query || \"\")\n .toLowerCase()\n .replace(/rocksange/g, \"rock\")\n .replace(/metalsange/g, \"metal\")\n .replace(/popsange/g, \"pop\")\n .replace(/technosange/g, \"techno\")\n .replace(/[.,!?()]/g, \" \")\n .replace(/\\b(find|show|list|give|me|all|stored|songs|song|chords|for|please|vis|finde|alle|sange|sang|sorteret|alfabetisk|kunstner|kunstnere|artist|artists|efter|med|noget|mere|specifikke|specifik|lookup|opslagsvaerk|opslagsv\u00e6rk|by)\\b/g, \" \")\n .replace(/\\s+/g, \" \")\n .trim();\n}\n\nfunction inferGenres(row) {\n const genres = new Set();\n const artist = String(row.artist || \"\").toLowerCase();\n const song = String(row.song || \"\").toLowerCase();\n const tags = Array.isArray(row.tags) ? row.tags.map(tag => String(tag).toLowerCase()) : [];\n const summary = String(row.summary || \"\").toLowerCase();\n\n for (const tag of tags) {\n if (tag) genres.add(tag);\n }\n\n const addGenres = (...values) => values.forEach(value => genres.add(value));\n\n if (artist.includes(\"oasis\")) addGenres(\"rock\", \"britpop\");\n if (artist.includes(\"coldplay\")) addGenres(\"rock\", \"alternative rock\", \"pop rock\");\n if (artist.includes(\"courtney barnett\")) addGenres(\"rock\", \"indie rock\");\n if (artist.includes(\"bruce springsteen\")) addGenres(\"rock\", \"heartland rock\");\n if (artist.includes(\"twenty one pilots\")) addGenres(\"rock\", \"alternative rock\", \"pop rock\");\n if (artist.includes(\"finn wolfhard\")) addGenres(\"rock\", \"indie rock\");\n if (artist.includes(\"rend collective\")) addGenres(\"rock\", \"folk rock\", \"christian rock\");\n if (artist.includes(\"olivia rodrigo\")) addGenres(\"pop\", \"pop rock\");\n if (artist.includes(\"taylor swift\")) addGenres(\"pop\", \"country pop\", \"pop rock\");\n if (artist.includes(\"ed sheeran\")) addGenres(\"pop\");\n if (artist.includes(\"charli xcx\")) addGenres(\"pop\", \"electropop\");\n if (artist.includes(\"electric callboy\")) addGenres(\"metal\", \"metalcore\", \"electronicore\", \"techno\");\n\n if (song.includes(\"easy version\")) {\n genres.add(\"accessible\");\n }\n\n if (summary.includes(\"metal\")) addGenres(\"metal\");\n if (summary.includes(\"rock\")) addGenres(\"rock\");\n if (summary.includes(\"pop\")) addGenres(\"pop\");\n if (summary.includes(\"techno\")) addGenres(\"techno\");\n\n return Array.from(genres);\n}\n\nfunction detectRequestedGenre(normalizedQuery) {\n if (!normalizedQuery) return \"\";\n if (/(^|\\s)rock($|\\s)/.test(normalizedQuery)) return \"rock\";\n if (/(^|\\s)metal($|\\s)/.test(normalizedQuery)) return \"metal\";\n if (/(^|\\s)pop($|\\s)/.test(normalizedQuery)) return \"pop\";\n if (/(^|\\s)techno($|\\s)/.test(normalizedQuery)) return \"techno\";\n return \"\";\n}\n\nfunction removeGenreTokens(normalizedQuery, genre) {\n if (!genre) return normalizedQuery;\n return normalizedQuery\n .replace(new RegExp(`(^|\\\\s)${genre}($|\\\\s)`, \"g\"), \" \")\n .replace(/\\s+/g, \" \")\n .trim();\n}\n\nfunction matchesTextQuery(row, searchText) {\n if (!searchText) return true;\n\n const haystack = [\n row.artist,\n row.song,\n row.summary,\n ...(Array.isArray(row.tags) ? row.tags : []),\n ...(Array.isArray(row.inferredGenres) ? row.inferredGenres : [])\n ]\n .filter(Boolean)\n .join(\" \")\n .toLowerCase();\n\n const terms = searchText.split(\" \").filter(Boolean);\n return terms.every(term => haystack.includes(term));\n}\n\nfunction matchGenreBucket(row, bucket) {\n const inferredGenres = Array.isArray(row.inferredGenres) ? row.inferredGenres : [];\n return inferredGenres.some(genre => bucket.includes(genre));\n}\n\nconst genreProfiles = {\n rock: {\n aliases: [\"rock\", \"alternative rock\", \"indie rock\", \"hard rock\", \"britpop\", \"folk rock\", \"heartland rock\", \"christian rock\"],\n fallback: [\"metal\", \"metalcore\", \"electronicore\"]\n },\n metal: {\n aliases: [\"metal\", \"metalcore\", \"electronicore\", \"hard rock\"],\n fallback: [\"rock\", \"alternative rock\"]\n },\n pop: {\n aliases: [\"pop\", \"electropop\", \"dance pop\", \"country pop\", \"synthpop\", \"pop rock\"],\n fallback: [\"rock\", \"indie rock\"]\n },\n techno: {\n aliases: [\"techno\", \"electronic\", \"electropop\", \"electronicore\"],\n fallback: []\n }\n};\n\nconst rows = values.map(row => ({\n ...mapRow(row),\n inferredGenres: inferGenres(mapRow(row))\n}));\n\nconst effectiveNormalizedQuery = normalizeCatalogQuery(normalizedQuery || originalQuery);\nconst requestedGenre = detectRequestedGenre(effectiveNormalizedQuery);\nconst textSearch = removeGenreTokens(effectiveNormalizedQuery, requestedGenre);\n\nconst textMatchedRows = rows.filter(row => matchesTextQuery(row, textSearch));\n\nlet filteredRows = textMatchedRows;\nlet genreFallbackUsed = false;\n\nif (requestedGenre && genreProfiles[requestedGenre]) {\n const primaryRows = textMatchedRows.filter(row =>\n matchGenreBucket(row, genreProfiles[requestedGenre].aliases)\n );\n\n if (primaryRows.length > 0) {\n filteredRows = primaryRows;\n } else if (genreProfiles[requestedGenre].fallback.length > 0) {\n const fallbackRows = textMatchedRows.filter(row =>\n matchGenreBucket(row, genreProfiles[requestedGenre].fallback)\n );\n\n if (fallbackRows.length > 0) {\n filteredRows = fallbackRows;\n genreFallbackUsed = true;\n } else {\n filteredRows = [];\n }\n } else {\n filteredRows = [];\n }\n}\n\nfilteredRows = filteredRows.slice(0, limit);\n\nif (!filteredRows.length) {\n return [\n {\n json: {\n found: false,\n response: originalQuery\n ? `I couldn't find any stored songs matching \"${originalQuery}\".`\n : \"I couldn't find any stored songs in Neo4j.\"\n }\n }\n ];\n}\n\nconst lines = [];\nconst heading = originalQuery\n ? `Stored songs matching \"${originalQuery}\" (sorted by artist):`\n : \"Stored songs (sorted by artist):\";\n\nlines.push(heading);\n\nif (genreFallbackUsed && requestedGenre === \"rock\") {\n lines.push(\"\");\n lines.push(\"No direct rock-labelled matches were found, so I included the closest metal-adjacent matches instead.\");\n}\n\nlines.push(\"\");\n\nfor (const row of filteredRows) {\n const extras = [];\n\n if (row.capo) {\n extras.push(`capo ${row.capo}`);\n }\n\n if (row.songKey) {\n extras.push(`key ${row.songKey}`);\n }\n\n const displayGenres = Array.isArray(row.inferredGenres)\n ? row.inferredGenres.filter(genre => genre !== \"accessible\").slice(0, 3)\n : [];\n\n if (displayGenres.length > 0) {\n extras.push(`genres: ${displayGenres.join(\", \")}`);\n }\n\n const suffix = extras.length ? ` [${extras.join(\" \u2022 \")}]` : \"\";\n lines.push(`- ${row.artist} \u2014 ${row.song}${suffix}`);\n}\n\nif (filteredRows.length === limit) {\n lines.push(\"\");\n lines.push(`Showing the first ${limit} matches.`);\n}\n\nreturn [\n {\n json: {\n found: true,\n response: lines.join(\"\\n\"),\n matches: filteredRows\n }\n }\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
848,
0
],
"id": "dcaec54f-cc6c-4610-ba36-84bd0c48c003",
"name": "Format Song List Results"
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{$json}}",
"options": {}
},
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.5,
"position": [
1056,
0
],
"id": "4cc0a176-aba5-4494-a735-5e810f3deef9",
"name": "Respond - Song List Results"
},
{
"parameters": {
"jsCode": "const item = $input.first().json;\n\nconst originalQuery = item.originalQuery || \"\";\nconst normalizedQuery = item.normalizedQuery || \"\";\nconst limit = item.limit || 25;\n\nconst rawBody = JSON.stringify({\n statement: `\n MATCH (s:Song)-[:BY_ARTIST]->(a:Artist)\n OPTIONAL MATCH (s)-[:HAS_TAG]->(t:Tag)\n RETURN\n a.name AS artist,\n s.title AS song,\n coalesce(s.capo, \"\") AS capo,\n coalesce(s.key, \"\") AS songKey,\n coalesce(s.url, \"\") AS url,\n coalesce(s.summary, \"\") AS summary,\n [tag IN collect(DISTINCT t.name) WHERE tag IS NOT NULL] AS tags\n ORDER BY toLower(a.name), toLower(s.title)\n LIMIT $limit\n `.replace(/\\s+/g, \" \").trim(),\n parameters: {\n originalQuery,\n normalizedQuery,\n limit\n }\n});\n\nreturn [\n {\n json: {\n originalQuery,\n normalizedQuery,\n limit,\n rawBody\n }\n }\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
432,
0
],
"id": "b103bf0c-149b-416a-9d42-753654487a98",
"name": "Build Neo4j List Request Body"
}
],
"connections": {
"Webhook - List Songs": {
"main": [
[
{
"node": "Prepare List Query",
"type": "main",
"index": 0
}
]
]
},
"Prepare List Query": {
"main": [
[
{
"node": "Build Neo4j List Request Body",
"type": "main",
"index": 0
}
]
]
},
"Query Neo4j List": {
"main": [
[
{
"node": "Format Song List Results",
"type": "main",
"index": 0
}
]
]
},
"Format Song List Results": {
"main": [
[
{
"node": "Respond - Song List Results",
"type": "main",
"index": 0
}
]
]
},
"Build Neo4j List Request Body": {
"main": [
[
{
"node": "Query Neo4j List",
"type": "main",
"index": 0
}
]
]
}
},
"active": true,
"settings": {
"executionOrder": "v1",
"binaryMode": "separate",
"availableInMCP": false
},
"versionId": "ba3430ed-ab47-4c67-8377-b8400724afe6",
"id": "QC0y6MMYaFEL01usFxOZb",
"tags": []
}
Credentials you'll need
Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.
httpBasicAuth
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
list_songs_webhook. Uses httpRequest. Webhook trigger; 6 nodes.
Source: https://github.com/AndLOLGG/ai-chord-neo4j-agent/blob/92988c1277a189f2339216ab9b4eb9fc9d90ad74/n8n/list_songs_webhook.json — original creator credit. Request a take-down →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
Jigsaw API key for image processing, I use this as a gatekeeper/second pair of eyes. LINK to their website https://jigsawstack.com/ SECOND A postgress DATABASE (I use Supabase) LlamaCloud for the pars
Onsite Photos to Jobs (SMS Agent). Uses dataTable, twilio, httpRequest, airtable. Webhook trigger; 62 nodes.
W1 - IN WhatsApp Adapter (Secure + Fast ACK). Uses postgres, redis, httpRequest. Webhook trigger; 50 nodes.
W1 - IN WhatsApp Adapter (Secure + Fast ACK). Uses postgres, redis, httpRequest. Webhook trigger; 48 nodes.