Skip to main content

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:

  1. A publicly reachable dakkio API. n8n Cloud cannot reach localhost — deploy, or tunnel with cloudflared / ngrok.
  2. An integration key (dakkio_i_…) from the dashboard.
  3. At least one active rule with an evaluation block. A rule without one is invisible to the due endpoint.

Create the credential

Credentials → New → Header Auth

FieldValue
NameX-API-Key
Valueyour 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)

Node parameters
{
"rule": {
"interval": [
{
"field": "minutes",
"minutesInterval": 15
}
]
}
}

Step 2 — Config

A Set node holding non-secret configuration:

FieldValue
dakkioHosthttps://api.dakkio.io

Downstream nodes reference it as {{ $('Config').first().json.dakkioHost }}, so moving environments is a one-node change.

Never put the API key here

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)

Node parameters
{
"assignments": {
"assignments": [
{
"id": "baseurl",
"name": "dakkioHost",
"value": "https://api.dakkio.io",
"type": "string"
}
]
},
"options": {}
}

Step 3 — Get Due Rules

HTTP Request

SettingValue
MethodGET
URL{{ $json.dakkioHost }}/api/evaluations/due
AuthenticationGeneric → 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)

Node parameters
{
"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

SettingValue
Fields To Split Outevaluations
IncludeNo Other Fields

One item per due rule. The only sibling field is count, which nothing needs.

Node type: n8n-nodes-base.splitOut (typeVersion 1)

Node parameters
{
"fieldToSplitOut": "evaluations",
"options": {}
}

No include key means "No Other Fields" — the default.

Step 5 — Split Inputs

Split Out

SettingValue
Fields To Split Outinputs
IncludeAll Other Fields ⚠️

One item per series the rule needs.

"All Other Fields" is not optional

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)

Node parameters
{
"fieldToSplitOut": "inputs",
"include": "allOtherFields",
"options": {}
}

include: "allOtherFields" is the setting that carries ruleId, kind and parameters through.

Step 6 — Query Series

HTTP Request

SettingValue
MethodPOST
URL{{ $('Config').first().json.dakkioHost }}/api/data/query
Send Bodyon, 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.

Why the expression tests for inputs

Split 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 } } ] }
The value is nested under 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)

Node 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": {}
}

Step 7 — Merge

Merge, mode Combine → By Position.

Connect Split Inputs → Merge input 1 directly, as well as through Query Series → Merge input 2.

Why this node exists

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)

Node parameters
{
"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)

Node parameters
{
"mode": "runOnceForAllItems",
"jsCode": "<the Code above>"
}

Step 9 — Submit Verdict

HTTP Request

SettingValue
MethodPOST
URL{{ $('Config').first().json.dakkioHost }}/api/evaluations/{{ $json.ruleId }}/verdict
Send Bodyon, JSON
JSON{{ JSON.stringify({ outcome: $json.outcome, evidence: $json.evidence }) }}
Settings → On ErrorContinue
Why "Continue" on error

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)

Node 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": {}
}

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.

FromTo
Every 15 MinutesConfig
ConfigGet Due Rules
Get Due RulesSplit Rules
Split RulesSplit Inputs
Split InputsQuery Series, Merge Context
Query SeriesMerge Context
Merge ContextEvaluate
EvaluateSubmit 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
dakkio-evaluator-http.json
{
"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:

  1. Open Config and set dakkioHost to your dakkio host
  2. 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:

Rundispatchedreason
Firsttruefired
Secondfalsestill_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

SymptomCause
Get Due Rules returns count: 0Nothing is due — normal. Check the rule is active and has an evaluation block.
"undefined" is not valid JSONThe expression path is wrong. Check the node's input panel for where query actually sits.
Submit Verdict URL has no rule idSplit Inputs is not set to All Other Fields.
Every verdict is inconclusiveThe series is empty — you are reading the wrong key. It is values[field].
Evaluate emits nothingThe Merge node is missing or miswired, so ruleId never arrives.
Two verdicts per rule, second is 409Evaluate is not set to Run Once for All Items.
state: FIRED with outcome: not_metCorrect — 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.

Not usable on n8n Cloud

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.