Below is how I set up my workflow. Below, I’ll walk through deploying n8n, deploying Vikunja, creating the Vikunja credential within n8n, and then creating the workflow above. You don’t need to use webhooks since n8n can directly interact with the Vikunja API, although you could do the same with webhooks instead by swapping out the “Get Personal/Work Tasks” nodes. See The beginner's guide to webhooks for workflow automation – n8n Blog for details on webhooks vs. API.
In Vikunja, create an API token and copy the API key.
In n8n, create a Vikunja API credential with the above API key. For the API URL, reference Vikunja by the name of the Docker container like this: http://vikunja:3456/api/v1
.
You should then be able to create a workflow, including Vikunja nodes.
The nodes for “Get Personal/Work Tasks” are tied to a specific project. You’ll need to update those numbers by copying the project number from the Vikunja URL. For example, https://vikunja.yourdomain.tld/projects/7/1
is project 7. You could also add more nodes before the “Get Task” node if you have additional projects. FYI, sub-projects are automatically included and do not need their own node.
For details on how the flow works, check the notes for each node. Good luck!
JSON for above workflow
{
"name": "Vikunja - Recurrence",
"nodes": [
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "1f229ea3-e848-45e2-a857-b851051973c6",
"leftValue": "={{ $('Get Task').item.json.data.task.start_date }}",
"rightValue": "2000-01-01T00:00:00",
"operator": {
"type": "dateTime",
"operation": "after"
}
}
],
"combinator": "and"
},
"options": {}
},
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
1360,
-300
],
"id": "8e383e2c-42de-45af-b44d-8e4b2b5a78bb",
"name": "If Start Date"
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "1f229ea3-e848-45e2-a857-b851051973c6",
"leftValue": "={{ $('Get Task').item.json.data.task.due_date }}",
"rightValue": "2000-01-01T00:00:00",
"operator": {
"type": "dateTime",
"operation": "after"
}
}
],
"combinator": "and"
},
"options": {}
},
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
1280,
-80
],
"id": "eac342a4-4743-447e-9390-659acd90f396",
"name": "If Due Date"
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "41a231bc-a81d-4978-b99d-8c234fca0f71",
"name": "end date",
"value": "={{ $json.data.task.end_date }}",
"type": "string"
},
{
"id": "6cdb0b81-e0c2-4567-9b08-4aa6ebe7447d",
"name": "cron",
"value": "={{ $json.data.task.description.split(\"Cron: \")[1].split(\"<\")[0] }}",
"type": "string"
}
]
},
"options": {}
},
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
880,
0
],
"id": "3eeb4b37-817e-4c4e-aeb8-94dfc0725c5c",
"name": "Parse Vars",
"notesInFlow": true,
"notes": "Extract the cron expression by looking for text between \"Cron: \" and the hidden \"<p>\" at the end of each paragraph."
},
{
"parameters": {
"mode": "chooseBranch",
"numberInputs": 4,
"useDataOfInput": 3
},
"type": "n8n-nodes-base.merge",
"typeVersion": 3,
"position": [
1700,
-20
],
"id": "ae9a01b0-479a-4c20-9b54-a2571e7072a9",
"name": "Merge"
},
{
"parameters": {
"method": "POST",
"url": "=http://vikunja:3456/api/v1/tasks/{{ $('Get Task').last().json.data.task.id }}",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "vikunjaApi",
"sendBody": true,
"bodyParameters": {
"parameters": [
{
"name": "end_date",
"value": "={{ $json.next_cron_end_date }}"
},
{
"name": "start_date",
"value": "={{\n $if(\n $('Get Task').last().json.data.task.start_date != \"0001-01-01T00:00:00Z\",\n $json['next_cron_end_date'].toDateTime().minus($('Start Date Delta').last().json.timeDifference.minutes, 'minutes'),\n $('Get Task').last().json.data.task.start_date\n )\n}}"
},
{
"name": "due_date",
"value": "={{\n $if(\n $('Get Task').last().json.data.task.due_date != \"0001-01-01T00:00:00Z\",\n $json['next_cron_end_date'].toDateTime().minus($('Due Date Delta').last().json.timeDifference.minutes, 'minutes'),\n $('Get Task').last().json.data.task.due_date\n )\n}}"
},
{
"name": "reminders",
"value": "={{\n $if(\n $('Get Task').last().json.data.task.reminders != null,\n $('Calculate Reminders').last().json.reminders,\n $('Get Task').last().json.data.task.reminders\n )\n}}"
},
{
"name": "done",
"value": "={{ false }}"
},
{
"name": "assignees",
"value": "={{ $('Get Task').last().json.data.task.assignees }}"
},
{
"name": "bucket_id",
"value": "={{ $('Get Task').last().json.data.task.bucket_id }}"
},
{
"name": "cover_image_attachment_id",
"value": "={{ $('Get Task').last().json.data.task.cover_image_attachment_id }}"
},
{
"name": "description",
"value": "={{ $('Get Task').last().json.data.task.description }}"
},
{
"name": "percent_done",
"value": "={{ $('Get Task').last().json.data.task.percent_done }}"
}
]
},
"options": {}
},
"id": "242331b1-6d98-4e7b-986a-5ec89dc57417",
"name": "Update Task",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1880,
0
],
"alwaysOutputData": true,
"notesInFlow": true,
"credentials": {
"vikunjaApi": {
"id": "jIQLzVfq62CYQWXV",
"name": "your Vikunja credential"
}
},
"notes": "Update task in Vikunja."
},
{
"parameters": {
"jsCode": "const event = $('Get Task').first().json;\nconst reminders = event.data.task.reminders;\n\nconst nextCronEndDate = new Date($input.first().json['next_cron_end_date'])\nlet endDate = new Date(event.data.task.end_date);\n\n// Build the adjusted reminders\nconst adjustedReminders = reminders.map(r => {\n if (r.relative_to === '') { // Only adjust reminders that are a custom date/time and ignore reminders relative to other start/due/end dates\n const reminderDate = new Date(r.reminder);\n const diffMs = endDate.getTime() - reminderDate.getTime();\n const adjustedDate = new Date(nextCronEndDate.getTime() - diffMs);\n\n // Return updated reminder object\n return {\n ...r,\n reminder: adjustedDate.toISOString()\n };\n }\n\n // Leave other reminders unchanged\n return r;\n});\n\nreturn [{ json: { reminders: adjustedReminders } }];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1420,
80
],
"id": "60555db4-910a-41a3-a87f-2cc327a9b918",
"name": "Calculate Reminders",
"notesInFlow": true,
"notes": "Adjust each reminder that has a custom date/time to reflect the same duration to the new end date."
},
{
"parameters": {
"project": 7,
"events": [
"task.updated"
]
},
"id": "c5f60ea5-d579-452b-bb40-c445d78ac929",
"name": "Get Personal Tasks",
"type": "n8n-nodes-vikunja.vikunjaTrigger",
"typeVersion": 1,
"position": [
340,
-80
],
"webhookId": "a858e228-ea7f-4036-8eb7-e50559e6d871",
"credentials": {
"vikunjaApi": {
"id": "jIQLzVfq62CYQWXV",
"name": "your Vikunja credential"
}
}
},
{
"parameters": {
"project": 20,
"events": [
"task.updated"
]
},
"id": "6dd96a61-66a7-46cb-bdda-908a74671f0e",
"name": "Get Work Tasks",
"type": "n8n-nodes-vikunja.vikunjaTrigger",
"typeVersion": 1,
"position": [
340,
80
],
"webhookId": "a858e228-ea7f-4036-8eb7-e50559e6d871",
"credentials": {
"vikunjaApi": {
"id": "jIQLzVfq62CYQWXV",
"name": "your Vikunja credential"
}
}
},
{
"parameters": {},
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [
520,
0
],
"id": "0bf7eb99-1141-4a5d-ad4d-efc8a459e003",
"name": "Get Task",
"notesInFlow": true,
"notes": "This node is used as a single point of reference for the other nodes."
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "1f229ea3-e848-45e2-a857-b851051973c6",
"leftValue": "={{ $('Get Task').item.json.data.task.reminders }}",
"rightValue": "2000-01-01T00:00:00",
"operator": {
"type": "array",
"operation": "exists",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
1260,
160
],
"id": "0b8709fe-22f6-462f-a165-2fd5020d25ec",
"name": "If Reminders"
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 1
},
"conditions": [
{
"id": "fb62750f-ac83-4d86-a31b-cc37f6214987",
"leftValue": "={{ $json.data.task.description }}",
"rightValue": "<p>Cron: ",
"operator": {
"type": "string",
"operation": "contains"
}
},
{
"id": "1e227ef5-48c5-4701-9700-c0c83736896f",
"leftValue": "={{ $json.data.task.end_date }}",
"rightValue": "2000-01-01T00:00:00",
"operator": {
"type": "dateTime",
"operation": "after"
}
},
{
"id": "4fe6a14d-961f-4e40-9638-c0bbef838ed2",
"leftValue": "={{ $json.data.task.done }}",
"rightValue": "={{ true }}",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "7d891bae-be8d-4e1d-bc98-85333969580b",
"name": "Check for Criteria",
"type": "n8n-nodes-base.filter",
"typeVersion": 2,
"position": [
700,
-20
],
"notesInFlow": true,
"notes": "This node verifies that the cron expression is in the description, an end date is set, and the task is marked as done."
},
{
"parameters": {
"language": "python",
"pythonCode": "from datetime import datetime, timedelta, timezone\nimport re\n\ndef get_next_cron_end_date(current_end_date: str, cron_expr: str) -> str:\n \"\"\"\n current_end_date: ISO 8601 string, e.g., '2025-05-14T00:00:00-04:00'\n cron_expr: Crontab string with 5 fields, e.g., '0 12 * * 3#2' or '0 12 * * 1-5'.\n - First minute and hour are used.\n - If nth weekday is defined, day is ignored.\n - Steps are not allowed.\n Returns next end date as ISO string (UTC).\n \"\"\"\n minute_field, hour_field, day_field, month_field, weekday_field = cron_expr.split()\n\n local = datetime.fromisoformat(current_end_date)\n current = local.astimezone(timezone.utc)\n year = current.year\n month = current.month\n\n def parse_cron_field(field, min_val, max_val):\n \"\"\"\n Parse a cron field into a sorted list of allowed integers.\n Evaluate the following: * , -\n But do not evaluate: # (nth)\n \"\"\"\n # Evaluate * (wildcard)\n if field == \"*\":\n return list(range(min_val, max_val + 1))\n\n result = set()\n # Evaluate , (list)\n for part in field.split(','):\n # Evaluate - (range)\n if '-' in part:\n start, end = map(int, part.split('-'))\n result.update(range(start, end + 1))\n else:\n result.add(int(part))\n\n return sorted(v for v in result if min_val <= v <= max_val)\n\n # Determine allowed values for each field based on cron expression\n minute = parse_cron_field(minute_field, 0, 59)[0]\n hour = parse_cron_field(hour_field, 0, 23)[0]\n days = parse_cron_field(day_field, 1, 31)\n months = parse_cron_field(month_field, 1, 12)\n\n # Evaluate # (nth) be determining `weekdays` and `nth`\n if \"#\" in weekday_field:\n weekday_part, nth_part = weekday_field.split(\"#\")\n weekdays = [int(weekday_part)] # example 1 = Monday\n nth = int(nth_part) # example 2 = 2nd Xday of the month\n else:\n weekdays = parse_cron_field(weekday_field, 0, 6) # example 1 = Monday\n nth = None\n\n def get_nth_weekday(year, month, weekday, nth):\n \"\"\"\n This function is only used if `nth` is defined. It returns the next nth weekday.\n It gets looped by a call below.\n \"\"\"\n count = 0\n for day in range(1, 32):\n try:\n date = datetime(year, month, day, tzinfo=local.tzinfo)\n except ValueError:\n break\n if (date.weekday() + 1) % 7 == weekday: # add 1 to test date to adjust from Python format (`.weekday()`, Monday = 0) to cron format (Monday = 1)\n count += 1\n if count == nth:\n return date\n return None\n\n # Loop up to 24 months\n for i in range(24):\n test_year = year + (month + i - 1) // 12\n test_month = (month + i - 1) % 12 + 1\n\n if test_month not in months:\n continue\n\n candidates = []\n\n # Define candidates. If nth is defined, get valid weekdays. Otherwise, get valid day of month and weekday.\n if nth is not None:\n for weekday in weekdays:\n candidate = get_nth_weekday(test_year, test_month, weekday, nth)\n if candidate:\n candidates.append(candidate)\n else:\n for day in range(1, 32):\n try:\n candidate = datetime(test_year, test_month, day, tzinfo=local.tzinfo)\n except ValueError:\n break\n if days and candidate.day not in days:\n continue\n if weekdays and candidate.weekday() not in weekdays:\n continue\n candidates.append(candidate)\n\n for candidate in candidates:\n candidate = candidate.replace(hour=hour, minute=minute, second=0, microsecond=0)\n if candidate > current:\n return candidate.isoformat()\n\n raise ValueError(\"No valid next end date found in the next 24 months.\")\n\n# Determine next end date based off of inputs\nnext_cron_end_date = get_next_cron_end_date(_input.first().json['end date'], _input.first().json.cron)\nd = {'next_cron_end_date': next_cron_end_date}\n\nreturn d"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1060,
180
],
"id": "800a9513-7a3d-45d6-93c1-b43a000be634",
"name": "Calculate Next End Date (Python)"
},
{
"parameters": {
"jsCode": "const { Cron } = require('croner'); // this requires n8n to have Croner installed and enabled via environmental variable\n\nconst job = new Cron( $input.first().json.cron ); // input cron expression\nconst next_cron_end_date = job.nextRun( $input.first().json['end date'] ); // begin searching after current end date\n\nreturn [{ json: { next_cron_end_date: next_cron_end_date } }];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1060,
0
],
"id": "a1e53b60-01f0-4374-a491-f7a5f15d8eb2",
"name": "Calculate Next End Date (JavaScript with Croner)",
"notesInFlow": true,
"notes": "Use Croner to determine the next date that matches the cron expression and is after the end date."
},
{
"parameters": {
"operation": "getTimeBetweenDates",
"startDate": "={{ $('Get Task').item.json.data.task.start_date }}",
"endDate": "={{ $('Get Task').item.json.data.task.end_date }}",
"units": [
"minute"
],
"options": {}
},
"type": "n8n-nodes-base.dateTime",
"typeVersion": 2,
"position": [
1540,
-320
],
"id": "f4d6bd3e-a881-4b5b-bf0d-94ba3a0403bf",
"name": "Start Date Delta",
"notesInFlow": true,
"notes": "Determine duration between start date and end date."
},
{
"parameters": {
"operation": "getTimeBetweenDates",
"startDate": "={{ $('Get Task').item.json.data.task.due_date }}",
"endDate": "={{ $('Get Task').item.json.data.task.end_date }}",
"units": [
"minute"
],
"options": {}
},
"type": "n8n-nodes-base.dateTime",
"typeVersion": 2,
"position": [
1440,
-140
],
"id": "32479801-2f74-4b09-9a46-7f0dfbc22f8f",
"name": "Due Date Delta",
"notesInFlow": true,
"notes": "Determine duration between due date and end date."
}
],
"pinData": {},
"connections": {
"If Start Date": {
"main": [
[
{
"node": "Start Date Delta",
"type": "main",
"index": 0
}
],
[
{
"node": "Merge",
"type": "main",
"index": 0
}
]
]
},
"If Due Date": {
"main": [
[
{
"node": "Due Date Delta",
"type": "main",
"index": 0
}
],
[
{
"node": "Merge",
"type": "main",
"index": 1
}
]
]
},
"Parse Vars": {
"main": [
[
{
"node": "Calculate Next End Date (JavaScript with Croner)",
"type": "main",
"index": 0
}
]
]
},
"Merge": {
"main": [
[
{
"node": "Update Task",
"type": "main",
"index": 0
}
]
]
},
"Update Task": {
"main": [
[]
]
},
"Calculate Reminders": {
"main": [
[
{
"node": "Merge",
"type": "main",
"index": 3
}
]
]
},
"Get Personal Tasks": {
"main": [
[
{
"node": "Get Task",
"type": "main",
"index": 0
}
]
]
},
"Get Work Tasks": {
"main": [
[
{
"node": "Get Task",
"type": "main",
"index": 0
}
]
]
},
"Get Task": {
"main": [
[
{
"node": "Check for Criteria",
"type": "main",
"index": 0
}
]
]
},
"If Reminders": {
"main": [
[
{
"node": "Calculate Reminders",
"type": "main",
"index": 0
}
],
[
{
"node": "Merge",
"type": "main",
"index": 3
}
]
]
},
"Check for Criteria": {
"main": [
[
{
"node": "Parse Vars",
"type": "main",
"index": 0
}
]
]
},
"Calculate Next End Date (Python)": {
"main": [
[]
]
},
"Calculate Next End Date (JavaScript with Croner)": {
"main": [
[
{
"node": "If Reminders",
"type": "main",
"index": 0
},
{
"node": "If Start Date",
"type": "main",
"index": 0
},
{
"node": "If Due Date",
"type": "main",
"index": 0
},
{
"node": "Merge",
"type": "main",
"index": 2
}
]
]
},
"Start Date Delta": {
"main": [
[
{
"node": "Merge",
"type": "main",
"index": 0
}
]
]
},
"Due Date Delta": {
"main": [
[
{
"node": "Merge",
"type": "main",
"index": 1
}
]
]
}
},
"active": true,
"settings": {
"executionOrder": "v1",
"callerPolicy": "workflowsFromSameOwner",
"saveDataSuccessExecution": "none"
},
"versionId": "de354075-8066-4459-a079-629b41bb0776",
"meta": {
"templateCredsSetupCompleted": true,
"instanceId": "2463bf03b26cd468961fab42d7d116247f91b33a4e53561eee0272102a1112ac"
},
"id": "a29B8M98eEC417Yf",
"tags": [
{
"createdAt": "2025-02-25T14:42:34.725Z",
"updatedAt": "2025-02-25T14:42:34.725Z",
"id": "7PYkCl9kT9xOUaPs",
"name": "Active"
}
]
}