This workflow corresponds to n8n.io template #omniproject-time-travel — we link there as the canonical source.
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": "OmniProject \u2014 Time-travel (history egress + replay)",
"meta": {
"templateId": "omniproject-time-travel",
"description": "Two branches in one workflow. HISTORIAN: a scheduled trigger snapshots the current portfolio state and POSTs a timestamped record to the operator's logging server (LOGGING_SYNC_URL). REPLAY: a webhook handling action == 'replay' queries that store for records in payload.from..payload.to and returns HistoryState[] ({ at, completionPct, openBlockers, provenance: 'replayed' }). OmniProject stays stateless \u2014 the operator's logging server holds the history. Egressed data is the operator's responsibility and OUTSIDE OmniProject's warranty."
},
"active": false,
"settings": {
"executionOrder": "v1"
},
"nodes": [
{
"parameters": {
"content": "## OmniProject Time-travel\n\n**OmniProject stays stateless** \u2014 it stores nothing. This workflow lives on the operator's n8n and uses an operator-owned logging server as the durable history store.\n\n> \u26a0\ufe0f **Out of warranty.** Data egressed to `LOGGING_SYNC_URL` leaves OmniProject's control and **warranty** \u2014 the operator is responsible for its security, retention and residency. Same trust class as the OData / Power BI feeds. See [docs/TIME-TRAVEL.md](../../docs/TIME-TRAVEL.md).\n\n### Two branches\n1. **HISTORIAN (egress / write)** \u2014 Schedule Trigger \u2192 read current portfolio state from OmniProject \u2192 build a timestamped record \u2192 **POST to the logging store** (`LOGGING_SYNC_URL`).\n2. **REPLAY (read-back)** \u2014 Webhook on `action == \"replay\"` \u2192 query the logging store for `payload.from..payload.to` \u2192 map to `HistoryState[]` (`provenance: \"replayed\"`) \u2192 respond.\n\n### Env vars\n- `LOGGING_SYNC_URL` \u2014 base URL of the operator's logging store (e.g. `https://logs.internal:9200/omni-history`). SSRF-validated by the gateway before egress is allowed.\n- `OMNI_API_BASE` \u2014 OmniProject gateway base URL the Historian reads from.\n- `OMNI_HISTORY_TOKEN` \u2014 service token for the Historian's reads + sink writes (optional).\n- `LOGGING_SYNC_QUERY_URL` \u2014 read endpoint for replay (defaults to `LOGGING_SYNC_URL`).",
"height": 420,
"width": 480
},
"id": "ff000000-0000-4000-8000-000000000000",
"name": "Notes",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
-820,
-220
]
},
{
"parameters": {
"content": "## Branch 1 \u2014 HISTORIAN (egress / write)\n\nRuns on a schedule (hourly by default). Captures the **current** portfolio state from OmniProject's own read endpoint and writes ONE timestamped record to the operator's logging store.\n\nThis is the only \"send data out\" path. Everything it POSTs is **outside OmniProject's warranty** \u2014 the operator owns the destination.\n\nSwap the HTTP **Write to logging store** node for an Elasticsearch / Loki / OpenSearch node if you prefer a native integration; HTTP is used here for portability.",
"height": 320,
"width": 460
},
"id": "ff000000-0000-4000-8000-000000000001",
"name": "Historian Notes",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
-820,
240
]
},
{
"parameters": {
"rule": {
"interval": [
{
"field": "hours",
"hoursInterval": 1
}
]
}
},
"id": "ff000000-0000-4000-8000-000000000010",
"name": "Schedule (hourly)",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [
-320,
560
],
"notes": "How often to snapshot the portfolio. Hourly is a sensible default; switch to daily for a smaller, coarser history."
},
{
"parameters": {
"method": "GET",
"url": "={{ $env.OMNI_API_BASE }}/api/portfolio/health",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "=Bearer {{ $env.OMNI_HISTORY_TOKEN }}"
}
]
},
"options": {}
},
"id": "ff000000-0000-4000-8000-000000000011",
"name": "Read current portfolio state",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
-80,
560
],
"notes": "Read the live portfolio from OmniProject's own read endpoint (or call your backends directly). Adjust the path/auth to your deployment. The response is summarised into a single point-in-time record below."
},
{
"parameters": {
"jsCode": "// Build ONE timestamped HistoryState-shaped record from the current portfolio.\n// OmniProject stores nothing; this record is written to the operator's logging\n// store, which IS the durable history. Egressed data is outside OmniProject's\n// warranty \u2014 the operator owns this destination.\nconst rows = items.map((i) => i.json);\nconst portfolio = Array.isArray(rows) && rows.length === 1 && Array.isArray(rows[0]) ? rows[0] : rows;\nconst list = Array.isArray(portfolio) ? portfolio : (portfolio.data ?? portfolio.projects ?? [portfolio]);\n\nconst pct = (p) => Number(p.completionPct ?? p.completionPercentage ?? p.percentComplete ?? 0);\nconst blockers = (p) => Number(p.activeBlockersCount ?? p.openBlockers ?? 0);\n\nconst n = list.length || 1;\nconst completionPct = Math.round(list.reduce((s, p) => s + pct(p), 0) / n);\nconst openBlockers = list.reduce((s, p) => s + blockers(p), 0);\n\nreturn [{ json: {\n at: new Date().toISOString(),\n completionPct,\n openBlockers,\n provenance: 'replayed',\n source: 'omniproject-historian'\n} }];"
},
"id": "ff000000-0000-4000-8000-000000000012",
"name": "Build history record",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
160,
560
]
},
{
"parameters": {
"method": "POST",
"url": "={{ $env.LOGGING_SYNC_URL }}",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "=Bearer {{ $env.OMNI_HISTORY_TOKEN }}"
},
{
"name": "Content-Type",
"value": "application/json"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify($json) }}",
"options": {}
},
"id": "ff000000-0000-4000-8000-000000000013",
"name": "Write to logging store (operator-owned)",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
420,
560
],
"notes": "EGRESS \u2014 OUTSIDE OmniProject's warranty. POSTs the timestamped record to the operator's logging store at LOGGING_SYNC_URL (the gateway SSRF-validates this URL before time-travel is enabled). Replace with an Elasticsearch / Loki / OpenSearch node for a native integration; the record shape stays the same."
},
{
"parameters": {
"content": "## Branch 2 \u2014 REPLAY (read-back)\n\nThe gateway's `GET /api/history/replay` (409 unless time-travel is enabled) brokers the **`replay`** action here as:\n\n```jsonc\n{ \"action\": \"replay\", \"source\": \"history_provider\",\n \"payload\": { \"from\": \"<ISO?>\", \"to\": \"<ISO?>\" } }\n```\n\nThis branch queries the logging store for records in `payload.from..payload.to` and replies with the **`HistoryState[]`** array (`{ at, completionPct, openBlockers, provenance: \"replayed\" }`) wrapped as `N8nActionResult` (`{ success, data }`).",
"height": 320,
"width": 460
},
"id": "ff000000-0000-4000-8000-000000000002",
"name": "Replay Notes",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
-820,
-560
]
},
{
"parameters": {
"httpMethod": "POST",
"path": "omniproject-time-travel",
"responseMode": "responseNode",
"options": {}
},
"id": "ff000000-0000-4000-8000-000000000020",
"name": "Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
-320,
-360
],
"notes": "Point the OmniProject broker (BROKER_URL) at this URL, or merge this branch into your core-sync workflow. The gateway sends X-OmniProject-Action: replay and body { action, payload: { from?, to? }, source: 'history_provider' }."
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"typeValidation": "loose"
},
"conditions": [
{
"leftValue": "={{ $json.body.action }}",
"rightValue": "replay",
"operator": {
"type": "string",
"operation": "equals"
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "ff000000-0000-4000-8000-000000000021",
"name": "Action == replay?",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
-80,
-360
],
"notes": "Only the 'replay' action is served here. Anything else falls through to the Unsupported branch so this workflow can be merged with others without colliding."
},
{
"parameters": {
"method": "GET",
"url": "={{ $env.LOGGING_SYNC_QUERY_URL || $env.LOGGING_SYNC_URL }}",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "=Bearer {{ $env.OMNI_HISTORY_TOKEN }}"
}
]
},
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "from",
"value": "={{ $json.body.payload.from }}"
},
{
"name": "to",
"value": "={{ $json.body.payload.to }}"
}
]
},
"options": {}
},
"id": "ff000000-0000-4000-8000-000000000022",
"name": "Query logging store",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
180,
-440
],
"notes": "Read records the Historian wrote, filtered to payload.from..payload.to. Adjust the query params / body to your store's API (an Elasticsearch range query, a Loki LogQL range, etc.). Replace with the matching native node if you prefer."
},
{
"parameters": {
"jsCode": "// Map the operator logging store's rows into the HistoryState[] contract the\n// gateway expects: { at, completionPct, openBlockers, provenance }. Recorded\n// states are 'replayed' (real, not projected). Window-filter as a backstop in\n// case the store ignored from/to.\nconst body = $('Webhook').first().json.body;\nconst from = body.payload && body.payload.from ? Date.parse(body.payload.from) : -Infinity;\nconst to = body.payload && body.payload.to ? Date.parse(body.payload.to) : Infinity;\n\nconst rows = items.map((i) => i.json);\nconst hits = rows.length === 1 && Array.isArray(rows[0]) ? rows[0]\n : (rows.length === 1 && rows[0] && Array.isArray(rows[0].hits)) ? rows[0].hits\n : rows;\n\nconst states = (hits || [])\n .map((r) => (r && r._source) ? r._source : r)\n .filter((r) => r && r.at)\n .filter((r) => { const t = Date.parse(r.at); return t >= from && t <= to; })\n .map((r) => ({\n at: new Date(r.at).toISOString(),\n completionPct: Number(r.completionPct ?? 0),\n openBlockers: r.openBlockers == null ? null : Number(r.openBlockers),\n provenance: 'replayed'\n }))\n .sort((a, b) => Date.parse(a.at) - Date.parse(b.at));\n\nreturn [{ json: { success: true, data: states, message: 'replay ok' } }];"
},
"id": "ff000000-0000-4000-8000-000000000023",
"name": "Map \u2192 HistoryState[]",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
440,
-440
]
},
{
"parameters": {
"jsCode": "const action = $('Webhook').first().json.body.action;\nreturn [{ json: { success: false, data: [], message: 'Unsupported action: ' + action } }];"
},
"id": "ff000000-0000-4000-8000-000000000024",
"name": "Unsupported Action",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
180,
-240
]
},
{
"parameters": {
"respondWith": "firstIncomingItem",
"options": {}
},
"id": "ff000000-0000-4000-8000-000000000025",
"name": "Respond N8nActionResult",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1,
"position": [
700,
-360
]
}
],
"connections": {
"Schedule (hourly)": {
"main": [
[
{
"node": "Read current portfolio state",
"type": "main",
"index": 0
}
]
]
},
"Read current portfolio state": {
"main": [
[
{
"node": "Build history record",
"type": "main",
"index": 0
}
]
]
},
"Build history record": {
"main": [
[
{
"node": "Write to logging store (operator-owned)",
"type": "main",
"index": 0
}
]
]
},
"Webhook": {
"main": [
[
{
"node": "Action == replay?",
"type": "main",
"index": 0
}
]
]
},
"Action == replay?": {
"main": [
[
{
"node": "Query logging store",
"type": "main",
"index": 0
}
],
[
{
"node": "Unsupported Action",
"type": "main",
"index": 0
}
]
]
},
"Query logging store": {
"main": [
[
{
"node": "Map \u2192 HistoryState[]",
"type": "main",
"index": 0
}
]
]
},
"Map \u2192 HistoryState[]": {
"main": [
[
{
"node": "Respond N8nActionResult",
"type": "main",
"index": 0
}
]
]
},
"Unsupported Action": {
"main": [
[
{
"node": "Respond N8nActionResult",
"type": "main",
"index": 0
}
]
]
}
}
}
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
OmniProject — Time-travel (history egress + replay). Uses httpRequest. Scheduled trigger; 13 nodes.
Source: https://github.com/walshd1/Omniproject/blob/main/artifacts/n8n-blueprints/omniproject-time-travel.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.
Birthday Automation - Production (Fixed). Uses stopAndError, httpRequest, emailSend, bannerbear. Scheduled trigger; 86 nodes.
This template runs two scheduled workflows to govern Microsoft Entra ID (Azure AD) guest accounts by detecting stale users via Microsoft Graph, staging deletions in SharePoint with a 72-hour window, n
Jira-Allure-Auto-Qa. Uses httpRequest, jira. Scheduled trigger; 68 nodes.
Spotify-Sync-Surrealdb-V1. Uses httpRequest, n8n-nodes-surrealdb, spotify. Scheduled trigger; 62 nodes.
As n8n instances scale, teams often lose track of sub-workflows—who uses them, where they are referenced, and whether they can be safely updated. This leads to inefficiencies like unnecessary copies o