n8n Integration
A complete evaluator built in n8n, using only the built-in HTTP Request node. Nothing to install — it runs on n8n Cloud as-is.
The workflow
Nine nodes. Three call dakkio, two reshape items, one does your maths.
Before you start
You need three things:
- A publicly reachable dakkio API. n8n Cloud cannot reach
localhost— deploy, or tunnel withcloudflared/ngrok. - An integration key (
dakkio_i_…) from the dashboard. - At least one active rule with an
evaluationblock. A rule without one is invisible to the due endpoint.
Create the credential
Credentials → New → Header Auth
| Field | Value |
|---|---|
| Name | X-API-Key |
| Value | your dakkio_i_… key |
Using a credential rather than a Set node keeps the key out of the workflow JSON and out of every execution log.
Step 1 — Schedule Trigger
Every 15 Minutes.
Polling more often than rules become due is harmless: due-ness is decided server-side, so extra polls simply return an empty array. Pick a cadence at or below your shortest intervalSeconds.
Node type: n8n-nodes-base.scheduleTrigger (typeVersion 1.2)
{
"rule": {
"interval": [
{
"field": "minutes",
"minutesInterval": 15
}
]
}
}
Step 2 — Config
A Set node holding non-secret configuration:
| Field | Value |
|---|---|
dakkioHost | https://api.dakkio.io |
Downstream nodes reference it as {{ $('Config').first().json.dakkioHost }}, so moving environments is a one-node change.
Values in a Set node are stored in the workflow JSON and echoed into every execution log. The key belongs in the credential.
Node type: n8n-nodes-base.set (typeVersion 3.4)
{
"assignments": {
"assignments": [
{
"id": "baseurl",
"name": "dakkioHost",
"value": "https://api.dakkio.io",
"type": "string"
}
]
},
"options": {}
}
Step 3 — Get Due Rules
HTTP Request
| Setting | Value |
|---|---|
| Method | GET |
| URL | {{ $json.dakkioHost }}/api/evaluations/due |
| Authentication | Generic → Header Auth → your credential |
Returns { count, evaluations: [...] }. When nothing is due, evaluations is empty and the rest of the workflow simply does not run.
Node type: n8n-nodes-base.httpRequest (typeVersion 4.2)
{
"method": "GET",
"url": "={{ $json.dakkioHost }}/api/evaluations/due",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "limit",
"value": "100"
}
]
},
"options": {}
}
The credential is attached separately, under credentials.httpHeaderAuth — it is never part of parameters.
Step 4 — Split Rules
Split Out
| Setting | Value |
|---|---|
| Fields To Split Out | evaluations |
| Include | No Other Fields |
One item per due rule. The only sibling field is count, which nothing needs.
Node type: n8n-nodes-base.splitOut (typeVersion 1)
{
"fieldToSplitOut": "evaluations",
"options": {}
}
No include key means "No Other Fields" — the default.
Step 5 — Split Inputs
Split Out
| Setting | Value |
|---|---|
| Fields To Split Out | inputs |
| Include | All Other Fields ⚠️ |
One item per series the rule needs.
By default, Split Out emits only the split field's contents. ruleId, kind and parameters would be discarded here — and Submit Verdict, five nodes later, would have no rule id to build its URL from.
This is the single most common way to get this workflow subtly wrong.
Node type: n8n-nodes-base.splitOut (typeVersion 1)
{
"fieldToSplitOut": "inputs",
"include": "allOtherFields",
"options": {}
}
include: "allOtherFields" is the setting that carries ruleId, kind and parameters through.
Step 6 — Query Series
HTTP Request
| Setting | Value |
|---|---|
| Method | POST |
| URL | {{ $('Config').first().json.dakkioHost }}/api/data/query |
| Send Body | on, JSON |
| JSON | {{ JSON.stringify($json.inputs ? $json.inputs.query : $json.query) }} |
dakkio already built that query object with absolute time bounds. You pass it through untouched.
inputsSplit Out either nests the array element under inputs or spreads its fields onto the item root, depending on version and the Include setting. The ternary handles both, so the workflow imports and runs either way — check the node's input panel if you want to know which yours does.
The response looks like:
{ "data": [ { "time": "…", "dataSourceId": "…", "values": { "weight": 48.06 } } ] }
values[field]Not value, not _value. Reading the wrong key yields an empty series, and the rule then returns inconclusive forever while looking perfectly healthy — no error anywhere.
Node type: n8n-nodes-base.httpRequest (typeVersion 4.2)
{
"method": "POST",
"url": "={{ $('Config').first().json.dakkioHost }}/api/data/query",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify($json.inputs ? $json.inputs.query : $json.query) }}",
"options": {}
}
Step 7 — Merge
Merge, mode Combine → By Position.
Connect Split Inputs → Merge input 1 directly, as well as through Query Series → Merge input 2.
The HTTP Request node replaces the item JSON wholesale. Without re-attaching the context, the query response arrives with no idea which rule it belongs to, and Evaluate emits nothing at all.
Node type: n8n-nodes-base.merge (typeVersion 3)
{
"mode": "combine",
"combineBy": "combineByPosition",
"options": {}
}
Wiring matters as much as parameters — see the connection map at the end of this page.
Step 8 — Evaluate
Code, mode Run Once for All Items.
// Input: one item per (rule x input). Output: exactly ONE item per rule.
const byRule = {};
for (const item of $input.all()) {
const j = item.json;
if (!j.ruleId) continue;
// Tolerate both Split Out shapes.
const input = j.inputs && !Array.isArray(j.inputs) ? j.inputs : j;
const key = input.key;
const field = input.field;
if (!key || !field) continue;
byRule[j.ruleId] ??= {
ruleId: j.ruleId, name: j.name, kind: j.kind,
parameters: j.parameters || {}, series: {},
};
// The number lives at values[field]. If "Include Response Headers" is on,
// the payload sits under .body instead.
const rows = j.body?.data ?? j.data ?? [];
byRule[j.ruleId].series[key] = rows
.map((r) => (r?.values ? r.values[field] : r?.value))
.filter((v) => typeof v === 'number' && !Number.isNaN(v));
}
const spread = (xs) => (xs.length ? Math.max(...xs) - Math.min(...xs) : 0);
const round = (n) => Math.round(n * 100) / 100;
const out = [];
for (const rule of Object.values(byRule)) {
const p = rule.parameters;
const s = rule.series;
let outcome = 'inconclusive';
let evidence = {};
if (rule.kind === 'threshold') {
const v = s.v ?? [];
if (v.length === 0) {
evidence = { reason: 'no samples in window' };
} else {
const latest = v[v.length - 1];
const t = Number(p.threshold);
const op = String(p.operator ?? '>');
const cmp = { '>': latest > t, '<': latest < t, '>=': latest >= t, '<=': latest <= t };
outcome = cmp[op] ? 'met' : 'not_met';
evidence = { latest: round(latest), threshold: t, operator: op, samples: v.length };
}
} else if (rule.kind === 'thermal-band') {
const int = s.int ?? [];
const ext = s.ext ?? [];
if (int.length === 0 || ext.length === 0) {
evidence = { reason: 'missing samples' };
} else if (spread(ext) < Number(p.minExtSpread)) {
// THE GATE. Uninculsive, never not_met - see below.
evidence = { reason: 'external variation below gate', extSpread: round(spread(ext)) };
} else {
const held = int.every((v) => v >= Number(p.bandMin) && v <= Number(p.bandMax));
outcome = held ? 'met' : 'not_met';
evidence = {
extSpread: round(spread(ext)), intSpread: round(spread(int)),
intMin: round(Math.min(...int)), intMax: round(Math.max(...int)),
samples: int.length,
};
}
} else {
// Unknown kind: hold state rather than guess.
evidence = { reason: `no evaluator for kind "${rule.kind}"` };
}
out.push({ json: { ruleId: rule.ruleId, name: rule.name, kind: rule.kind, outcome, evidence } });
}
return out;
Three things this code is careful about:
It regroups by ruleId. A two-input rule produces two items; emitting a verdict per item would submit twice, and the second would come back 409.
The gate returns inconclusive. Reporting not_met when the window is uninformative would start the clear-dwell timer and eventually re-arm the rule on no evidence at all.
Unknown kinds hold state. Adding a rule type in dakkio can never make this workflow produce a wrong verdict.
Node type: n8n-nodes-base.code (typeVersion 2)
{
"mode": "runOnceForAllItems",
"jsCode": "<the Code above>"
}
Step 9 — Submit Verdict
HTTP Request
| Setting | Value |
|---|---|
| Method | POST |
| URL | {{ $('Config').first().json.dakkioHost }}/api/evaluations/{{ $json.ruleId }}/verdict |
| Send Body | on, JSON |
| JSON | {{ JSON.stringify({ outcome: $json.outcome, evidence: $json.evidence }) }} |
| Settings → On Error | Continue |
A 409 means a concurrent or duplicate verdict already landed. It is benign, and it must not abort the run for every other rule in the batch.
Node type: n8n-nodes-base.httpRequest (typeVersion 4.2)
{
"method": "POST",
"url": "={{ $('Config').first().json.dakkioHost }}/api/evaluations/{{ $json.ruleId }}/verdict",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ outcome: $json.outcome, evidence: $json.evidence }) }}",
"options": {}
}
The onError: "continueRegularOutput" sits on the node itself, alongside parameters, not inside it.
Connection map
Parameters alone are not enough — Split Inputs feeds two nodes, and that fan-out is what makes the Merge work.
| From | To |
|---|---|
Every 15 Minutes | Config |
Config | Get Due Rules |
Get Due Rules | Split Rules |
Split Rules | Split Inputs |
Split Inputs | Query Series, Merge Context |
Query Series | Merge Context |
Merge Context | Evaluate |
Evaluate | Submit Verdict |
Split Inputs → Merge Context lands on input 1; Query Series → Merge Context lands on input 2. Reversing them overwrites the rule context with the query response, which is the failure this node exists to prevent.
Complete workflow
Import this directly: Workflows → Import from File, or paste onto the canvas.
Full workflow JSON — click to expand
{
"name": "dakkio evaluator (HTTP)",
"nodes": [
{
"parameters": {
"rule": {
"interval": [
{
"field": "minutes",
"minutesInterval": 15
}
]
}
},
"id": "every-15-minutes",
"name": "Every 15 Minutes",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [
-220,
300
]
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "baseurl",
"name": "dakkioHost",
"value": "https://api.dakkio.io",
"type": "string"
}
]
},
"options": {}
},
"id": "config",
"name": "Config",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
0,
300
]
},
{
"parameters": {
"method": "GET",
"url": "={{ $json.dakkioHost }}/api/evaluations/due",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "limit",
"value": "100"
}
]
},
"options": {}
},
"id": "get-due-rules",
"name": "Get Due Rules",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
220,
300
],
"credentials": {
"httpHeaderAuth": {
"id": "REPLACE_ME",
"name": "dakkio API key"
}
}
},
{
"parameters": {
"fieldToSplitOut": "evaluations",
"options": {}
},
"id": "split-rules",
"name": "Split Rules",
"type": "n8n-nodes-base.splitOut",
"typeVersion": 1,
"position": [
440,
300
]
},
{
"parameters": {
"fieldToSplitOut": "inputs",
"include": "allOtherFields",
"options": {}
},
"id": "split-inputs",
"name": "Split Inputs",
"type": "n8n-nodes-base.splitOut",
"typeVersion": 1,
"position": [
660,
300
]
},
{
"parameters": {
"method": "POST",
"url": "={{ $('Config').first().json.dakkioHost }}/api/data/query",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify($json.inputs ? $json.inputs.query : $json.query) }}",
"options": {}
},
"id": "query-series",
"name": "Query Series",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
880,
400
],
"credentials": {
"httpHeaderAuth": {
"id": "REPLACE_ME",
"name": "dakkio API key"
}
}
},
{
"parameters": {
"mode": "combine",
"combineBy": "combineByPosition",
"options": {}
},
"id": "merge-context",
"name": "Merge Context",
"type": "n8n-nodes-base.merge",
"typeVersion": 3,
"position": [
1100,
300
]
},
{
"parameters": {
"mode": "runOnceForAllItems",
"jsCode": "// Runs ONCE FOR ALL ITEMS.\n//\n// Input: one item per (rule x input), each carrying the rule context that\n// survived the Split Out plus the series returned by Query Series.\n// Output: exactly ONE item per rule. This regrouping is what stops a\n// two-input rule from submitting two verdicts and eating a 409.\n\nconst byRule = {};\n\nfor (const item of $input.all()) {\n const j = item.json;\n if (!j.ruleId) continue;\n\n if (!byRule[j.ruleId]) {\n byRule[j.ruleId] = {\n ruleId: j.ruleId,\n name: j.name,\n kind: j.kind,\n parameters: j.parameters || {},\n series: {},\n };\n }\n\n // Split Out either spreads the array element's fields onto the item root or\n // keeps them nested under the original field name, depending on version and\n // the Include setting. Accept both rather than depending on which.\n const input =\n j.inputs && typeof j.inputs === 'object' && !Array.isArray(j.inputs) ? j.inputs : j;\n const key = input.key ?? j.key;\n const field = input.field ?? j.field;\n\n // POST /api/data/query returns:\n // { data: [ { time, dataSourceId, values: { <field>: <number> } } ], metadata }\n // The number lives under values[field] - note \"values\" is plural and nested.\n // If the HTTP node has \"Include Response Headers and Status\" enabled, that\n // whole payload is wrapped under .body instead.\n const rows = j.body?.data ?? j.data ?? [];\n const values = rows\n .map((r) => {\n if (typeof r === 'number') return r;\n if (r && typeof r.values === 'object' && r.values !== null) return r.values[field];\n // Tolerate a flatter shape in case the query endpoint changes.\n return r?.value ?? r?._value ?? r?.[field];\n })\n .filter((v) => typeof v === 'number' && !Number.isNaN(v));\n\n if (key) byRule[j.ruleId].series[key] = values;\n}\n\nconst spread = (xs) => (xs.length ? Math.max(...xs) - Math.min(...xs) : 0);\nconst round = (n) => Math.round(n * 100) / 100;\n\nconst out = [];\n\nfor (const rule of Object.values(byRule)) {\n const p = rule.parameters;\n let outcome = 'inconclusive';\n let evidence = {};\n\n if (rule.kind === 'thermal-band') {\n const int = rule.series.int ?? [];\n const ext = rule.series.ext ?? [];\n\n if (int.length === 0 || ext.length === 0) {\n evidence = { reason: 'missing samples', intSamples: int.length, extSamples: ext.length };\n } else if (spread(ext) < Number(p.minExtSpread)) {\n // THE GATE. External barely moved, so the window carries no information.\n // This must be 'inconclusive', NOT 'not_met' - reporting not_met would\n // start the clear-dwell timer and eventually re-arm the rule on no\n // evidence at all.\n evidence = {\n reason: 'external variation below gate',\n extSpread: round(spread(ext)),\n minExtSpread: Number(p.minExtSpread),\n };\n } else {\n // Strict form: one out-of-band sample breaks it, and it cannot recover\n // until that sample ages out of the window.\n const held = int.every((v) => v >= Number(p.bandMin) && v <= Number(p.bandMax));\n outcome = held ? 'met' : 'not_met';\n evidence = {\n extSpread: round(spread(ext)),\n intSpread: round(spread(int)),\n intMin: round(Math.min(...int)),\n intMax: round(Math.max(...int)),\n samples: int.length,\n };\n }\n } else if (rule.kind === 'threshold') {\n const v = rule.series.v ?? [];\n if (v.length === 0) {\n evidence = { reason: 'no samples in window' };\n } else {\n const latest = v[v.length - 1];\n const t = Number(p.threshold);\n const op = String(p.operator ?? '>');\n const cmp = {\n '>': latest > t,\n '<': latest < t,\n '>=': latest >= t,\n '<=': latest <= t,\n };\n outcome = cmp[op] ? 'met' : 'not_met';\n evidence = { latest: round(latest), threshold: t, operator: op, samples: v.length };\n }\n } else {\n // Unknown kind: hold state rather than guessing. Safe by construction.\n evidence = { reason: 'no evaluator implemented for kind \"' + rule.kind + '\"' };\n }\n\n out.push({\n json: { ruleId: rule.ruleId, name: rule.name, kind: rule.kind, outcome, evidence },\n });\n}\n\nreturn out;"
},
"id": "evaluate",
"name": "Evaluate",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1320,
300
]
},
{
"parameters": {
"method": "POST",
"url": "={{ $('Config').first().json.dakkioHost }}/api/evaluations/{{ $json.ruleId }}/verdict",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ outcome: $json.outcome, evidence: $json.evidence }) }}",
"options": {}
},
"id": "submit-verdict",
"name": "Submit Verdict",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1540,
300
],
"credentials": {
"httpHeaderAuth": {
"id": "REPLACE_ME",
"name": "dakkio API key"
}
},
"onError": "continueRegularOutput"
}
],
"connections": {
"Every 15 Minutes": {
"main": [
[
{
"node": "Config",
"type": "main",
"index": 0
}
]
]
},
"Config": {
"main": [
[
{
"node": "Get Due Rules",
"type": "main",
"index": 0
}
]
]
},
"Get Due Rules": {
"main": [
[
{
"node": "Split Rules",
"type": "main",
"index": 0
}
]
]
},
"Split Rules": {
"main": [
[
{
"node": "Split Inputs",
"type": "main",
"index": 0
}
]
]
},
"Split Inputs": {
"main": [
[
{
"node": "Query Series",
"type": "main",
"index": 0
},
{
"node": "Merge Context",
"type": "main",
"index": 0
}
]
]
},
"Query Series": {
"main": [
[
{
"node": "Merge Context",
"type": "main",
"index": 1
}
]
]
},
"Merge Context": {
"main": [
[
{
"node": "Evaluate",
"type": "main",
"index": 0
}
]
]
},
"Evaluate": {
"main": [
[
{
"node": "Submit Verdict",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
},
"pinData": {}
}
After importing:
- Open Config and set
dakkioHostto your dakkio host - Open each of the three HTTP nodes and select your Header Auth credential — the import cannot bind credentials, and
"id": "REPLACE_ME"is a placeholder
Testing it
Execute the workflow twice in a row against a condition that is true:
| Run | dispatched | reason |
|---|---|---|
| First | true | fired |
| Second | false | still_firing |
That is edge-triggering working: a rule that stays true notifies once.
To watch a full cycle, set dwellSeconds to something short (120) when creating the rule, let the condition clear, and wait — you will see dwell_started, then dwell_in_progress, then rearmed.
Troubleshooting
| Symptom | Cause |
|---|---|
Get Due Rules returns count: 0 | Nothing is due — normal. Check the rule is active and has an evaluation block. |
"undefined" is not valid JSON | The expression path is wrong. Check the node's input panel for where query actually sits. |
| Submit Verdict URL has no rule id | Split Inputs is not set to All Other Fields. |
Every verdict is inconclusive | The series is empty — you are reading the wrong key. It is values[field]. |
| Evaluate emits nothing | The Merge node is missing or miswired, so ruleId never arrives. |
Two verdicts per rule, second is 409 | Evaluate is not set to Run Once for All Items. |
state: FIRED with outcome: not_met | Correct — the dwell timer is running. |
The community node
There is a n8n-nodes-dakkio package providing typed nodes with dropdowns for buckets, data sources and fields, plus a polling trigger.
n8n Cloud only permits verified community nodes. An unverified package cannot be installed there regardless of publication, so the HTTP Request workflow above is the supported path on Cloud. The package is usable on self-hosted n8n.