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 →
{
"id": "resinCostWatch01",
"name": "Resin Cost Watch: daily material cost signal",
"active": false,
"nodes": [
{
"id": "manual",
"name": "Run manually",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [
-320,
460
],
"parameters": {}
},
{
"id": "trigger",
"name": "Every morning",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [
-320,
300
],
"parameters": {
"rule": {
"interval": [
{
"field": "cronExpression",
"expression": "0 8 * * *"
}
]
}
}
},
{
"id": "sources",
"name": "Build source list",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-100,
300
],
"parameters": {
"mode": "runOnceForAllItems",
"jsCode": "// n8n Code node: Build source list\n//\n// Out: one item per cost driver, carrying the URL to fetch. The HTTP Request node that\n// follows runs once per item.\n//\n// The list lives here rather than in the cost_driver table on purpose. The table holds WHAT\n// is tracked and how much it weighs \u2014 a business definition. This file holds WHERE the number\n// comes from \u2014 a fetching detail. Keeping them apart means swapping a data source never\n// touches the database.\n\nconst YF = 'https://query1.finance.yahoo.com/v8/finance/chart';\n\nreturn [\n {\n json: {\n code: 'USDIDR',\n // Bank Indonesia JISDOR. PRIMARY source: this is the official reference rate that\n // Indonesian companies book against. The page is HTML, not JSON, so it has to be\n // parsed out of a table.\n url: 'https://www.bi.go.id/id/statistik/informasi-kurs/jisdor/Default.aspx',\n parser: 'jisdor',\n tier: 'primary',\n },\n },\n {\n json: {\n code: 'BRENT',\n // Brent crude. SECONDARY source: Yahoo aggregates from exchanges and its interface is\n // undocumented, so it can change without notice. Used because a free daily official\n // price is not available, and its second-hand status is recorded openly.\n url: `${YF}/BZ=F?range=6mo&interval=1d`,\n parser: 'yahoo',\n tier: 'secondary',\n },\n },\n {\n json: {\n code: 'NATGAS',\n url: `${YF}/NG=F?range=6mo&interval=1d`,\n parser: 'yahoo',\n tier: 'secondary',\n },\n },\n {\n json: {\n code: 'TPIA',\n // Chandra Asri Pacific, the largest resin producer in Indonesia. Used as a read on\n // domestic supplier conditions, NOT as a resin price. A share price is not a material\n // price, and that distinction is spelled out in the README.\n url: `${YF}/TPIA.JK?range=6mo&interval=1d`,\n parser: 'yahoo',\n tier: 'secondary',\n },\n },\n];\n"
}
},
{
"id": "fetch",
"name": "Fetch source",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
120,
300
],
"alwaysOutputData": true,
"onError": "continueRegularOutput",
"parameters": {
"url": "={{ $json.url }}",
"options": {
"response": {
"response": {
"neverError": true,
"responseFormat": "text"
}
},
"timeout": 30000
},
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "User-Agent",
"value": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36"
},
{
"name": "Accept",
"value": "application/json, text/html"
}
]
}
}
},
{
"id": "parse",
"name": "Parse readings",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
340,
300
],
"parameters": {
"mode": "runOnceForAllItems",
"jsCode": "// n8n Code node: Parse readings\n//\n// In: the HTTP Request results, paired by index with the items from \"Build source list\".\n// Out: one item per successful reading, plus an ok:false item for each source that failed.\n//\n// ONE FAILING SOURCE DOES NOT KILL THE RUN. If Yahoo is down but Bank Indonesia is up, the\n// index is still computed from what is available and the driver count is recorded. A pipeline\n// that stops completely because one upstream site is having a bad morning is no use in daily\n// operations.\n\nconst sources = $('Build source list').all().map((i) => i.json);\nconst responses = $input.all();\nconst out = [];\n\n// Indonesian month names, for parsing JISDOR dates like \"3 Agustus 2026\".\nconst BULAN = {\n januari: 1, februari: 2, maret: 3, april: 4, mei: 5, juni: 6,\n juli: 7, agustus: 8, september: 9, oktober: 10, november: 11, desember: 12,\n};\n\nfunction isoDate(y, m, d) {\n return `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`;\n}\n\nfunction parseJisdor(html) {\n const table = html.match(/<table[^>]*>([\\s\\S]*?)<\\/table>/);\n if (!table) throw new Error('JISDOR table not found on the page');\n\n const rows = table[1].match(/<tr[^>]*>[\\s\\S]*?<\\/tr>/g) || [];\n const readings = [];\n\n for (const row of rows) {\n const cells = (row.match(/<t[dh][^>]*>([\\s\\S]*?)<\\/t[dh]>/g) || [])\n .map((c) => c.replace(/<[^>]+>/g, '').replace(/ /g, ' ').trim())\n .filter(Boolean);\n if (cells.length < 2) continue;\n\n // \"3 Agustus 2026\"\n const dm = cells[0].match(/^(\\d{1,2})\\s+([A-Za-z]+)\\s+(\\d{4})$/);\n if (!dm) continue;\n const bulan = BULAN[dm[2].toLowerCase()];\n if (!bulan) continue;\n\n // \"Rp17.991,00\": dot separates thousands, comma separates decimals.\n const nm = cells[1].replace(/[^\\d.,]/g, '').replace(/\\./g, '').replace(',', '.');\n const value = Number(nm);\n if (!Number.isFinite(value) || value <= 0) continue;\n\n readings.push({ observed_on: isoDate(dm[3], bulan, dm[1]), value });\n }\n\n if (readings.length === 0) throw new Error('JISDOR table parsed but no valid rows found');\n return readings;\n}\n\nfunction parseYahoo(raw) {\n const d = typeof raw === 'string' ? JSON.parse(raw) : raw;\n const r = d?.chart?.result?.[0];\n if (!r) throw new Error('Yahoo response shape changed, chart.result is empty');\n\n const stamps = r.timestamp || [];\n const closes = r.indicators?.quote?.[0]?.close || [];\n const readings = [];\n\n for (let i = 0; i < stamps.length; i++) {\n const v = closes[i];\n // Market holidays return null. Not an error, just no trading that day.\n if (v === null || v === undefined || !Number.isFinite(v)) continue;\n readings.push({\n observed_on: new Date(stamps[i] * 1000).toISOString().slice(0, 10),\n value: Math.round(v * 10000) / 10000,\n });\n }\n\n if (readings.length === 0) throw new Error('no valid closing prices in this range');\n return readings;\n}\n\nfor (let i = 0; i < sources.length; i++) {\n const src = sources[i];\n const res = responses[i]?.json;\n\n try {\n if (!res) throw new Error('no response from the HTTP Request node');\n const body = res.data ?? res.body ?? res;\n const readings = src.parser === 'jisdor' ? parseJisdor(String(body)) : parseYahoo(body);\n\n // Every reading is emitted, not just the latest. A single run then backfills the whole\n // range the source offers, which is why one run produces six months of history.\n for (const r of readings) {\n out.push({ json: { ok: true, code: src.code, tier: src.tier, ...r } });\n }\n } catch (e) {\n out.push({ json: { ok: false, code: src.code, tier: src.tier, reason: e.message } });\n }\n}\n\nreturn out;\n"
}
},
{
"id": "keepOk",
"name": "Reading valid?",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
560,
300
],
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "loose",
"version": 2
},
"combinator": "and",
"conditions": [
{
"id": "ok",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
},
"leftValue": "={{ $json.ok }}",
"rightValue": ""
}
]
},
"options": {}
}
},
{
"id": "saveReading",
"name": "Save reading",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.5,
"position": [
800,
200
],
"credentials": {
"postgres": {
"name": "<your credential>"
}
},
"parameters": {
"operation": "executeQuery",
"query": "INSERT INTO driver_reading (driver_id, observed_on, value)\nSELECT d.id, $2::date, $3::numeric\nFROM cost_driver d\nWHERE d.code = $1 AND d.is_active\nON CONFLICT (driver_id, observed_on) DO UPDATE SET\n value = EXCLUDED.value,\n fetched_at = now();",
"options": {
"queryReplacement": "={{ [$json.code, $json.observed_on, $json.value] }}"
}
}
},
{
"id": "logSkip",
"name": "Log unreadable source",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.5,
"position": [
800,
420
],
"credentials": {
"postgres": {
"name": "<your credential>"
}
},
"parameters": {
"operation": "executeQuery",
"query": "INSERT INTO run_log (stage, status, detail) VALUES ('fetch', 'failed', $1);",
"options": {
"queryReplacement": "={{ [$json.code + ': ' + $json.reason] }}"
}
}
},
{
"id": "loadDrivers",
"name": "Load drivers",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.5,
"position": [
1040,
200
],
"credentials": {
"postgres": {
"name": "<your credential>"
}
},
"parameters": {
"operation": "executeQuery",
"query": "SELECT code, name, weight, is_active FROM cost_driver WHERE is_active ORDER BY weight DESC;",
"options": {}
},
"executeOnce": true
},
{
"id": "loadHistory",
"name": "Load history",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.5,
"position": [
1260,
200
],
"credentials": {
"postgres": {
"name": "<your credential>"
}
},
"parameters": {
"operation": "executeQuery",
"query": "SELECT d.code, r.observed_on, r.value\nFROM driver_reading r\nJOIN cost_driver d ON d.id = r.driver_id\nWHERE d.is_active AND r.observed_on >= (CURRENT_DATE - INTERVAL '400 days')\nORDER BY d.code, r.observed_on;",
"options": {}
},
"executeOnce": true
},
{
"id": "compute",
"name": "Compute index",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1480,
200
],
"parameters": {
"mode": "runOnceForAllItems",
"jsCode": "// n8n Code node: Compute cost index\n//\n// In: the full reading history from driver_reading, and the driver definitions with their\n// weights from cost_driver.\n// Out: one item holding today's index, its changes, an alert level, and a summary a\n// non-technical reader can act on.\n//\n// HOW THE INDEX WORKS\n// Deliberately simple. Each driver's value is divided by its value on a base date, multiplied\n// by its weight, summed, and scaled by 100. The base date is the earliest date on which EVERY\n// active driver has a reading, so the comparison is like for like. An index of 108 means the\n// cost drivers combined sit 8 percent above that base.\n//\n// THREE ASSUMPTIONS, STATED OPENLY rather than buried:\n//\n// 1. DIRECTION. Every driver is treated as moving cost the same way: up means cost pressure\n// rises. For oil, gas and the exchange rate that is direct. For Chandra Asri it is not:\n// its share price tends to rise when resin margins widen, which happens when resin\n// outruns its own feedstock. Long chain, so it carries the smallest weight of the four.\n//\n// 2. WEIGHTS. Brent 0.35, exchange rate 0.35, Chandra Asri 0.20, gas 0.10. Reasoned starting\n// points, NOT calibrated against resin purchase invoices. How to fix it is written in the\n// README: compare index movement against actual purchase prices, then reset the weights.\n//\n// 3. ALERT THRESHOLDS. 2 percent for watch and 5 percent for act over 30 days. Starting\n// points as well, not derived from real cost data.\n//\n// Saying all three out loud is more useful than presenting the numbers as settled. An index\n// that admits its assumptions can be improved; one that hides them cannot.\n\nconst drivers = $('Load drivers').all().map((i) => i.json);\nconst rows = $('Load history').all().map((i) => i.json);\n\n// --- group history by driver ---\nconst byCode = {};\nfor (const r of rows) {\n const code = r.code;\n if (!byCode[code]) byCode[code] = [];\n byCode[code].push({ on: String(r.observed_on).slice(0, 10), value: Number(r.value) });\n}\nfor (const code of Object.keys(byCode)) {\n byCode[code].sort((a, b) => a.on.localeCompare(b.on));\n}\n\nconst active = drivers.filter((d) => d.is_active !== false && byCode[d.code]?.length);\nconst skipped = drivers.filter((d) => !byCode[d.code]?.length).map((d) => d.code);\n\nif (active.length === 0) {\n return [{ json: { ok: false, reason: 'no driver has any readings' } }];\n}\n\n// --- base date: the earliest date EVERY active driver has ---\nconst earliestPerDriver = active.map((d) => byCode[d.code][0].on);\nconst baseDate = earliestPerDriver.sort().reverse()[0];\n\n/** Value on a given date, or the last reading before it. */\nfunction valueOn(code, dateStr) {\n const series = byCode[code];\n let found = null;\n for (const p of series) {\n if (p.on <= dateStr) found = p;\n else break;\n }\n return found ? found.value : null;\n}\n\nfunction latest(code) {\n const s = byCode[code];\n return s[s.length - 1];\n}\n\nfunction daysAgo(n) {\n const d = new Date(latest(active[0].code).on + 'T00:00:00Z');\n d.setUTCDate(d.getUTCDate() - n);\n return d.toISOString().slice(0, 10);\n}\n\n/**\n * Weighted index on a given date, relative to the base date.\n * Returns the value ALONG WITH which drivers were actually used, because two index values are\n * only comparable when built from the same set of drivers.\n */\nfunction indexOn(dateStr) {\n let total = 0;\n let weightUsed = 0;\n const used = [];\n for (const d of active) {\n const now = valueOn(d.code, dateStr);\n const base = valueOn(d.code, baseDate);\n if (now === null || base === null || base === 0) continue;\n total += Number(d.weight) * (now / base);\n weightUsed += Number(d.weight);\n used.push(d.code);\n }\n // Weights are renormalised so a driver with missing data does not drag the index down as if\n // its value were zero.\n if (weightUsed === 0) return null;\n return { value: (total / weightUsed) * 100, used: used.sort().join(',') };\n}\n\nconst today = latest(active[0].code).on;\nconst nowIdx = indexOn(today);\nconst indexNow = nowIdx ? nowIdx.value : null;\n\n/**\n * Index change over n days.\n *\n * Returns null when the driver composition DIFFERS from today. The first version did not check\n * this, and ended up comparing a four-driver index today against a three-driver index ninety\n * days ago, producing minus 40.7 percent. That is not cost movement, that is composition. This\n * is the easiest kind of mistake to miss, because the result is still a number that looks\n * perfectly reasonable.\n */\nfunction changeOver(days) {\n const past = indexOn(daysAgo(days));\n if (!past || !nowIdx || past.value === 0) return null;\n if (past.used !== nowIdx.used) return null;\n return Math.round(((nowIdx.value - past.value) / past.value) * 1000) / 10;\n}\n\nconst change7 = changeOver(7);\nconst change30 = changeOver(30);\nconst change90 = changeOver(90);\n\n// --- alert level ---\n// The 30-day window is the main reference. When history is not long enough yet, this must NOT\n// treat missing data as zero. The first version did exactly that (`change30 ?? 0`) and reported\n// \"bergerak 0%\" when the honest answer was \"not known yet\". A fabricated zero is more dangerous\n// than admitting insufficient data, because whoever reads it concludes costs are calm.\nlet window = null;\nlet driverChange = null;\nif (change30 !== null) { window = 30; driverChange = change30; }\nelse if (change7 !== null) { window = 7; driverChange = change7; }\n\nlet level;\nif (driverChange === null) level = 'insufficient-history';\nelse if (driverChange >= 5) level = 'act';\nelse if (driverChange >= 2) level = 'watch';\nelse level = 'stable';\n\n// --- human-readable summary ---\nconst parts = [];\nfor (const d of active) {\n const l = latest(d.code);\n const past = window === null ? null : valueOn(d.code, daysAgo(window));\n const pct = past && past !== 0 ? Math.round(((l.value - past) / past) * 1000) / 10 : null;\n const arah = pct === null\n ? 'riwayat belum cukup untuk membandingkan'\n : pct >= 0 ? `naik ${pct}% dalam ${window} hari` : `turun ${Math.abs(pct)}% dalam ${window} hari`;\n parts.push(`${d.name}: ${l.value} (${arah})`);\n}\n\nconst headline =\n level === 'insufficient-history'\n ? 'Riwayat belum cukup panjang untuk mengukur pergerakan. Angka akan bermakna setelah alur berjalan beberapa hari.'\n : level === 'act'\n ? `Biaya bahan bergerak ${driverChange}% dalam ${window} hari. Tinjau penawaran yang berlaku lebih dari 60 hari.`\n : level === 'watch'\n ? `Biaya bahan bergerak ${driverChange}% dalam ${window} hari. Belum mendesak, tetapi perlu diawasi.`\n : `Biaya bahan bergerak ${driverChange}% dalam ${window} hari. Tidak ada tindakan yang diperlukan.`;\n\nconst notes = [];\nif (skipped.length) notes.push(`Tidak terbaca hari ini: ${skipped.join(', ')}.`);\nnotes.push(`Tanggal acuan indeks: ${baseDate}.`);\nif (window !== null && window < 30) {\n notes.push(`Perbandingan memakai jendela ${window} hari, bukan 30, karena riwayatnya belum sepanjang itu.`);\n}\n\nreturn [{\n json: {\n ok: true,\n computed_on: today,\n index_value: Math.round(indexNow * 100) / 100,\n change_7d: change7,\n change_30d: change30,\n change_90d: change90,\n level,\n drivers_used: active.length,\n summary: [headline, ...parts, ...notes].join('\\n'),\n },\n}];\n"
}
},
{
"id": "saveSignal",
"name": "Save signal",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.5,
"position": [
1700,
200
],
"credentials": {
"postgres": {
"name": "<your credential>"
}
},
"parameters": {
"operation": "executeQuery",
"query": "INSERT INTO cost_signal\n (computed_on, index_value, change_7d, change_30d, change_90d,\n level, drivers_used, summary)\nVALUES ($1::date, $2, $3, $4, $5, $6, $7, $8)\nON CONFLICT (computed_on) DO UPDATE SET\n index_value = EXCLUDED.index_value,\n change_7d = EXCLUDED.change_7d,\n change_30d = EXCLUDED.change_30d,\n change_90d = EXCLUDED.change_90d,\n level = EXCLUDED.level,\n drivers_used = EXCLUDED.drivers_used,\n summary = EXCLUDED.summary,\n computed_at = now()\nRETURNING computed_on, index_value, level;",
"options": {
"queryReplacement": "={{ [$json.computed_on, $json.index_value, $json.change_7d, $json.change_30d, $json.change_90d, $json.level, $json.drivers_used, $json.summary] }}"
}
}
},
{
"id": "logOk",
"name": "Log run",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.5,
"position": [
1920,
200
],
"credentials": {
"postgres": {
"name": "<your credential>"
}
},
"parameters": {
"operation": "executeQuery",
"query": "INSERT INTO run_log (stage, status, detail) VALUES ('compute', 'ok', $1);",
"options": {
"queryReplacement": "={{ [$('Compute index').first().json.level + ' | index ' + $('Compute index').first().json.index_value] }}"
}
}
}
],
"connections": {
"Every morning": {
"main": [
[
{
"node": "Build source list",
"type": "main",
"index": 0
}
]
]
},
"Run manually": {
"main": [
[
{
"node": "Build source list",
"type": "main",
"index": 0
}
]
]
},
"Build source list": {
"main": [
[
{
"node": "Fetch source",
"type": "main",
"index": 0
}
]
]
},
"Fetch source": {
"main": [
[
{
"node": "Parse readings",
"type": "main",
"index": 0
}
]
]
},
"Parse readings": {
"main": [
[
{
"node": "Reading valid?",
"type": "main",
"index": 0
}
]
]
},
"Reading valid?": {
"main": [
[
{
"node": "Save reading",
"type": "main",
"index": 0
}
],
[
{
"node": "Log unreadable source",
"type": "main",
"index": 0
}
]
]
},
"Save reading": {
"main": [
[
{
"node": "Load drivers",
"type": "main",
"index": 0
}
]
]
},
"Load drivers": {
"main": [
[
{
"node": "Load history",
"type": "main",
"index": 0
}
]
]
},
"Load history": {
"main": [
[
{
"node": "Compute index",
"type": "main",
"index": 0
}
]
]
},
"Compute index": {
"main": [
[
{
"node": "Save signal",
"type": "main",
"index": 0
}
]
]
},
"Save signal": {
"main": [
[
{
"node": "Log run",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1",
"saveManualExecutions": true
},
"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.
postgres
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
Resin Cost Watch: daily material cost signal. Uses httpRequest, postgres. Event-driven trigger; 13 nodes.
Source: https://github.com/lixfeyzen/resin-cost-watch/blob/main/workflow/resin-cost-watch.workflow.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.
Reagendamiento_v2. Uses executeWorkflowTrigger, redis, httpRequest, n8n-nodes-evolution-api. Event-driven trigger; 89 nodes.
This workflow acts as a junior finance research analyst for a UK boutique M&A or corporate finance team. It listens for Slack messages, classifies the request, gathers company or market data, and prod
Agendamiento_v2. Uses n8n-nodes-evolution-api, redis, httpRequest, executeWorkflowTrigger. Event-driven trigger; 59 nodes.
Cancelacion_v2. Uses executeWorkflowTrigger, redis, httpRequest, n8n-nodes-evolution-api. Event-driven trigger; 46 nodes.
04_dados_de_teste. Uses postgres, httpRequest. Event-driven trigger; 41 nodes.