Skip to content

Instantly share code, notes, and snippets.

@nitefood
Last active June 11, 2026 19:45
Show Gist options
  • Select an option

  • Save nitefood/e2813b900692f0ec3ed1c42e3c3a6767 to your computer and use it in GitHub Desktop.

Select an option

Save nitefood/e2813b900692f0ec3ed1c42e3c3a6767 to your computer and use it in GitHub Desktop.
How to Connect Alexa to Gemini: A Step-by-Step Guide Using n8n

Step-by-Step Setup

  1. Access the Alexa Developer Console: Go to https://developer.amazon.com/alexa/console/ask.
  2. Create a New Skill: Click on Create Skill, give it a name, and choose your preferred language.
  3. Choose a Template: Select the "Start from Scratch" template and leave the rest as the default.

Configure the Skill

Once your skill is created, you will be taken to the dashboard. Here's what you need to configure:

  • Skill Invocation Name: This is the word or phrase you'll use to open your skill. It's what you say to your Echo device (e.g., "Alexa, open my awesome assistant"). This command launches the skill, which in turn runs your n8n workflow and starts a conversation with Gemini.

  • Interaction Model -> Intents: This is where you define what actions your skill can perform.

    • You can delete the default HelloWorldIntent.
    • Click Add intent to create a new one.
    • Name the new intent LLMIntent and click Create custom intent.
  • Configure the Intent Slot: A slot is a variable that captures information from the user's request.

    • In the LLMIntent settings, scroll down to the Intent Slots section and click the plus icon to add a new slot.
    • Name this slot question.
    • Set the Slot Type to AMAZON.SearchQuery. This type is perfect for capturing open-ended questions.
    • Click Edit Dialog and enable the "Is this slot required to fulfill the intent?" selector. This ensures Alexa will prompt the user if they don't provide a question.
  • Add Utterances: Utterances are the phrases that trigger your intent.

    • In the LLMIntent settings, go to the Sample Utterances section.
    • Add a simple utterance like gemini {question}.
    • This setup means that when your skill is active, you'll need to start your questions with "gemini." For example: "Alexa, open my awesome assistant... Gemini, what's the largest planet in the solar system?"
    • Pro-Tip: If you want to avoid saying "gemini" for every question, you'll need to add more utterances to cover various conversation starters, like tell me {question}, ask {question}, what {question}, and so on.

n8n Configuration

To handle the conversation and interface with Gemini, you will need to import a pre-made n8n workflow.

  1. Download the Workflow: Download the workflow JSON below.
  2. Import to n8n: In your n8n instance, click the New button in the top-left corner, and then select Import from File. Upload the JSON file you just downloaded.
  3. Configure Credentials: The imported workflow will require you to add your Gemini API key. Double-click the Gemini node to open its settings and add your credentials.
  4. More Customizations: Optionally, you can customize the SearXNG, Calendar and Telegram tools to integrate them within the workflow, but that's not strictly required (although it can be very useful for your AI Agent to have Internet, calendar and Telegram to interact with).

Connect the skill to n8n

The last step is to connect your Alexa skill to your n8n workflow.

  • In the left-hand sidebar of the Alexa developer console, click on Endpoint.
  • Select the HTTPS radio button.
  • In the Default Region field, paste the production webhook URL from your n8n instance. Make sure you've activated the workflow in n8n first!
  • Select the option "My development endpoint has a certificate from a trusted certificate authority" (this is the standard for most n8n setups).
  • Finally, click Save and then Build at the top of the page.
{
"name": "Alexa-Gemini",
"nodes": [
{
"parameters": {
"respondWith": "json",
"responseBody": "={\n \"version\": \"1.0\",\n \"sessionAttributes\": {\n \"countActionList\": {\n \"read\": true,\n \"category\": true\n }\n }{{ (typeof $json.response !== 'undefined' && $json.response !== null) ? ',\"response\": ' + JSON.stringify($json.response) : '' }}\n}",
"options": {}
},
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.4,
"position": [
1216,
32
],
"id": "441a9b83-850d-43bc-b5a0-95a6ab669ebe",
"name": "Respond to Webhook"
},
{
"parameters": {
"rules": {
"values": [
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"leftValue": "={{ $json.body.request.type }}",
"rightValue": "LaunchRequest",
"operator": {
"type": "string",
"operation": "equals"
},
"id": "cb591d54-51db-475e-8f75-aa17b008a875"
}
],
"combinator": "and"
}
},
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "60b2e95d-b0e1-4fd5-bfd3-2acc0e196b0c",
"leftValue": "={{ $json.body.request.type }}",
"rightValue": "SessionEndedRequest",
"operator": {
"type": "string",
"operation": "equals",
"name": "filter.operator.equals"
}
}
],
"combinator": "and"
}
},
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "7ba00338-55f1-4039-809a-89f137d3969b",
"leftValue": "={{ $json.body.request.type }}",
"rightValue": "IntentRequest",
"operator": {
"type": "string",
"operation": "equals",
"name": "filter.operator.equals"
}
}
],
"combinator": "and"
}
}
]
},
"options": {}
},
"type": "n8n-nodes-base.switch",
"typeVersion": 3.2,
"position": [
-160,
0
],
"id": "40d76e47-c8ae-46d8-a8d5-7aaf9aa1b496",
"name": "Request type Router"
},
{
"parameters": {
"promptType": "define",
"text": "=The user request is the following:\n\n```\n\n{{ $json.body.request.intent.slots.question.value }}\n\n```\n\nIf needed, remember that the current date and time are: {{ $now.toISO() }}.\n",
"options": {
"systemMessage": "=You are a friendly and versatile assistant. You have a vast knowledge of any topic. The user might mix questions about the tools you have available, but also mix them with generic questions, always try to understand the context and do not take the user's intent for granted.\nAlways respond in an exhaustive, friendly and concise manner. Follow the following output guidelines:\n\n# IMPORTANT: OUTPUT GUIDELINES\n\n- Interactions with the user will happen through voice-controlled devices, so do not use emojis and special formatting\n- Respond using only text that is easy to convey in audio format (TTS) in response to the user\n- NEVER use the characters `\\n` (new line) and `\"`, otherwise this will invalidate the output. The text must be extremely simple to embed in a JSON string, and clear for subsequent reading by a TTS model."
}
},
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 2.1,
"position": [
352,
352
],
"id": "e65ba76f-a112-46d0-b217-f3d66c9afb68",
"name": "AI Agent"
},
{
"parameters": {
"options": {}
},
"type": "@n8n/n8n-nodes-langchain.lmChatGoogleGemini",
"typeVersion": 1,
"position": [
352,
544
],
"id": "32c018fe-1a56-4e8b-a519-b92a0ba84572",
"name": "Google Gemini Chat Model",
"credentials": {
"googlePalmApi": {
"id": "4cOR2QMoEXlCs0aI",
"name": "Google Gemini(PaLM) Api account"
}
}
},
{
"parameters": {
"sessionIdType": "customKey",
"sessionKey": "={{ $('Alexa Skill Webhook').item.json.body.session.sessionId }}",
"contextWindowLength": 10
},
"type": "@n8n/n8n-nodes-langchain.memoryBufferWindow",
"typeVersion": 1.3,
"position": [
480,
512
],
"id": "9896b6d3-a787-4680-8d2f-49b000342874",
"name": "Simple Memory"
},
{
"parameters": {
"httpMethod": "POST",
"path": "c8fda81e-73c8-4ccf-b806-57756dedf947",
"responseMode": "responseNode",
"options": {}
},
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
-368,
16
],
"id": "fb45a6d9-baf6-4d3c-86c0-e44e4ea63ea1",
"name": "Alexa Skill Webhook",
"webhookId": "c8fda81e-73c8-4ccf-b806-57756dedf947"
},
{
"parameters": {
"content": "\n\n\n\n\n\n\n\n\n\n\n\n\n\n_Route the request according to the user's intent_",
"height": 272,
"width": 176,
"color": 5
},
"type": "n8n-nodes-base.stickyNote",
"position": [
48,
80
],
"typeVersion": 1,
"id": "79dbf154-ad3e-4739-adc4-8f2828736ced",
"name": "Sticky Note"
},
{
"parameters": {
"content": "_Handle skill Start / Stop and session close_",
"height": 592,
"width": 208
},
"type": "n8n-nodes-base.stickyNote",
"position": [
448,
-400
],
"typeVersion": 1,
"id": "3b88be09-335b-4dcc-99ca-3adb92eccf7e",
"name": "Sticky Note1"
},
{
"parameters": {
"content": "## Handle the LLM conversation intent",
"height": 480,
"width": 720,
"color": 4
},
"type": "n8n-nodes-base.stickyNote",
"position": [
304,
240
],
"typeVersion": 1,
"id": "1bde640b-291d-437c-96e5-ce0866bfcb94",
"name": "Sticky Note2"
},
{
"parameters": {
"content": "_Determine the request type (if the user has opened the skill, closed it, or wants to interact with the LLM)_",
"height": 288,
"width": 208,
"color": 7
},
"type": "n8n-nodes-base.stickyNote",
"position": [
-208,
-96
],
"typeVersion": 1,
"id": "dfb4aff9-7768-4e14-b54c-03a839abae1f",
"name": "Sticky Note3"
},
{
"parameters": {
"rules": {
"values": [
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "ce1db873-0b88-4f1b-9458-d06ed274b539",
"leftValue": "={{ $json.body.request.intent.name }}",
"rightValue": "AMAZON.StopIntent",
"operator": {
"type": "string",
"operation": "equals",
"name": "filter.operator.equals"
}
}
],
"combinator": "and"
}
},
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "49394ba5-74df-4ba5-ba0e-33b761ce2c8f",
"leftValue": "={{ $json.body.request.intent.name }}",
"rightValue": "LLMIntent",
"operator": {
"type": "string",
"operation": "equals",
"name": "filter.operator.equals"
}
}
],
"combinator": "and"
}
}
]
},
"options": {}
},
"type": "n8n-nodes-base.switch",
"typeVersion": 3.2,
"position": [
80,
112
],
"id": "9e0cd4b2-20f2-4d71-a4f2-ac9fa6b7ce12",
"name": "Intent router"
},
{
"parameters": {
"mode": "raw",
"jsonOutput": "={\n \"type\": \"SessionEndedRequest\",\n \"requestId\": \"{{ $json.body.request.requestId }}\",\n \"timestamp\": \"{{ $now.toISO() }}\",\n \"reason\": \"USER_INITIATED\"\n}",
"options": {}
},
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
496,
-160
],
"id": "a1cf3091-db6f-4fa4-a4e7-454a5c05cd52",
"name": "Set response fields (SessionEndedRequest)"
},
{
"parameters": {
"mode": "raw",
"jsonOutput": "={\n \"response\": {\n \"outputSpeech\": {\n \"type\": \"PlainText\",\n \"text\": \"{{ $json.output }}\"\n },\n \"card\": {\n \"type\": \"Simple\",\n \"title\": \"Reply from Gemini\",\n \"content\": \"{{ $json.output }}\"\n },\n \"reprompt\": {\n \"outputSpeech\": {\n \"type\": \"PlainText\",\n \"text\": \"start your sentence with: \\\"gemini\\\", to ask a question\"\n }\n },\n \"shouldEndSession\": false\n }\n}",
"options": {}
},
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
880,
352
],
"id": "1084f95c-833a-4615-b680-45577c8f2021",
"name": "Set response fields (LLMIntent)"
},
{
"parameters": {
"mode": "raw",
"jsonOutput": "={\n \"response\": {\n \"outputSpeech\": {\n \"type\": \"PlainText\",\n \"text\": \"Bye bye!\"\n }\n },\n \"shouldEndSession\": false\n}",
"options": {}
},
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
496,
16
],
"id": "66357f3c-f00f-468a-bdf3-f224be52936e",
"name": "Set response fields (AMAZON.StopIntent)"
},
{
"parameters": {
"mode": "raw",
"jsonOutput": "{\n \"response\": {\n \"outputSpeech\": {\n \"type\": \"PlainText\",\n \"text\": \"start your sentence with: \\\"gemini\\\", to ask a question\"\n },\n \"reprompt\": {\n \"outputSpeech\": {\n \"type\": \"PlainText\",\n \"text\": \"start your sentence with: \\\"gemini\\\", to ask a question\"\n }\n },\n \"shouldEndSession\": false\n }\n}",
"options": {}
},
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
496,
-336
],
"id": "84e91110-ad67-4271-ac20-cee1f43b0c15",
"name": "Set response fields (LaunchRequest)"
},
{
"parameters": {
"options": {
"safesearch": 0
}
},
"type": "@n8n/n8n-nodes-langchain.toolSearXng",
"typeVersion": 1,
"position": [
720,
560
],
"id": "12d9034a-1e28-4807-ab65-e6fd9339b476",
"name": "SearXNG",
"credentials": {
"searXngApi": {
"id": "kVIfIc15vsCQvMAm",
"name": "Your SearXNG Instance"
}
}
},
{
"parameters": {
"sseEndpoint": "http://127.0.0.1:5678/mcp/04f71579-6b7b-49cb-8ef7-db29d3189ab2"
},
"type": "@n8n/n8n-nodes-langchain.mcpClientTool",
"typeVersion": 1,
"position": [
608,
544
],
"id": "a5a4b1f2-09ea-4a61-94db-244bc0eadb1a",
"name": "Calendar MCP Client"
},
{
"parameters": {
"jsCode": "let text = $input.first().json.output;\n\n// Escape double quotes\ntext = text.replace(/\"/g, '\\\\\"');\n\n// Remove linefeeds\ntext = text.replace(/\\n/g, '');\n\n// Replace the original text\n$input.first().json.output = text;\n\nreturn $input.all();"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
704,
352
],
"id": "f4eda107-c41c-4c76-8ac2-03d99f4ea008",
"name": "Escape special chars"
},
{
"parameters": {
"chatId": "<YOUR-TELEGRAM-CHATID>",
"text": "={{ /*n8n-auto-generated-fromAI-override*/ $fromAI('Text', ``, 'string') }}",
"additionalFields": {
"appendAttribution": false
}
},
"type": "n8n-nodes-base.telegramTool",
"typeVersion": 1.2,
"position": [
864,
560
],
"id": "696ff64a-4997-4a53-8eb0-7bf42010e6a3",
"name": "Send a text message in Telegram",
"webhookId": "2888a723-f40e-4acd-ba67-c3a4abae0ff2",
"credentials": {
"telegramApi": {
"id": "7HEjUwbAQ1qdz5ao",
"name": "Telegram account"
}
}
}
],
"pinData": {},
"connections": {
"Request type Router": {
"main": [
[
{
"node": "Set response fields (LaunchRequest)",
"type": "main",
"index": 0
}
],
[
{
"node": "Set response fields (SessionEndedRequest)",
"type": "main",
"index": 0
}
],
[
{
"node": "Intent router",
"type": "main",
"index": 0
}
]
]
},
"Google Gemini Chat Model": {
"ai_languageModel": [
[
{
"node": "AI Agent",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"AI Agent": {
"main": [
[
{
"node": "Escape special chars",
"type": "main",
"index": 0
}
]
]
},
"Simple Memory": {
"ai_memory": [
[
{
"node": "AI Agent",
"type": "ai_memory",
"index": 0
}
]
]
},
"Alexa Skill Webhook": {
"main": [
[
{
"node": "Request type Router",
"type": "main",
"index": 0
}
]
]
},
"Intent router": {
"main": [
[
{
"node": "Set response fields (AMAZON.StopIntent)",
"type": "main",
"index": 0
}
],
[
{
"node": "AI Agent",
"type": "main",
"index": 0
}
]
]
},
"Set response fields (SessionEndedRequest)": {
"main": [
[
{
"node": "Respond to Webhook",
"type": "main",
"index": 0
}
]
]
},
"Set response fields (LLMIntent)": {
"main": [
[
{
"node": "Respond to Webhook",
"type": "main",
"index": 0
}
]
]
},
"Set response fields (AMAZON.StopIntent)": {
"main": [
[
{
"node": "Respond to Webhook",
"type": "main",
"index": 0
}
]
]
},
"Set response fields (LaunchRequest)": {
"main": [
[
{
"node": "Respond to Webhook",
"type": "main",
"index": 0
}
]
]
},
"SearXNG": {
"ai_tool": [
[
{
"node": "AI Agent",
"type": "ai_tool",
"index": 0
}
]
]
},
"Calendar MCP Client": {
"ai_tool": [
[
{
"node": "AI Agent",
"type": "ai_tool",
"index": 0
}
]
]
},
"Escape special chars": {
"main": [
[
{
"node": "Set response fields (LLMIntent)",
"type": "main",
"index": 0
}
]
]
},
"Send a text message in Telegram": {
"ai_tool": [
[
{
"node": "AI Agent",
"type": "ai_tool",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
},
"versionId": "a1e51c7b-0d10-44a1-b2e4-b20060167f76",
"meta": {
"templateCredsSetupCompleted": true,
"instanceId": "6c0ae52c424bb304754dd36ddb4d5a27e72da7897a48a3fabdfb969b44861181"
},
"id": "UKLoxp3jvO3ALHFc",
"tags": []
}
@chidarumaa

Copy link
Copy Markdown

nice thanks but I get the following error while trying to build the alexa skill:

Prompt id "" for slot "question" is not present in prompts, but is referenced in the dialog model for intent "LLMIntent".

@chidarumaa

Copy link
Copy Markdown

I got it working, was close to en1ng myself

@kinduff

kinduff commented Nov 9, 2025

Copy link
Copy Markdown

@chidarumaa glad you didn't! How did you fix the issue?

@boop5

boop5 commented Nov 26, 2025

Copy link
Copy Markdown

you have to pick the "smart home" type (at least not "other") before you can pick the template

image

@JDIDDLY

JDIDDLY commented Dec 24, 2025

Copy link
Copy Markdown

@chidarumaa, I'm getting the same error when building the skill. What was your fix?

Prompt id "" for slot "question" is not present in prompts, but is referenced in the dialog model for intent "llmintent".

@chidarumaa

chidarumaa commented Dec 25, 2025

Copy link
Copy Markdown

@kinduff @JDIDDLY sorry for the late response. I was playing too much real-life :D .

could you please try the following:

Go to Interaction Model > Intents > LLMIntent.

In the list of slots at the bottom, find the question slot.

Click on the slot name (question) or click "Edit" next to it.

Scroll down to the Slot Filling section.

If "Is this slot required to fulfill the intent?" is checked:

    Look at Alexa speech prompts.

    Type a question like: "What would you like to ask?" or "Please tell me your question."

    Click the + button to add it.

Click Save Model at the top, then click Build Model.`

@soubhi8

soubhi8 commented Apr 23, 2026

Copy link
Copy Markdown

I added this node to verify whether requests are legit from Alexa before going to the agent.
P.s: you need to have node built in tools allowed: (crypto, https, url)

{ "nodes": [ { "parameters": { "jsCode": "// -------------------------------------------------------------\n// Alexa request verification - per Amazon's spec.\n// Fails closed: any error returns { verified:false, reason, detail }\n// -------------------------------------------------------------\nconst crypto = require('crypto');\nconst https = require('https');\nconst { URL } = require('url'); // explicit - n8n sandbox may not expose URL as a global\n\nconst item = $input.first();\nconst headers = item.json.headers || {};\n\n// n8n lower-cases header names\nconst sigCertChainUrl = headers['signaturecertchainurl'];\nconst signature256 = headers['signature-256'];\n\n// --- Raw body handling ---------------------------------------\n// The signature is computed over the EXACT bytes Alexa sent.\n// Re-serialising a parsed object will not produce the same bytes\n// (key order, whitespace, numeric formatting all differ) and the\n// signature will never validate. Enable \"Raw Body\" on the\n// Alexa Skill Webhook node.\nlet rawBody, parsedBody, rawBodyAvailable = true;\nif (typeof item.json.body === 'string') {\n rawBody = item.json.body;\n parsedBody = JSON.parse(rawBody);\n} else if (item.binary && item.binary.data) {\n rawBody = Buffer.from(item.binary.data.data, 'base64').toString('utf8');\n parsedBody = JSON.parse(rawBody);\n} else {\n parsedBody = item.json.body;\n rawBody = JSON.stringify(parsedBody);\n rawBodyAvailable = false;\n}\n\nconst fail = (reason, detail) => [{\n json: {\n verified: false,\n reason,\n detail: detail || null,\n rawBodyAvailable,\n certUrl: sigCertChainUrl || null,\n body: parsedBody,\n headers,\n }\n}];\n\nif (!rawBodyAvailable) {\n return fail(\n 'Raw body not available on webhook',\n 'Open the Alexa Skill Webhook node, expand Options, and enable \"Raw Body\". Without it the body bytes cannot be re-hashed.'\n );\n}\n\nif (!sigCertChainUrl || !signature256) {\n return fail('Missing SignatureCertChainUrl or Signature-256 header');\n}\n\n// --- Step 1: validate the cert-chain URL --------------------\nlet certUrl;\ntry {\n certUrl = new URL(sigCertChainUrl);\n} catch (e) {\n return fail('Malformed SignatureCertChainUrl', e.message);\n}\n\n// Normalise: strip fragment, resolve dot-segments, collapse //\ncertUrl.hash = '';\nconst segs = [];\nfor (const p of certUrl.pathname.split('/')) {\n if (p === '' || p === '.') continue;\n if (p === '..') { segs.pop(); continue; }\n segs.push(p);\n}\nconst normPath = '/' + segs.join('/');\n\nif (certUrl.protocol.toLowerCase() !== 'https:') return fail('Bad protocol', certUrl.protocol);\nif (certUrl.hostname.toLowerCase() !== 's3.amazonaws.com') return fail('Bad hostname', certUrl.hostname);\nif (!normPath.startsWith('/echo.api/')) return fail('Bad path', normPath);\nif (certUrl.port && certUrl.port !== '443') return fail('Bad port', certUrl.port);\n\n// --- Step 2: download the PEM chain -------------------------\nlet certPem;\ntry {\n certPem = await new Promise((resolve, reject) => {\n const req = https.get(sigCertChainUrl, (res) => {\n if (res.statusCode !== 200) {\n return reject(new Error('HTTP ' + res.statusCode));\n }\n let data = '';\n res.setEncoding('utf8');\n res.on('data', (c) => { data += c; });\n res.on('end', () => resolve(data));\n });\n req.on('error', reject);\n req.setTimeout(5000, () => req.destroy(new Error('timeout')));\n });\n} catch (e) {\n return fail('Cert download failed', e.message);\n}\n\n// --- Step 3: validate the signing (leaf) certificate --------\nlet signingCert;\ntry {\n const first = certPem.match(/-----BEGIN CERTIFICATE-----[\\s\\S]+?-----END CERTIFICATE-----/);\n if (!first) throw new Error('No PEM block found');\n signingCert = new crypto.X509Certificate(first[0]);\n} catch (e) {\n return fail('Invalid signing certificate', e.message);\n}\n\nconst now = Date.now();\nif (now < Date.parse(signingCert.validFrom) ||\n now > Date.parse(signingCert.validTo)) {\n return fail(\n 'Signing certificate expired or not yet valid',\n 'validFrom=' + signingCert.validFrom + ' validTo=' + signingCert.validTo\n );\n}\n\nconst san = signingCert.subjectAltName || '';\nif (!san.includes('echo-api.amazon.com')) {\n return fail('SAN does not contain echo-api.amazon.com', san);\n}\n\n// --- Step 4: verify the signature ---------------------------\nlet sigValid;\ntry {\n const verifier = crypto.createVerify('RSA-SHA256');\n verifier.update(rawBody, 'utf8');\n verifier.end();\n const sigBuf = Buffer.from(signature256, 'base64');\n sigValid = verifier.verify(signingCert.publicKey, sigBuf);\n} catch (e) {\n return fail('Signature verification error', e.message);\n}\nif (!sigValid) {\n return fail('Signature does not match request body');\n}\n\n// --- Step 5: anti-replay timestamp check (<= 150 s) ---------\nconst ts = parsedBody && parsedBody.request && parsedBody.request.timestamp;\nif (!ts) return fail('Missing request.timestamp');\nconst ageSec = Math.abs((Date.now() - Date.parse(ts)) / 1000);\nif (ageSec > 150) return fail('Timestamp outside 150-second tolerance', 'age=' + ageSec.toFixed(1) + 's');\n\n// --- All good: forward the parsed body downstream -----------\nreturn [{\n json: { verified: true, headers, body: parsedBody }\n}];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ -1808, 1056 ], "id": "d30593e6-b769-4b6b-8c7c-3733a0331bc1", "name": "Verify Alexa Request" }, { "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2 }, "conditions": [ { "id": "20784bfc-36ed-4965-b8b4-28e28e3dedce", "leftValue": "={{ $json.verified }}", "rightValue": "", "operator": { "type": "boolean", "operation": "true", "singleValue": true } } ], "combinator": "and" }, "options": {} }, "type": "n8n-nodes-base.if", "typeVersion": 2.2, "position": [ -1536, 1056 ], "id": "bd39f390-41be-4fbd-86aa-745c544b285d", "name": "Verification passed?" }, { "parameters": { "content": "## Alexa request verification\n\nPer Amazon's spec every request must be rejected with HTTP 400 if:\n- SignatureCertChainUrl format is invalid\n- signing cert is expired / missing echo-api.amazon.com SAN\n- Signature-256 does not match SHA-256(body)\n- request.timestamp is older than 150 seconds\n\n_\"Raw Body\" must be enabled on the webhook — the signature is over the exact bytes._", "height": 496, "width": 420, "color": 3 }, "type": "n8n-nodes-base.stickyNote", "position": [ -1984, 736 ], "typeVersion": 1, "id": "97e32924-abbf-42c2-8b7f-ed03541a9447", "name": "Sticky Note Verify" } ], "connections": { "Verify Alexa Request": { "main": [ [ { "node": "Verification passed?", "type": "main", "index": 0 } ] ] }, "Verification passed?": { "main": [ [], [] ] } }, "pinData": {}, "meta": { "templateCredsSetupCompleted": true, "instanceId": "133364fc0a0dd2fe312f4305e4826b5dd8b93b560ad8173f9dcb2abd155c7d23" } }

@Leander250

Copy link
Copy Markdown

Thanks soubhi8 for providing that.

How to add:

  1. Save into a JSON file
  2. In your n8n web UI select "Import from file..."
  3. Add the verification at the beginning
image 4. Allow the required node modules by setting an environment variable for n8n. This is how it works for the n8n Homeassistant plugin configuration:

- "NODE_FUNCTION_ALLOW_BUILTIN: crypto,https,url"
5. Select the "Alexa Skill Webhook" and add the raw body option:
image

Why add?
Because otherwise, everyone who knows your webhook URL could send fake requests to your AI.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment