This workflow follows the HTTP Request → Postgres recipe pattern — see all workflows that pair these two integrations.
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": "W2 - IN Instagram Adapter (Secure)",
"active": true,
"settings": {
"executionTimeout": 300,
"saveExecutionProgress": true,
"saveManualExecutions": true
},
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "v1/inbound/instagram",
"responseMode": "responseNode",
"options": {
"rawBody": true
}
},
"id": "c66cc055-eda7-47b5-a286-3f51428c62ac",
"name": "IN - Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 1,
"position": [
-2400,
0
]
},
{
"parameters": {
"language": "javascript",
"jsCode": "\nfunction extractAttachments(msg) {\n const attachments = [];\n if (msg?.attachments && Array.isArray(msg.attachments)) {\n for (const a of msg.attachments) {\n if (a.type === 'audio' && a.payload?.url) {\n attachments.push({ type: 'audio', url: a.payload.url, mime: 'audio/mp4' });\n } else if (a.type === 'image' && a.payload?.url) {\n attachments.push({ type: 'image', url: a.payload.url, mime: 'image/jpeg' });\n } else if (a.type === 'video' && a.payload?.url) {\n attachments.push({ type: 'video', url: a.payload.url, mime: 'video/mp4' });\n } else if (a.type === 'file' && a.payload?.url) {\n attachments.push({ type: 'document', url: a.payload.url, mime: 'application/octet-stream' });\n } else if (a.type === 'ig_voice' && a.payload?.url) {\n attachments.push({ type: 'audio', url: a.payload.url, mime: 'audio/mp4' });\n }\n }\n }\n return attachments;\n}\n\nfunction parseMetaNative(rawBody) {\n if (!rawBody || typeof rawBody !== 'object') return null;\n if (rawBody.object !== 'page' && rawBody.object !== 'instagram') return null;\n \n const entry = rawBody.entry?.[0];\n if (!entry) return null;\n \n const messaging = entry.messaging?.[0];\n if (!messaging) return null;\n \n if (messaging.delivery || messaging.read || messaging.optin) {\n return { _isStatusUpdate: true, _ignore: true };\n }\n \n const msg = messaging.message;\n if (!msg && !messaging.postback) return null;\n \n let text = msg?.text || messaging.postback?.payload || messaging.postback?.title || '';\n \n // Convert epoch timestamp to ISO 8601\n const epochTs = messaging.timestamp;\n let isoTimestamp;\n if (epochTs) {\n const epochNum = Number(epochTs);\n const msTs = epochNum > 9999999999 ? epochNum : epochNum * 1000;\n isoTimestamp = new Date(msTs).toISOString();\n } else {\n isoTimestamp = new Date().toISOString();\n }\n \n const provider = rawBody.object === 'instagram' ? 'ig' : 'msg';\n \n return {\n _isMetaNative: true,\n provider,\n msg_id: msg?.mid || messaging.postback?.mid || '',\n from: messaging.sender?.id || '',\n text: text,\n timestamp: isoTimestamp,\n attachments: extractAttachments(msg),\n meta: {\n recipient_id: messaging.recipient?.id || '',\n },\n raw_meta_message: msg || messaging.postback\n };\n}\n\nconst rawBodyInput = $json.body ?? $json;\nconst metaNativeParsed = parseMetaNative(rawBodyInput);\n\nlet body;\nlet isMetaNative = false;\nlet isStatusUpdate = false;\n\nif (metaNativeParsed && metaNativeParsed._isStatusUpdate) {\n isStatusUpdate = true;\n body = rawBodyInput;\n} else if (metaNativeParsed && metaNativeParsed._isMetaNative) {\n isMetaNative = true;\n body = metaNativeParsed;\n} else {\n body = rawBodyInput;\n}\n\nconst headers = ($json.headers ?? $json?.headers ?? {});\nconst qs = ($json.query || $json.qs || {});\n\nfunction normVersion(v) {\n const s = (v || '').toString().trim().toLowerCase();\n if (!s) return 'v1';\n if (s === '1' || s === 'v1') return 'v1';\n if (s === '2' || s === 'v2') return 'v2';\n return 'unknown';\n}\n\nconst headerVer = headers['x-contract-version'] || headers['X-Contract-Version'] || headers['x_contract_version'] || '';\nconst bodyVer = body.contract_version || body.contractVersion || '';\nconst contractVersion = normVersion(headerVer || bodyVer || 'v1');\n\nconst auth = (headers['authorization'] || headers['Authorization'] || '').toString();\nconst bearer = auth.toLowerCase().startsWith('bearer ') ? auth.slice(7).trim() : '';\n\nconst headerToken = (\n headers['x-api-token'] || headers['X-Api-Token'] ||\n headers['x-webhook-token'] || headers['X-Webhook-Token'] ||\n ''\n).toString().trim();\n\nconst allowQueryToken = (($env.ALLOW_QUERY_TOKEN || 'false').toString().toLowerCase() === 'true');\nconst queryTokenProvided = !!(qs['token'] || qs['access_token']);\nconst queryToken = allowQueryToken ? (qs['token'] || qs['access_token'] || '') : '';\n\nconst token = (headerToken || bearer || queryToken || '').toString().trim();\nconst queryTokenUsed = !!queryToken && !headerToken && !bearer;\n\nconst shared = ($env.WEBHOOK_SHARED_TOKEN || '').toString().trim();\nconst legacySharedConfigured = !!shared;\nconst legacySharedValid = !!token && legacySharedConfigured && (token === shared);\n\n// Meta/Instagram signature verification (X-Hub-Signature-256) - P0-SEC-03\nconst metaSig = (headers['x-hub-signature-256'] || headers['X-Hub-Signature-256'] || '').toString().trim();\nconst metaSecret = ($env.META_APP_SECRET || '').toString();\nconst metaSigMode = ($env.META_SIGNATURE_REQUIRED || 'off').toString().toLowerCase();\nconst metaSigRequired = (metaSigMode === 'true' || metaSigMode === 'enforce');\nconst metaSigWarn = (metaSigMode === 'warn');\n\nfunction timingSafeEq(a, b) {\n try {\n const ba = Buffer.from(String(a));\n const bb = Buffer.from(String(b));\n if (ba.length !== bb.length) return false;\n return crypto.timingSafeEqual(ba, bb);\n } catch { return false; }\n}\n\nlet metaSigValid = null;\nlet metaSigReason = '';\nif (!metaSig) {\n metaSigValid = metaSigRequired ? false : null;\n metaSigReason = 'signature_missing';\n} else if (!metaSecret) {\n metaSigValid = false;\n metaSigReason = 'secret_missing';\n} else {\n const raw = ($json.rawBody && typeof $json.rawBody === 'string') ? $json.rawBody : JSON.stringify(body || {});\n const expected = 'sha256=' + crypto.createHmac('sha256', metaSecret).update(raw, 'utf8').digest('hex');\n metaSigValid = timingSafeEq(expected, metaSig);\n metaSigReason = metaSigValid ? 'ok' : 'invalid';\n}\n\nconst ipRaw = (headers['x-forwarded-for'] || headers['X-Forwarded-For'] || '').toString();\nconst ip = ipRaw.split(',')[0].trim();\n\nconst inboundReceivedAt = new Date().toISOString();\n\n// P1-01: Generate correlation ID for end-to-end tracing\nconst correlationId = (headers['x-correlation-id'] || headers['X-Correlation-Id'] || headers['x-request-id'] || crypto.randomUUID()).toString();\n\n// Hints from body (NEVER trusted)\nconst tenantHint = (body.tenantId || body.tenant_id || body.tenant || '').toString();\nconst restaurantHint = (body.restaurantId || body.restaurant_id || body.restaurant || '').toString();\n\n// Detect canonical envelope\nconst looksLikeV1 = body && typeof body === 'object' && body.provider && (body.msg_id || body.msgId) && (body.from || body.sender || body.sender_id);\nconst looksLikeV2 = body && typeof body === 'object' && body.provider && body.sender && body.message && (body.msg_id || body.msgId);\n\nfunction buildEnvelopeLegacy() {\n const userId = (body.userId || body.from || body.sender || body.sender_id || 'unknown-user').toString();\n const msgId = (body.msgId || body.messageId || body.mid || body.message?.id || body.message?.mid || crypto.randomUUID()).toString();\n const text = (body.text || body.message?.text || body.message?.body || '').toString();\n\n const buttonId = (body.buttonId || body.interactive?.button_reply?.id || body.message?.buttonId || '').toString();\n const audioUrl = (body.audioUrl || body.audio?.url || body.message?.audio?.url || '').toString();\n const audioMime = (body.audio?.mime || body.message?.audio?.mime || 'audio/ogg').toString();\n const imageUrl = (body.imageUrl || body.image?.url || body.message?.image?.url || '').toString();\n const imageMime = (body.image?.mime || body.message?.image?.mime || 'image/jpeg').toString();\n\n let attachments = [];\n if (audioUrl) attachments.push({type:'audio', url: audioUrl, mime: audioMime});\n if (imageUrl) attachments.push({type:'image', url: imageUrl, mime: imageMime});\n\n const locale = (body.locale || body.meta?.locale || '').toString();\n const timezone = (body.timezone || body.meta?.timezone || '').toString();\n\n return {\n contract_version: contractVersion === 'unknown' ? 'v1' : contractVersion,\n provider: 'ig',\n msg_id: msgId,\n from: userId,\n text: buttonId ? buttonId : text,\n timestamp: (body.timestamp || body.time || body.meta?.timestamp || inboundReceivedAt).toString(),\n attachments,\n meta: {\n locale: locale || undefined,\n timezone: timezone || undefined,\n ip: ip || undefined,\n user_agent: (headers['user-agent'] || headers['User-Agent'] || '').toString() || undefined\n },\n tenant_context: {\n source: 'untrusted_payload',\n hints: {\n tenant_hint: tenantHint || undefined,\n restaurant_hint: restaurantHint || undefined\n }\n }\n };\n}\n\nfunction buildEnvelopeFromV1(obj) {\n return {\n contract_version: 'v1',\n provider: (obj.provider || 'ig').toString(),\n msg_id: (obj.msg_id || obj.msgId).toString(),\n from: (obj.from || obj.sender || obj.sender_id).toString(),\n text: (obj.text || '').toString(),\n timestamp: (obj.timestamp || inboundReceivedAt).toString(),\n attachments: Array.isArray(obj.attachments) ? obj.attachments : [],\n meta: obj.meta || {},\n tenant_context: obj.tenant_context || obj.tenantContext || {\n source: 'untrusted_payload',\n hints: { tenant_hint: tenantHint || undefined, restaurant_hint: restaurantHint || undefined }\n }\n };\n}\n\nfunction buildEnvelopeFromV2(obj) {\n return {\n contract_version: 'v2',\n provider: (obj.provider || 'ig').toString(),\n msg_id: (obj.msg_id || obj.msgId).toString(),\n sender: {\n id: (obj.sender?.id || obj.sender?.from || obj.sender?.userId || '').toString(),\n display_name: (obj.sender?.display_name || obj.sender?.displayName || '').toString() || undefined\n },\n message: {\n text: (obj.message?.text || '').toString() || undefined,\n attachments: Array.isArray(obj.message?.attachments) ? obj.message.attachments : []\n },\n timestamp: (obj.timestamp || inboundReceivedAt).toString(),\n meta: obj.meta || {},\n tenant_context: obj.tenant_context || obj.tenantContext || {\n source: 'untrusted_payload',\n hints: { tenant_hint: tenantHint || undefined, restaurant_hint: restaurantHint || undefined }\n }\n };\n}\n\nlet envelope;\n\nif (isStatusUpdate) {\n normalizedVersion = 'v1';\n envelope = {\n contract_version: 'v1',\n provider: 'ig',\n msg_id: 'status_update',\n from: 'status_update',\n text: '',\n timestamp: inboundReceivedAt,\n attachments: [],\n meta: { status_update: true },\n tenant_context: { source: 'status_update', hints: {} }\n };\n}\nlet normalizedVersion = contractVersion;\nelse if (normalizedVersion === 'unknown') {\n envelope = null;\n} else if (normalizedVersion === 'v2') {\n envelope = buildEnvelopeFromV2(body);\n} else if (normalizedVersion === 'v1' && looksLikeV1 && !looksLikeV2) {\n envelope = buildEnvelopeFromV1(body);\n} else {\n normalizedVersion = 'v1';\n envelope = buildEnvelopeLegacy();\n}\n\n// Validate envelope against schema\nlet isValid = isStatusUpdate ? true : false;\nlet errors = [];\nlet schemaHash = '';\nlet schemaPath = '';\nlet validator = 'ajv';\n\ntry {\n if (!envelope) throw new Error('unknown_contract_version');\n if ((envelope.provider || '').toString() !== 'ig') throw new Error('provider_mismatch');\n const schemasRoot = ($env.SCHEMAS_ROOT || '/opt/resto/schemas').toString();\n schemaPath = path.join(schemasRoot, 'inbound', `${envelope.contract_version}.json`);\n const schemaText = fs.readFileSync(schemaPath, 'utf8');\n schemaHash = crypto.createHash('sha256').update(schemaText).digest('hex');\n const schema = JSON.parse(schemaText);\n\n let Ajv;\n try { Ajv = require('ajv'); } catch (e) { Ajv = null; }\n if (!Ajv) {\n validator = 'basic';\n // Hard fail: schema validation must be available in production\n throw new Error('ajv_not_available');\n }\n const ajv = new Ajv({allErrors:true, strict:false, allowUnionTypes:true});\n const validate = ajv.compile(schema);\n isValid = validate(envelope);\n if (!isValid) {\n errors = (validate.errors || []).slice(0, 10).map(e => ({path: e.instancePath || '', message: e.message || 'invalid'}));\n }\n} catch (err) {\n isValid = false;\n errors = [{path:'', message: (err && err.message) ? err.message : 'validation_error'}];\n}\n\nconst tokenHash = token ? crypto.createHash('sha256').update(token).digest('hex') : '';\n\n// Build internal canonical message (keep existing fields)\nconst ch = 'instagram';\nconst userId = envelope?.contract_version === 'v2'\n ? (envelope.sender?.id || '').toString()\n : (envelope?.from || '').toString();\n\nconst msgId = envelope?.contract_version === 'v2'\n ? (envelope.msg_id || '').toString()\n : (envelope?.msg_id || '').toString();\n\nconst text = envelope?.contract_version === 'v2'\n ? ((envelope.message?.text || '')).toString()\n : ((envelope?.text || '')).toString();\n\nconst atts = envelope?.contract_version === 'v2'\n ? (Array.isArray(envelope.message?.attachments) ? envelope.message.attachments : [])\n : (Array.isArray(envelope.attachments) ? envelope.attachments : []);\n\nconst firstAudio = Array.isArray(atts) ? atts.find(a => a && a.type === 'audio' && a.url) : null;\nconst firstImage = Array.isArray(atts) ? atts.find(a => a && a.type === 'image' && a.url) : null;\n\nlet type = 'text';\nif (firstAudio) type = 'audio';\nif (firstImage) type = 'image';\n\nconst textHash = crypto.createHash('sha256').update((text || '').toString()).digest('hex');\n\nreturn [{\n json: {\n channel: ch,\n _metaParsing: {\n isMetaNative,\n isStatusUpdate,\n rawBodyType: rawBodyInput?.object || 'legacy'\n },\n userId: userId || (body.userId || body.from || body.sender || 'unknown-user').toString(),\n tenantId: '',\n restaurantId: '',\n conversationKey: '',\n roleHint: body.roleHint || 'customer',\n contract_version: normalizedVersion,\n inbound_envelope: envelope,\n metadata: {\n msgId: msgId || (body.msgId || body.messageId || crypto.randomUUID()).toString(),\n timestamp: envelope?.timestamp || inboundReceivedAt,\n ip,\n userAgent: (headers['user-agent'] || headers['User-Agent'] || '').toString(),\n testMode: !!body.testMode\n },\n message: {\n type,\n text: (text || '').toString().trim(),\n buttonId: '',\n audio: firstAudio ? { url: firstAudio.url, mime: firstAudio.mime || 'audio/ogg' } : null,\n image: firstImage ? { url: firstImage.url, mime: firstImage.mime || 'image/jpeg' } : null\n },\n tenant_context_hints: {\n tenant_hint: tenantHint || '',\n restaurant_hint: restaurantHint || ''\n },\n _contract: {\n version: normalizedVersion,\n schemaPath,\n schemaHash,\n validator,\n isValid,\n errors\n },\n _timing: {\n inbound_received_at: inboundReceivedAt,\n correlation_id: correlationId\n },\n _auth: {\n tokenPresent: !!token,\n tokenHash,\n legacySharedConfigured,\n legacySharedValid,\n metaSigPresent: !!metaSig,\n metaSigRequired,\n metaSigValid,\n metaSigReason,\n allowQueryToken,\n queryTokenProvided,\n queryTokenUsed,\n tenantHint,\n restaurantHint\n },\n _sec: {\n textHash\n },\n raw: body\n }\n}];\n"
},
"id": "c6dd375c-ebed-4897-a874-cd4ca958e753",
"name": "B0 - Parse & Canonicalize",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-2150,
0
]
},
{
"parameters": {
"workflowId": "W0_MODULE_GUARD",
"workflowInputs": {
"mappingMode": "defineBelow",
"value": {
"json": "={{ { module_key: 'channel_instagram', tenant_id: $json.tenantId } }}"
}
}
},
"id": "module-guard-ig",
"name": "B0 - Module Guard",
"type": "n8n-nodes-base.executeWorkflow",
"typeVersion": 1,
"position": [
-1800,
-150
]
},
{
"parameters": {
"conditions": {
"boolean": [
{
"value1": "={{ $json.allowed }}",
"operation": "isTrue"
}
]
}
},
"id": "guard-check-ig",
"name": "B0 - Guard OK?",
"type": "n8n-nodes-base.if",
"typeVersion": 1,
"position": [
-1600,
-150
]
},
{
"parameters": {
"responseCode": 403,
"responseBody": "={{JSON.stringify({error:'gated_access',reason:$json.reason || 'Instagram channel disabled'})}}",
"options": {}
},
"id": "guard-error-ig",
"name": "RESP - 403 Forbidden",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1,
"position": [
-1400,
150
]
},
{
"parameters": {
"conditions": {
"boolean": [
{
"value1": "={{$json._auth.authOk}}",
"operation": "isTrue"
},
{
"value1": "={{$json._auth.scopeOk}}",
"operation": "isTrue"
}
]
}
},
"id": "aa40547f-8cbe-4f50-adfc-60901838b62b",
"name": "B0 - Token OK?",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
-1920,
0
]
},
{
"parameters": {
"operation": "executeQuery",
"query": "WITH ins AS (\n INSERT INTO idempotency_keys (conversation_key, msg_id, channel)\n VALUES ($1, $2, $3)\n ON CONFLICT DO NOTHING\n RETURNING 1\n )\n SELECT COALESCE((SELECT 1 FROM ins), 0) AS inserted;",
"additionalFields": {
"queryParams": "={{[$json.conversationKey, $json.metadata.msgId, $json.channel]}}"
}
},
"id": "4abbeb0b-274a-43a9-a18f-f0fbff03abe3",
"name": "B0 - Idempotency (DB)",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2,
"position": [
-1700,
-120
]
},
{
"parameters": {
"language": "javascript",
"jsCode": "// P1-01: Idempotency check result + Redis key preparation\nconst inserted = Number($json.inserted || 0);\nconst isNew = inserted === 1;\n\n// Prepare Redis key for future migration (see docs/REDIS_SETUP.md)\nconst redisDedupeKey = `ralphe:dedupe:${$json.channel}:${$json.metadata?.msgId || 'unknown'}`;\nconst redisRateLimitKey = `ralphe:rl:${$json.conversationKey || 'unknown'}`;\n\nreturn [{json: {\n ...$json,\n _sec: {\n ...$json._sec,\n isNew,\n redisDedupeKey,\n redisRateLimitKey\n }\n}}];"
},
"id": "87ff3374-7452-439e-9bcb-eb17ab415112",
"name": "B0 - Idempotency Flag",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-1480,
-120
]
},
{
"parameters": {
"conditions": {
"boolean": [
{
"value1": "={{$json._sec.isNew}}",
"operation": "isTrue"
}
]
}
},
"id": "12e8632c-663f-4c77-9129-b6c85b5886e8",
"name": "B0 - Is New Msg?",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
-1260,
-120
]
},
{
"parameters": {
"operation": "executeQuery",
"query": "WITH ins AS (\n INSERT INTO inbound_messages (conversation_key, msg_id, channel, message_type, text_hash, meta_json)\n VALUES ($1, $2, $3, $4, $5, $6::jsonb)\n ON CONFLICT DO NOTHING\n RETURNING 1\n )\n SELECT COUNT(*)::int AS cnt_30s\n FROM inbound_messages\n WHERE conversation_key = $1\n AND received_at > (now() - interval '30 seconds');",
"additionalFields": {
"queryParams": "={{[$json.conversationKey, $json.metadata.msgId, $json.channel, $json.message.type, $json._sec.textHash, JSON.stringify({ip:$json.metadata.ip,ua:$json.metadata.userAgent})]}}"
}
},
"id": "78d0a464-e4db-4077-aa72-6a0c49051a25",
"name": "B0 - RateLimit + Log",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2,
"position": [
-1040,
-240
]
},
{
"parameters": {
"language": "javascript",
"jsCode": "const limit = Number($env.RATE_LIMIT_PER_30S || 6);\nconst cnt = Number($json.cnt_30s || 0);\nconst allowed = cnt <= limit;\nreturn [{json: {...$json, _sec: {...$json._sec, rateCnt30s: cnt, rateAllowed: allowed}}}];"
},
"id": "42aba848-0f3d-4df2-82fa-347244bb773b",
"name": "B0 - RateLimit Flag",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-820,
-240
]
},
{
"parameters": {
"conditions": {
"boolean": [
{
"value1": "={{$json._sec.rateAllowed}}",
"operation": "isTrue"
}
]
}
},
"id": "3c04aac4-cdc3-412c-ad86-79a8d4b81bdf",
"name": "B0 - Rate OK?",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
-600,
-240
]
},
{
"parameters": {
"operation": "executeQuery",
"query": "SELECT 1 AS quarantined\n FROM conversation_quarantine\n WHERE conversation_key=$1 AND active=true\n AND (expires_at IS NULL OR expires_at > now())\n LIMIT 1;",
"additionalFields": {
"queryParams": "={{[$json.conversationKey]}}"
}
},
"id": "44b92a07-8f1e-4ebf-88f6-b17c468f3f39",
"name": "B0 - Quarantine Check",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2,
"position": [
-380,
-360
]
},
{
"parameters": {
"language": "javascript",
"jsCode": "const q = ($json.quarantined === 1 || $json.quarantined === '1');\nconst notQ = !q;\nreturn [{json: {...$json, _sec: {...$json._sec, notQuarantined: notQ}}}];"
},
"id": "2a4b3d43-64dd-41ee-a089-40d134c315d7",
"name": "B0 - Quarantine Flag",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-160,
-360
]
},
{
"parameters": {
"conditions": {
"boolean": [
{
"value1": "={{$json._sec.notQuarantined}}",
"operation": "isTrue"
}
]
}
},
"id": "9029dc85-465e-48c3-b5ae-1ee551fd30f6",
"name": "B0 - Not Quarantined?",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
60,
-360
]
},
{
"parameters": {
"workflowId": "={{$env.CORE_WORKFLOW_ID}}",
"options": {
"waitTillFinished": false
}
},
"id": "72fec85b-ceac-4952-ad06-38a40db6d289",
"name": "B1 - Execute CORE_AGENT",
"type": "n8n-nodes-base.executeWorkflow",
"typeVersion": 1,
"position": [
300,
-360
]
},
{
"parameters": {
"language": "javascript",
"jsCode": "return [{json:{ok:true}}];"
},
"id": "42371e74-d867-492e-bfb2-89d1f0e0d48a",
"name": "END - Drop/Done",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
520,
0
]
},
{
"parameters": {
"operation": "executeQuery",
"query": "WITH c AS ( SELECT client_id, client_name, tenant_id, restaurant_id, scopes FROM api_clients WHERE is_active=true AND token_hash = $1 LIMIT 1) SELECT (SELECT client_id FROM c) AS client_id, (SELECT client_name FROM c) AS client_name, (SELECT tenant_id FROM c) AS tenant_id, (SELECT restaurant_id FROM c) AS restaurant_id, COALESCE((SELECT scopes FROM c), '[]'::jsonb) AS scopes, EXISTS(SELECT 1 FROM c) AS matched;",
"additionalFields": {
"queryParams": "={{[$json._auth.tokenHash]}}"
}
},
"id": "81e69149-a1b3-4813-a0c2-dbc42a1f12a1",
"name": "B0 - Resolve Client (DB)",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2,
"position": [
280,
-120
]
},
{
"parameters": {
"language": "javascript",
"jsCode": "const e = $json;\nconst matched = !!e.matched;\nconst legacyOk = !!e._auth?.legacySharedValid;\nconst legacyAllowed = (($env.LEGACY_SHARED_ALLOWED || 'false').toString().toLowerCase() === 'true');\n\nlet tenantId = '';\nlet restaurantId = '';\n// P0-04: Meta signature auth for IG (Meta doesn't send x-api-token)\nconst metaSigMode = ($env.META_SIGNATURE_REQUIRED || 'off').toString().toLowerCase();\nconst metaSigValid = (e._auth?.metaSigValid === true);\nconst metaAuthEnabled = (metaSigMode === 'warn' || metaSigMode === 'enforce');\n\nlet authMode = 'deny';\nlet scopes = [];\n\nif (matched && e.tenant_id && e.restaurant_id) {\n tenantId = e.tenant_id.toString();\n restaurantId = e.restaurant_id.toString();\n authMode = 'api_client';\n // scopes may be returned as array or string\n try { scopes = Array.isArray(e.scopes) ? e.scopes : (typeof e.scopes === 'string' ? JSON.parse(e.scopes) : (e.scopes?.scopes || [])); } catch { scopes = []; }\n} else if (metaAuthEnabled && metaSigValid) {\n // P0-04: Meta signature auth - valid signature = authenticated\n tenantId = '00000000-0000-0000-0000-000000000001';\n restaurantId = '00000000-0000-0000-0000-000000000000';\n authMode = 'meta_signature';\n scopes = ['inbound:write'];\n} else if (legacyOk && legacyAllowed) {\n // Legacy fallback to keep backward compatibility (MVP)\n tenantId = '00000000-0000-0000-0000-000000000001';\n restaurantId = '00000000-0000-0000-0000-000000000000';\n authMode = 'legacy_shared';\n scopes = ['legacy_shared'];\n}\n\nconst conversationKey = tenantId ? (tenantId + ':' + restaurantId + ':' + e.channel + ':' + e.userId) : '';\nlet authOk = authMode !== 'deny';\n\n// ---- Scopes enforcement (Release-grade)\n// Required scopes are per-endpoint (here: inbound).\nconst requiredScopes = ['inbound:write'];\n\nfunction hasScope(required, granted) {\n if (!required) return true;\n const g = new Set((granted || []).map(s => String(s || '').trim()).filter(Boolean));\n if (g.has(required)) return true;\n if (g.has('*')) return true;\n const parts = String(required).split(':');\n if (parts.length === 2 && g.has(`${parts[0]}:*`)) return true;\n return false;\n}\n\n// Legacy token can keep inbound compatibility, but MUST NOT be used for admin/internal.\nconst legacyBypass = (authMode === 'legacy_shared') && requiredScopes.some(s => s.startsWith('inbound:'));\nconst scopeOk = authOk && (legacyBypass || requiredScopes.length === 0 || requiredScopes.some(r => hasScope(r, scopes)));\n\nconst endpoint_group = 'inbound';\nconst endpoint_path = '/v1/inbound/instagram';\n\nlet denyReason = authOk ? (scopeOk ? '' : 'SCOPE_DENY') : 'AUTH_DENY';\n\n// Meta signature enforcement (fail-close only if META_SIGNATURE_REQUIRED=enforce)\nconst metaSigRequired = !!e._auth?.metaSigRequired;\nconst metaSigValid = (e._auth?.metaSigValid === true);\nconst metaSigPresent = !!e._auth?.metaSigPresent;\nconst metaSigReason = (e._auth?.metaSigReason || '').toString();\n\n// P0-08: Anti-replay timestamp validation\nconst replayWindowMs = parseInt($env.REPLAY_WINDOW_SECONDS || '300', 10) * 1000;\nconst replayCheckEnabled = (($env.REPLAY_CHECK_ENABLED || 'true').toString().toLowerCase() === 'true');\nlet timestampValid = true;\nlet timestampReason = '';\n\nif (replayCheckEnabled && e.metadata?.timestamp) {\n const msgTs = e.metadata.timestamp;\n let msgTime = 0;\n if (typeof msgTs === 'string' && msgTs.includes('T')) {\n msgTime = new Date(msgTs).getTime();\n } else if (typeof msgTs === 'number' || /^\\\\d+$/.test(msgTs)) {\n const num = Number(msgTs);\n msgTime = num > 9999999999 ? num : num * 1000;\n } else {\n msgTime = new Date(msgTs).getTime();\n }\n const now = Date.now();\n const age = now - msgTime;\n if (isNaN(msgTime) || msgTime <= 0) {\n timestampValid = true;\n timestampReason = 'unparseable';\n } else if (age > replayWindowMs) {\n timestampValid = false;\n timestampReason = 'too_old';\n } else if (age < -60000) {\n timestampValid = false;\n timestampReason = 'future_timestamp';\n } else {\n timestampReason = 'ok';\n }\n}\n\n// hard deny when meta signature is required and invalid/missing\nif (metaSigRequired && !metaSigValid) {\n denyReason = metaSigPresent ? 'IG_SIGNATURE_INVALID' : 'IG_SIGNATURE_MISSING';\n}\n// deny on replay attack\nif (replayCheckEnabled && !timestampValid && timestampReason !== 'unparseable') {\n denyReason = 'REPLAY_ATTACK_' + timestampReason.toUpperCase();\n}\n// deny legacy token if presented but legacy is not allowed\nif (legacyOk && !legacyAllowed) {\n denyReason = 'LEGACY_TOKEN_BLOCKED';\n}\n\nconst tenant_context = {\n tenant_id: tenantId || null,\n restaurant_id: restaurantId || null,\n source: authMode === 'api_client' ? 'auth_db' : (authMode === 'meta_signature' ? 'meta_signature' : (authMode === 'legacy_shared' ? 'legacy_shared' : 'untrusted_payload')),\n client_id: matched ? (e.client_id || null) : null,\n client_name: matched ? (e.client_name || null) : null,\n scopes\n};\n\n\nreturn [{\n json: {\n ...e,\n tenantId,\n restaurantId,\n conversationKey,\n tenant_context,\n _auth: {\n ...e._auth,\n authOk,\n authMode,\n scopes,\n requiredScopes,\n scopeOk,\n endpoint_group,\n endpoint_path,\n denyReason,\n clientId: matched ? (e.client_id || null) : null,\n clientName: matched ? (e.client_name || null) : null\n }\n }\n}];"
},
"id": "1d94ff21-11e5-4aff-b5f9-946d84ab576b",
"name": "B0 - Apply Auth Context",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
480,
-120
]
},
{
"parameters": {
"operation": "executeQuery",
"query": "INSERT INTO security_events(tenant_id, restaurant_id, conversation_key, channel, user_id, event_type, severity, payload_json) VALUES ($1,$2,$3,$4,$5,$6,'HIGH', jsonb_build_object('token_hash',$7,'ip',$8,'ua',$9,'tenant_hint',$10,'restaurant_hint',$11,'auth_mode',$12,'required_scopes',$13::jsonb,'scopes',$14::jsonb,'endpoint_group',$15,'endpoint_path',$16)) RETURNING 1;",
"additionalFields": {
"queryParams": "={{[null, null, null, $json.channel, $json.userId, ($json._auth.denyReason || 'AUTH_DENY'), $json._auth.tokenHash, $json.metadata.ip, $json.metadata.userAgent, $json._auth.tenantHint, $json._auth.restaurantHint, $json._auth.authMode, JSON.stringify($json._auth.requiredScopes || []), JSON.stringify($json._auth.scopes || []), $json._auth.endpoint_group, $json._auth.endpoint_path]}}"
}
},
"id": "a1f32ee6-1585-49b9-b2d1-7dc9d82c252c",
"name": "B0 - Log Deny (DB)",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2,
"position": [
520,
80
]
},
{
"parameters": {
"conditions": {
"boolean": [
{
"value1": "={{$json._contract.isValid && !$json._metaParsing?.isStatusUpdate}}",
"operation": "isTrue"
}
]
}
},
"id": "88a68870-aa47-4c1f-9595-a43d384535e1",
"name": "B0 - Contract Valid?",
"type": "n8n-nodes-base.if",
"typeVersion": 1,
"position": [
-1900,
0
]
},
{
"parameters": {
"operation": "executeQuery",
"query": "INSERT INTO security_events(tenant_id, restaurant_id, conversation_key, channel, user_id, event_type, severity, payload_json) VALUES (NULL,NULL,NULL,$1,$2,'CONTRACT_VALIDATION_FAILED','MEDIUM', jsonb_build_object('contract_version',$3,'schema_hash',$4,'errors',$5,'ip',$6,'ua',$7)) RETURNING 1;",
"additionalFields": {
"queryParams": "={{[$json.channel, $json.userId, $json._contract.version, $json._contract.schemaHash, $json._contract.errors, $json.metadata.ip, $json.metadata.userAgent]}}"
}
},
"id": "224c6d13-b278-4afd-9b90-ffb9c14e6a42",
"name": "B0 - Log Contract Reject (DB)",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2,
"position": [
-1630,
180
]
},
{
"parameters": {
"responseCode": 400,
"responseBody": "={{({error:'invalid_payload', contract_version:$json._contract.version, details:$json._contract.errors})}}",
"options": {}
},
"id": "43ac6a1f-68c2-489c-8dcd-81244756f331",
"name": "RESP - 400 Invalid Payload",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1,
"position": [
-1350,
180
]
},
{
"parameters": {
"responseCode": 200,
"responseBody": "={{JSON.stringify({status:'received',channel:'instagram',msg_id:$json.metadata?.msgId||'unknown',correlation_id:$json._timing?.correlation_id||'unknown'})}}",
"options": {}
},
"id": "dd1694cb-9a55-4ba5-af53-b25dfb79e7a8",
"name": "RESP - 200 OK",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1,
"position": [
780,
0
]
},
{
"parameters": {
"language": "JavaScript",
"jsCode": "const crypto = require('crypto');\nconst e = $json;\nconst ctx = e.tenant_context || {};\nconst secret = ($env.TENANT_CONTEXT_SECRET || 'fallback-secret-6789').toString();\nconst seal = crypto.createHmac('sha256', secret).update(JSON.stringify(ctx)).digest('hex');\nreturn [{json:{...e, tenant_context_seal: seal}}];\n"
},
"id": "43cec757-3619-456c-8d84-451c321b8e62",
"name": "B0 - Seal Tenant Context",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
730,
-300
]
},
{
"parameters": {
"language": "JavaScript",
"jsCode": "const crypto = require('crypto');\nconst e = $json;\nconst ctx = e.tenant_context || {};\nconst secret = ($env.TENANT_CONTEXT_SECRET || 'fallback-secret-6789').toString();\nconst seal = crypto.createHmac('sha256', secret).update(JSON.stringify(ctx)).digest('hex');\nif (e.tenant_context_seal && seal !== e.tenant_context_seal) {\n throw new Error('TENANT_CONTEXT_TAMPERED');\n}\nreturn [{json:e}];\n"
},
"id": "4af52dda-84ce-410a-a5eb-0729f403459d",
"name": "B0 - Verify Tenant Context Seal",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
50,
-540
]
},
{
"parameters": {
"method": "POST",
"url": "http://n8n-main:5678/webhook/v1/internal/audit-write",
"sendBody": true,
"bodyParameters": {
"parameters": [
{
"name": "workflow_name",
"value": "W2_IN_IG"
},
{
"name": "workflow_id",
"value": "W2"
},
{
"name": "execution_id",
"value": "={{ $executionId }}"
},
{
"name": "channel",
"value": "instagram"
},
{
"name": "status",
"value": "started"
},
{
"name": "started_at",
"value": "={{ $now.toISO() }}"
},
{
"name": "correlation_id",
"value": "={{ $json._timing?.correlation_id || '' }}"
}
]
},
"options": {
"timeout": 3000
}
},
"id": "audit-inbound-started-ig",
"name": "AUDIT - Inbound Started",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4,
"position": [
-1700,
-300
],
"continueOnFail": true,
"alwaysOutputData": false
},
{
"parameters": {
"method": "POST",
"url": "http://n8n-main:5678/webhook/v1/internal/audit-write",
"sendBody": true,
"bodyParameters": {
"parameters": [
{
"name": "workflow_name",
"value": "W2_IN_IG"
},
{
"name": "workflow_id",
"value": "W2"
},
{
"name": "execution_id",
"value": "={{ $executionId }}"
},
{
"name": "channel",
"value": "instagram"
},
{
"name": "status",
"value": "completed"
},
{
"name": "completed_at",
"value": "={{ $now.toISO() }}"
},
{
"name": "correlation_id",
"value": "={{ $json._timing?.correlation_id || '' }}"
}
]
},
"options": {
"timeout": 3000
}
},
"id": "audit-inbound-completed-ig",
"name": "AUDIT - Inbound Completed",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4,
"position": [
520,
-360
],
"continueOnFail": true,
"alwaysOutputData": false
}
],
"connections": {
"IN - Webhook": {
"main": [
[
{
"node": "B0 - Parse & Canonicalize",
"type": "main",
"index": 0
}
]
]
},
"B0 - Parse & Canonicalize": {
"main": [
[
{
"node": "B0 - Contract Valid?",
"type": "main",
"index": 0
}
]
]
},
"B0 - Token OK?": {
"main": [
[
{
"node": "B0 - Module Guard",
"type": "main",
"index": 0
}
],
[
{
"node": "B0 - Log Deny (DB)",
"type": "main",
"index": 0
}
]
]
},
"B0 - Module Guard": {
"main": [
[
{
"node": "B0 - Guard OK?",
"type": "main",
"index": 0
}
]
]
},
"B0 - Guard OK?": {
"main": [
[
{
"node": "B0 - Idempotency (DB)",
"type": "main",
"index": 0
}
],
[
{
"node": "RESP - 403 Forbidden",
"type": "main",
"index": 0
}
]
]
},
"B0 - Idempotency (DB)": {
"main": [
[
{
"node": "B0 - Idempotency Flag",
"type": "main",
"index": 0
}
]
]
},
"B0 - Idempotency Flag": {
"main": [
[
{
"node": "B0 - Is New Msg?",
"type": "main",
"index": 0
}
]
]
},
"B0 - Is New Msg?": {
"main": [
[
{
"node": "B0 - RateLimit + Log",
"type": "main",
"index": 0
}
],
[
{
"node": "END - Drop/Done",
"type": "main",
"index": 0
}
]
]
},
"B0 - RateLimit + Log": {
"main": [
[
{
"node": "B0 - RateLimit Flag",
"type": "main",
"index": 0
}
]
]
},
"B0 - RateLimit Flag": {
"main": [
[
{
"node": "B0 - Rate OK?",
"type": "main",
"index": 0
}
]
]
},
"B0 - Rate OK?": {
"main": [
[
{
"node": "B0 - Quarantine Check",
"type": "main",
"index": 0
}
],
[
{
"node": "END - Drop/Done",
"type": "main",
"index": 0
}
]
]
},
"B0 - Quarantine Check": {
"main": [
[
{
"node": "B0 - Quarantine Flag",
"type": "main",
"index": 0
}
]
]
},
"B0 - Quarantine Flag": {
"main": [
[
{
"node": "B0 - Not Quarantined?",
"type": "main",
"index": 0
}
]
]
},
"B0 - Not Quarantined?": {
"main": [
[
{
"node": "B0 - Verify Tenant Context Seal",
"type": "main",
"index": 0
}
],
[
{
"node": "END - Drop/Done",
"type": "main",
"index": 0
}
]
]
},
"B1 - Execute CORE_AGENT": {
"main": [
[
{
"node": "AUDIT - Inbound Completed",
"type": "main",
"index": 0
}
]
]
},
"AUDIT - Inbound Completed": {
"main": [
[
{
"node": "END - Drop/Done",
"type": "main",
"index": 0
}
]
]
},
"B0 - Resolve Client (DB)": {
"main": [
[
{
"node": "B0 - Apply Auth Context",
"type": "main",
"index": 0
}
]
]
},
"B0 - Apply Auth Context": {
"main": [
[
{
"node": "B0 - Seal Tenant Context",
"type": "main",
"index": 0
}
]
]
},
"B0 - Log Deny (DB)": {
"main": [
[
{
"node": "END - Drop/Done",
"type": "main",
"index": 0
}
]
]
},
"B0 - Contract Valid?": {
"main": [
[
{
"node": "B0 - Resolve Client (DB)",
"type": "main",
"index": 0
},
{
"node": "AUDIT - Inbound Started",
"type": "main",
"index": 0
}
],
[
{
"node": "B0 - Log Contract Reject (DB)",
"type": "main",
"index": 0
}
]
]
},
"B0 - Log Contract Reject (DB)": {
"main": [
[
{
"node": "RESP - 400 Invalid Payload",
"type": "main",
"index": 0
}
]
]
},
"RESP - 400 Invalid Payload": {
"main": [
[]
]
},
"END - Drop/Done": {
"main": [
[
{
"node": "RESP - 200 OK",
"type": "main",
"index": 0
}
]
]
},
"RESP - 200 OK": {
"main": [
[]
]
},
"B0 - Seal Tenant Context": {
"main": [
[
{
"node": "B0 - Token OK?",
"type": "main",
"index": 0
}
]
]
},
"B0 - Verify Tenant Context Seal": {
"main": [
[
{
"node": "B1 - Execute CORE_AGENT",
"type": "main",
"index": 0
}
]
]
}
}
}
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
W2 - IN Instagram Adapter (Secure). Uses postgres, httpRequest. Webhook trigger; 28 nodes.
Source: https://github.com/zerAda/RestaurantAgentAutomation/blob/41a4d42dcd66e57b1e87b4750c0fd5fbf7058f68/workflows/W2_IN_IG.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
W1 - IN WhatsApp Adapter (Secure + Fast ACK). Uses postgres, redis, httpRequest. Webhook trigger; 48 nodes.
W3 - IN Messenger Adapter (Secure). Uses postgres, httpRequest. Webhook trigger; 28 nodes.
Engagement Tracking Workflow. Uses postgres, httpRequest. Webhook trigger; 22 nodes.
Content Review Loop Workflow. Uses postgres, httpRequest. Webhook trigger; 20 nodes.