NinjaOne Automation with n8n Track/Scheduled and Recurring Tickets
NinjaOne Automation with n8n Track
Module 2 of 6

Scheduled and Recurring Tickets

Build automated ticket creation with dynamic device inventory on any schedule.

18 min read

What You'll Learn

  • Build a complete n8n workflow that creates recurring monthly patching tickets in NinjaOne
  • Pull live device inventory from the NinjaOne API and format it into structured HTML ticket content
  • Configure Schedule Trigger nodes for weekly, monthly, quarterly, and annual automation cadences
  • Implement multi-organization support by looping over clients in a single workflow
  • Look up and assign tickets to specific technicians using the NinjaOne user and form APIs

The Problem: NinjaOne Has No Recurring Tickets

Every MSP has routine maintenance tasks that need to happen on a schedule. Monthly patch management reviews. Quarterly security audits. Annual compliance assessments. Weekly backup verifications. These are predictable, repeatable tasks that should generate tickets automatically.

NinjaOne does not support this. There is no "create a recurring ticket" feature. There is no way to schedule ticket creation from the NinjaOne UI. If you search the NinjaOne documentation, community forums, or feature request boards, you will find years of requests for this feature with no resolution.

The workaround most MSPs use: a calendar reminder for a technician to manually create the ticket each month. This is error-prone (people forget), inconsistent (ticket content varies each time), and does not scale across multiple clients.

The n8n solution: A Schedule Trigger fires on the first Monday of each month. The workflow pulls the current device inventory for the target organization, formats it into a structured HTML ticket body with device names, OS versions, and online/offline status, and creates the ticket via the NinjaOne API. The ticket arrives fully populated, assigned to the right technician, with a task checklist ready to work through.

This pattern works for any scheduled task:

ScheduleUse Case
WeeklyBackup verification, alert review
MonthlyPatch management, license audits
QuarterlySecurity assessments, access reviews
AnnuallyCompliance audits, disaster recovery tests
Custom15th of each month, every other Friday, etc.

Quick Test: Map Your Recurring Tasks

Step 1: List every recurring task your team handles manually.

Step 2: For each task, note the cadence (weekly, monthly, quarterly), the client(s) it applies to, what information the ticket should contain, and who it should be assigned to.

Result: This list becomes your implementation roadmap for this module.

Building a Monthly Patching Workflow

This workflow uses four nodes: Schedule Trigger, HTTP Request (get devices), Code (build ticket body), and HTTP Request (create ticket).

Node 1: Schedule Trigger

Configure the Schedule Trigger to fire on the schedule you need:

  • Trigger Interval: Custom (Cron)
  • Cron Expression: 0 8 1-7 * 1 (first Monday of each month at 8:00 AM)

Alternative cron expressions:

  • 0 8 1 * * - 1st of each month at 8 AM
  • 0 8 * * 1 - every Monday at 8 AM
  • 0 8 1 1,4,7,10 * - quarterly (Jan, Apr, Jul, Oct)

Node 2: HTTP Request (Get Devices)

  • Method: GET
  • URL: https://app.ninjarmm.com/api/v2/organization/<ORG_ID>/devices-detailed
  • Authentication: OAuth2 API (your NinjaOne credential)
  • Query Parameters: pageSize = 1000

Replace <ORG_ID> with the numeric organization ID from NinjaOne. You can find this by calling GET /api/v2/organizations first.

Node 3: Code Node (Build Ticket Body)

This is where the real value is. The Code node processes the device list and builds a structured HTML ticket body:

const devices = $input.all().map(item => item.json);
const now = new Date();
const monthYear = now.toLocaleString('en-US', { month: 'long', year: 'numeric' });

// Separate servers and workstations
const servers = devices.filter(d =>
  d.nodeClass === 'WINDOWS_SERVER' ||
  d.nodeClass === 'LINUX_SERVER' ||
  d.nodeClass === 'MAC_SERVER'
);
const workstations = devices.filter(d =>
  d.nodeClass === 'WINDOWS_WORKSTATION' ||
  d.nodeClass === 'MAC' ||
  d.nodeClass === 'LINUX_WORKSTATION'
);

function buildDeviceList(deviceList) {
  if (deviceList.length === 0) return '<li>None</li>';
  return deviceList
    .sort((a, b) => a.systemName.localeCompare(b.systemName))
    .map(d => {
      const status = d.offline ? 'Offline' : 'Online';
      const os = d.os?.name || 'Unknown OS';
      return `<li><strong>${d.systemName}</strong> - ${os} (${status})</li>`;
    })
    .join('\n');
}

const htmlBody = `
<h3>Monthly Patch Management - ${monthYear}</h3>
<p>Scheduled patching review for <strong>CLIENT_NAME</strong>.</p>
<p>Device inventory as of ${now.toLocaleDateString('en-US')}:</p>

<h4>Servers (${servers.length})</h4>
<ul>
${buildDeviceList(servers)}
</ul>

<h4>Workstations (${workstations.length})</h4>
<ul>
${buildDeviceList(workstations)}
</ul>

<h4>Tasks</h4>
<ul>
<li>Review pending Windows updates on all servers</li>
<li>Check third-party application updates (Java, Chrome, Adobe)</li>
<li>Verify backup completion before applying patches</li>
<li>Apply critical/security patches to servers first</li>
<li>Apply patches to workstations</li>
<li>Verify all devices come back online after patching</li>
<li>Document any issues encountered</li>
</ul>

<p><em>Auto-generated by n8n workflow. Device count: ${devices.length} total (${servers.length} servers, ${workstations.length} workstations).</em></p>
`;

return [{ json: { htmlBody, deviceCount: devices.length } }];

Replace CLIENT_NAME with your actual client name, or pull it dynamically from the organizations API.

Node 4: HTTP Request (Create Ticket)

  • Method: POST
  • URL: https://app.ninjarmm.com/api/v2/ticketing/ticket
  • Authentication: OAuth2 API
  • Body Content Type: JSON
  • JSON Body:
{
  "clientId": <ORG_ID>,
  "subject": "Monthly Patching - {{ $now.format('MMMM yyyy') }}",
  "status": "NEW",
  "priority": "MEDIUM",
  "type": "PROBLEM",
  "description": {
    "htmlBody": "{{ $json.htmlBody }}"
  }
}

Replace <ORG_ID> with the numeric organization ID.

Test with Manual Trigger First

Before enabling the Schedule Trigger, replace it with a Manual Trigger and test the entire workflow end-to-end. Verify the ticket appears in NinjaOne with the correct formatting, device list, and assignment. Only switch to the Schedule Trigger once you have confirmed the output is correct.

Dynamic Ticket Content with the Code Node

The Code node is where you customize what goes into the ticket. Here are patterns for building rich, useful ticket content.

Sorting by online/offline status:

Group devices by their current status so technicians can quickly see which devices need attention:

const online = devices.filter(d => !d.offline);
const offline = devices.filter(d => d.offline);

let html = `<h3>Device Status Summary</h3>`;
html += `<p>Online: ${online.length} | Offline: ${offline.length} | Total: ${devices.length}</p>`;

if (offline.length > 0) {
  html += `<h4 style="color: red;">Offline Devices (${offline.length})</h4><ul>`;
  offline.forEach(d => {
    html += `<li><strong>${d.systemName}</strong> - Last seen: ${new Date(d.lastContact).toLocaleString()}</li>`;
  });
  html += `</ul>`;
}

Including OS version and patch status:

const deviceRow = (d) => {
  const lastContact = d.lastContact
    ? new Date(d.lastContact).toLocaleDateString()
    : 'Never';
  return `<tr>
    <td>${d.systemName}</td>
    <td>${d.os?.name || 'Unknown'}</td>
    <td>${d.offline ? 'Offline' : 'Online'}</td>
    <td>${lastContact}</td>
  </tr>`;
};

let table = `<table border="1" cellpadding="5" cellspacing="0">
<tr><th>Device</th><th>OS</th><th>Status</th><th>Last Contact</th></tr>
${devices.map(deviceRow).join('\n')}
</table>`;

Dynamic date references in tickets:

const now = new Date();
const month = now.toLocaleString('en-US', { month: 'long' });
const year = now.getFullYear();
const quarter = `Q${Math.ceil((now.getMonth() + 1) / 3)}`;

// Use in ticket content:
// "Monthly Patching - March 2026"
// "Q1 2026 Security Review"
// "Week of March 10, 2026 - Backup Verification"

Adding dynamic task checklists:

NinjaOne does not support native task checklists in tickets, but you can simulate them with HTML:

const tasks = [
  'Review pending updates on all servers',
  'Check third-party application updates',
  'Verify backup completion before patching',
  'Apply critical patches to servers',
  'Apply patches to workstations',
  'Verify all devices online post-patching',
  'Document issues and exceptions'
];

const taskList = tasks.map(t => `<li>${t}</li>`).join('\n');
const html = `<h4>Tasks</h4><ul>${taskList}</ul>`;

Customize Your Ticket Template

Copy the Code node example above and modify the task checklist to match your actual patching procedure. Add any client-specific steps (VPN connections, maintenance windows, approval contacts). Run the workflow and verify the ticket content in NinjaOne looks exactly how your technicians expect it.

Customizing for Different Schedules

The Schedule Trigger node supports multiple schedule formats. Here are the most common patterns for IT operations:

Cron expressions for common schedules:

ScheduleCron ExpressionDescription
Every Monday 8 AM0 8 * * 1Weekly tasks
1st of each month 8 AM0 8 1 * *Monthly tasks
First Monday of month0 8 1-7 * 1Monthly (business day)
15th of each month0 8 15 * *Mid-month tasks
Quarterly (Jan, Apr, Jul, Oct)0 8 1 1,4,7,10 *Quarterly reviews
Every 2 weeks on MondayUse n8n interval (14 days)Bi-weekly tasks
Annually on Jan 20 8 2 1 *Annual reviews

Multi-organization support:

If you manage multiple clients, you do not need a separate workflow for each one. Use a single workflow that loops over all organizations:

// Node: Code - Define target organizations
const orgs = [
  { id: 1, name: 'Internal IT' },
  { id: 2, name: 'Client Alpha' },
  { id: 3, name: 'Client Beta' },
  { id: 6, name: 'Client Gamma' }
];

return orgs.map(org => ({ json: org }));

Connect this to an HTTP Request node that uses {{ $json.id }} in the URL:

https://app.ninjarmm.com/api/v2/organization/{{ $json.id }}/devices-detailed

n8n will execute the HTTP Request once for each organization, pulling the device inventory for each client. The downstream Code node and ticket creation nodes also execute once per organization, creating a separate ticket for each client.

Timezone considerations:

Schedule Triggers use your n8n instance's configured timezone (set via the GENERIC_TIMEZONE environment variable). Ensure this matches your business hours. If your n8n server is in UTC but your team works in US Central, tickets will be created at the wrong time.

Verify your timezone: check the n8n Settings page or the GENERIC_TIMEZONE environment variable in your Docker Compose file.

Start with Manual, Then Schedule

Build the entire workflow with a Manual Trigger first. Test it thoroughly across all organizations. Once you are confident the output is correct, swap the Manual Trigger for a Schedule Trigger. This prevents accidentally creating test tickets in production during development.

Ticket Assignment and Routing

Creating tickets is only half the battle. You also need to assign them to the right technician and categorize them correctly.

Looking up user IDs:

NinjaOne identifies users by numeric IDs, not email addresses. To find a user's ID:

GET https://app.ninjarmm.com/api/v2/users

Response:

[
  {
    "id": 1,
    "firstname": "John",
    "lastname": "Smith",
    "email": "john@yourmsp.com",
    "userType": "TECHNICIAN"
  }
]

Note the id field. Use this as assignedAppUserId when creating tickets.

Looking up ticket form IDs:

If you use custom ticket forms in NinjaOne:

GET https://app.ninjarmm.com/api/v2/ticketing/ticket-form

Use the returned id as ticketFormId in your ticket creation payload.

Complete ticket creation payload with assignment:

{
  "clientId": 2,
  "subject": "Monthly Patching - March 2026",
  "status": "NEW",
  "priority": "MEDIUM",
  "type": "PROBLEM",
  "ticketFormId": 1,
  "assignedAppUserId": 1,
  "description": {
    "htmlBody": "<h3>Monthly Patching...</h3>"
  },
  "tags": ["patching", "scheduled", "monthly"]
}

Available ticket fields:

FieldTypeDescription
clientIdnumberOrganization ID (required)
subjectstringTicket subject line
statusstringNEW, OPEN, PENDING, CLOSED
prioritystringNONE, LOW, MEDIUM, HIGH, CRITICAL
typestringPROBLEM, QUESTION, INCIDENT, TASK
assignedAppUserIdnumberTechnician to assign to
ticketFormIdnumberCustom form ID
tagsstring[]Array of tag strings
description.htmlBodystringHTML ticket body

Routing based on organization:

For multi-client MSPs, different clients may have different assigned technicians. Handle this in your Code node:

const assignmentMap = {
  1: { techId: 1, formId: 1 },   // Internal IT -> John
  2: { techId: 2, formId: 1 },   // Client Alpha -> Jane
  3: { techId: 1, formId: 2 },   // Client Beta -> John (different form)
  6: { techId: 3, formId: 1 },   // Client Gamma -> Mike
};

const orgId = $json.orgId;
const assignment = assignmentMap[orgId] || { techId: 1, formId: 1 };

return [{ json: {
  ...($json),
  assignedAppUserId: assignment.techId,
  ticketFormId: assignment.formId
}}];

This keeps all routing logic in one place. When a technician leaves or clients get reassigned, you update the map in a single Code node.

Look Up Your IDs

Create a simple workflow: Manual Trigger > HTTP Request (GET /api/v2/users). Run it and note the user IDs for your technicians. Then do the same for GET /api/v2/ticketing/ticket-form. Save these IDs somewhere accessible - you will use them in every ticket creation workflow.

Core Insights

  • NinjaOne has no native recurring ticket feature - n8n with a Schedule Trigger fills this gap completely
  • Always test workflows with a Manual Trigger before switching to a Schedule Trigger to avoid creating test tickets in production
  • The Code node transforms raw device data into structured HTML ticket content with headers, tables, and task checklists
  • Multi-organization support uses a single workflow that loops over clients, creating separate tickets for each
  • Keep a routing map (org ID to technician ID) in a Code node so reassignments require changing only one place
  • Cron expressions like "0 8 1-7 * 1" (first Monday of month) handle business-day scheduling that simple intervals cannot