CronJobs API
The CronJobs API drives scheduled automation. A separate job-worker container executes the jobs (cron/interval, distributed locking, multi-instance) — from simple automations (create/update ticket, webhook, assignment) to the system monitors (SLA monitor, escalation and cleanup jobs). There are 28 action types and 23 built-in templates.
Architecture
Backend API (/api/cronjobs) CRUD, RBAC, audit, config │ persists CronJob/JobExecution in Postgres ▼
job-worker (separate container)
• Scheduler — cron/interval • Queue (BullMQ) — execution queue • 28 action types • Distributed Lock (Redis): cronjob:lock:{jobId}
• Heartbeat (15s) — multi-instance detection / worker status • Restricted DB access: only CronJob/JobExecution │
▼ mutations via backend internal API, notifications via notification-worker
Endpoints
Job Management /api/cronjobs
| Method | Endpoint | Permission |
|---|---|---|
GET | / | cronjobs.view |
GET | /:id | cronjobs.view |
POST | / | cronjobs.create |
PUT | /:id | cronjobs.edit |
DELETE | /:id | cronjobs.delete (critical) |
POST | /:id/restore | cronjobs.restore + cronjobs.viewDeleted |
GET | /stats/summary | cronjobs.view |
GET | /templates/list | cronjobs.view |
GET | /activity | cronjobs.view |
GET / and GET /:id additionally accept ?includeDeleted=true to include soft-deleted jobs — this requires the dedicated permission cronjobs.viewDeleted (otherwise 403).
Restoring requires two permissions: cronjobs.restore for the action and cronjobs.viewDeleted for access to the trash — whoever may not see the trash cannot pull anything out of it. No other mutation applies to a deleted job: update, enable/disable, manual run, dry run, bulk actions and retrying an execution all answer 404 while it sits in the trash. The execution history survives the deletion — visible, but not repeatable.
Execution & History
| Method | Endpoint | Permission |
|---|---|---|
PATCH | /:id/toggle | cronjobs.enableDisable |
POST | /:id/execute | cronjobs.executeManually |
POST | /:id/dry-run | cronjobs.dryRun |
GET | /executions/list | cronjobs.view |
GET | /executions/:id | cronjobs.view |
POST | /executions/:id/retry | cronjobs.retry (critical) |
POST | /bulk/enable · /bulk/disable | cronjobs.enableDisable |
POST | /bulk/delete | cronjobs.delete |
Workers & Config
| Method | Endpoint | Permission |
|---|---|---|
GET | /workers/status | cronjobs.view |
POST | /workers/pause-all · /workers/resume-all | cronjobs.pauseWorkers (critical) |
POST | /workers/:instanceId/pause · /resume | cronjobs.pauseWorkers |
POST | /workers/:instanceId/hide · /unhide | cronjobs.hideWorkers |
GET / PUT | /api/cronjobs/config | cronjobs.view / cronjobs.edit |
Data Model
CronJob {
id, name (unique),
category: ESCALATION | NOTIFICATION | REPORTING | MAINTENANCE | MONITORING | WORKFLOW | CUSTOM,
status: ENABLED | DISABLED | RUNNING | ERROR,
trigger: Json, // { type, schedule }
actions: Json, // [{ type, parameters }]
filters: Json, // entity scope (e.g. ticketStatuses)
runConditions: Json, // additional conditions
dependencies: String[], // job IDs that must succeed first
timeoutMinutes, maxRetries, runOnStartup,
lastExecutedAt, nextExecutionAt, executionCount, failureCount, avgDurationMs,
createdById, deletedAt // Soft-Delete
}
JobExecution {
id, cronJobId,
status: PENDING | RUNNING | COMPLETED | FAILED | CANCELLED,
triggeredBy: SCHEDULE | MANUAL | EVENT | CONDITION,
startedAt, completedAt, durationMs, retryCount,
results: Json, // [{ action, status, metadata }]
errorMessage?, workerId
}
Triggers
| type | Description |
|---|---|
cron | schedule.cronExpression (z.B. "0 8 * * 1-5") |
interval | schedule.intervalMinutes or intervalDays |
condition | condition-based (runConditions, see below) |
// Cron
{ "trigger": { "type": "cron", "schedule": { "cronExpression": "*/30 * * * *" } } }
// Interval
{ "trigger": { "type": "interval", "schedule": { "intervalMinutes": 5 } } }
Action Types (28)
Automation
| type | Description |
|---|---|
create_ticket | Create a ticket (e.g. recurring maintenance) |
update_ticket | Update tickets by filter (status/priority) |
assign_agent | Assign agent (strategy "specific": a named agent) |
assign_group | Assign group |
webhook | HTTP request to external URL (SSRF protection, circuit breaker, retry) |
Monitors & Escalation
| type | Description |
|---|---|
sla_monitor | Check SLA deadlines, warnings/breach/escalation |
lifecycle_stale_entity_reminder | Inactivity reminder for TICKET/PROBLEM/INCIDENT (assignee→lead→manager, only to recipients who may see the record) — never changes status or SLA |
ticket_hold_reminder_check | Reactivate on-hold tickets whose reminder is due |
stale_cascading_reminder | Stale resolution chains (incident/problem resolved, child open) |
major_incident_update_reminder | Major incidents with overdue nextUpdateETA |
data_breach_deadline_check | GDPR Art. 33: 72h deadline (reminder 48h, escalation 72h) |
inventory_due_monitor | Inventory due dates: warning 3 and 1 day ahead, then overdue (once per milestone) |
expiry_monitor | Expiry of assets/licenses/contracts (milestones 30/7/3/0 days) |
handover_return_reminder | Asset return reminders (24h/1h/overdue) |
pending_assignment_retry | Retry auto-assignment (group without agent, e.g. at capacity) |
workload_sync | Recompute agent workload counters (for assignment strategies) |
lifecycle_auto_close | Time-close due RESOLVED tickets (with advance warning) — opt-in per entity |
lifecycle_wc_auto_resolve | Set unanswered WAITING_CUSTOMER tickets to RESOLVED after a deadline (step before auto-close) — ticket only. A parent ticket with open sub-tickets is left as it is and counted as skipped in the run (see the tickets API). |
lifecycle_reopen_escalation | Escalation on too-frequent reopen (reopenCount ≥ threshold), only to recipients who may see the record |
Maintenance / Cleanup
| type | Description |
|---|---|
holiday_autoimport | Create German public holidays (current + next year) for every business-hours configuration in use — calculated including movable holidays, idempotent (no duplicates) |
asset_model_clustering | Cluster similar manufacturer/model spellings (admin review) |
attachment_cleanup | Stuck scans, file retention, orphaned and infected files (file system and virus quarantine) |
retention_purge | Bundled GDPR retention: enforces all time-based database retention periods in ONE run, from audit events (two-stage) to the auto-anonymization of archived users. Targets and periods: see Privacy & GDPR. |
audit_chain_verify | Nightly full verification of the tamper-evident audit hash chains (per-org continuity, purge anchor, purge plausibility); verified section by section, parameter windowSize (events per section, default 100,000, allowed 1,000–250,000); on any finding a CRITICAL alert to all audit.enterpriseView holders |
digest_dispatch | Sends due email digests (bundled delivery mode — hourly/daily/weekly, ONE bundled email per recipient in their timezone) and flushes orphaned bulk-batch items; no-op without digest opt-ins (see notifications page) |
report_schedule_check | Starts due report schedules (one execution per export format) and finalises finished runs with the completion mail — without this job, scheduled reports do NOT run |
push_retry | Retry failed web push |
entra_id_sync | Sync the users of the Entra ID base group: create, update, map roles, lock accounts that left the group or were disabled in Entra ID; without a configured and active Entra ID integration it does nothing |
Boundary rule: time-based retention of database rows runs via retention_purge — one place for all periods; anything touching files or virus scans stays with attachment_cleanup. Escalation runs via sla_monitor and lifecycle_stale_entity_reminder, capacity via workload_sync, return reminders via handover_return_reminder.
Built-in Templates (23)
GET /api/cronjobs/templates/list — returns preconfigured templates (fields: id, name, description, category, isBuiltIn, tags, template). For each template the backend start creates the matching built-in job if it is missing — ENABLED, except Asset Model Deduplication, which starts DISABLED because it depends on the features in use. The lifecycle jobs only take effect once the admin activates them per entity in the lifecycle config; the digest job only takes effect once users have chosen the digest; the Entra ID sync only takes effect once the Entra ID integration is configured and active. Schedules:
| Template | Category | action | Schedule |
|---|---|---|---|
| SLA Monitor | ESCALATION | sla_monitor | every 2 min, runOnStartup |
| Stale Entity Reminder | ESCALATION | lifecycle_stale_entity_reminder | 01:30 |
| Ticket Hold Reminder (follow-up) | MONITORING | ticket_hold_reminder_check | every 5 min |
| Major Incident Update Reminder | MONITORING | major_incident_update_reminder | every 5 min |
| Stale Cascading Resolution Reminder | MONITORING | stale_cascading_reminder | 08:00 |
| DSGVO Data Breach Deadline Monitor | MONITORING | data_breach_deadline_check | every 30 min |
| Inventory Due Monitoring | ESCALATION | inventory_due_monitor | hourly |
| Expiry Monitor | MONITORING | expiry_monitor | 07:00 |
| Asset Return Reminders | MONITORING | handover_return_reminder | every 30 min |
| Pending Assignment Retry | MAINTENANCE | pending_assignment_retry | every 5 min |
| Agent Workload Sync | MAINTENANCE | workload_sync | every 5 min |
| WebPush Retry | MAINTENANCE | push_retry | every 5 min |
| Notification Digest Dispatch | NOTIFICATION | digest_dispatch | every 15 min |
| Report Schedule Check | MAINTENANCE | report_schedule_check | every minute |
| Retention Purge (GDPR) | MAINTENANCE | retention_purge | 03:00 |
| Audit Chain Verify | MONITORING | audit_chain_verify | 04:15 (after retention_purge) |
| Attachment Cleanup | MAINTENANCE | attachment_cleanup | 03:00 |
| Asset Model Deduplication | MAINTENANCE | asset_model_clustering | Sun 02:00 · DISABLED |
| Holiday Auto-Import | MAINTENANCE | holiday_autoimport | yearly 1 Nov 04:00, runOnStartup |
| Entra ID User Sync | MAINTENANCE | entra_id_sync | 01:00 |
| Lifecycle Auto-Close | MAINTENANCE | lifecycle_auto_close | 02:30 |
| Lifecycle WC-Auto-Resolve | MAINTENANCE | lifecycle_wc_auto_resolve | 02:00 |
| Reopen Escalation | ESCALATION | lifecycle_reopen_escalation | 03:00 |
Built-in jobs: created automatically at start
The built-in jobs are reconciled on every backend start: missing built-in jobs are created from their template (ENABLED or DISABLED per default, see above), existing jobs are NEVER changed. Admin-changed schedules, parameters and the enabled status are therefore preserved. An update brings new standard jobs along without a manual step.
Create Job
POST /api/cronjobs
{
"name": "SLA Monitor - All Entities",
"category": "ESCALATION",
"description": "Check SLA deadlines and trigger escalations",
"trigger": { "type": "interval", "schedule": { "intervalMinutes": 2 } },
"actions": [ { "type": "sla_monitor", "parameters": { "entityType": null, "batchSize": 500 } } ],
"timeoutMinutes": 5,
"maxRetries": 3,
"runOnStartup": true
}
batchSize is the PAGE SIZE, not the ceiling of a run: the monitor pages through the due backlog until nothing is left. If it does stop early (a guard against endless runs), the job result and the log say so.
// Response 201
{
"id": "clx...",
"name": "SLA Monitor - All Entities",
"category": "ESCALATION",
"status": "DISABLED",
"trigger": { "type": "interval", "schedule": { "intervalMinutes": 2 } },
"nextExecutionAt": null,
"createdAt": "2026-01-27T23:00:00.000Z"
}
New jobs start as DISABLED — enable via PATCH /:id/toggle (cronjobs.enableDisable).
Execution, Dry-Run & Retry
# Run manually (cronjobs.executeManually)
POST /api/cronjobs/:id/execute { "reason": "Testing config" } # -> 202 { executionId }
# Dry-Run: shows affected entities, makes NO changes
POST /api/cronjobs/:id/dry-run
# Retry a failed execution (only status FAILED)
POST /api/cronjobs/executions/:id/retry { "reason": "Network issue resolved" }
# History (filter jobId/status, pagination)
GET /api/cronjobs/executions/list?status=FAILED&limit=20
Status Values
| Job (status) | Execution (status) |
|---|---|
ENABLED — active, runs on schedule | PENDING — queued |
DISABLED — disabled | RUNNING |
RUNNING — currently executing | COMPLETED |
ERROR — last run failed | FAILED — retryable |
CANCELLED — discarded/terminated (e.g. orphaned PENDING execution) |
Self-Healing
Jobs are (partially) self-healing — a job does not stay stuck permanently:
- ERROR is not terminal: ERROR only means "last run failed". The job stays active, keeps being scheduled (nextRunAt is recomputed) and heals back on the next successful run.
- No jobs stuck in RUNNING: On error/timeout the distributed lock is released; a job does not get stuck falsely in RUNNING.
- Catch-up on (re)start: On job-worker startup, overdue jobs (nextRunAt in the past) are detected, queued as a PENDING execution and rescheduled — they do not stay "overdue forever". A distributed lock ensures only ONE instance runs the catch-up.
- runOnStartup: Jobs with this flag (e.g. SLA Monitor) get an execution on every worker start.
- Orphan-Cleanup: Jobs present in the queue (BullMQ) but deleted from the DB are removed on startup.
"Partially self-healing": the underlying failure cause is not resolved automatically — the job simply retries on its next schedule (or within a run up to maxRetries) and leaves the ERROR state on success. Persistent failures should be inspected via execution history/activity.
Worker Management
Multiple job-worker instances register via heartbeat (instanceId). GET /workers/status returns health (null when offline) and executions per instance; the queue counters are their own field queue inside health — waiting, active, completed, failed, delayed — and are null while the instance queue is not yet initialized. For maintenance, workers can be paused (globally or per instance). Pause/resume is a critical, audited action and requires a reason (min. 10 chars).
POST /api/cronjobs/workers/pause-all { "reason": "Database maintenance window" }
# -> { "pausedWorkers": ["job-worker-abc123"], "failedWorkers": [] }
Filters & Run Conditions
filters scope the target entities; runConditions are additional preconditions. A runCondition carries exactly type + duration (minimum age in minutes) — type is ticket_age or change_pending_approval.
{
"filters": { "ticketStatuses": ["OPEN", "IN_PROGRESS"], "ticketPriorities": ["LOW", "MEDIUM"] },
"runConditions": [ { "type": "ticket_age", "duration": 4320 } ]
}
ticketStatuses and ticketPriorities accept only valid ticket statuses and priorities; an empty list acts as no filter, an unknown value makes the condition check fail — the job is then not triggered. ticket_age counts tickets older than duration that match the filters; change_pending_approval counts changes in status PENDING_APPROVAL and does not evaluate the filters. Both conditions are met as soon as at least one record matches.
Webhook Security
- SSRF: blocks localhost, private IPs (10/172.16-31/192.168), link-local (169.254), cloud metadata (169.254.169.254)
- Circuit Breaker + Rate-Limit + Retry (exponential backoff) + configurable timeout
Error Codes
| Error | HTTP |
|---|---|
CRONJOB_NOT_FOUND | 404 |
JOB_NAME_EXISTS | 409 |
JOB_CURRENTLY_RUNNING | 409 |
CANNOT_DELETE_RUNNING_JOB | 400 |
CAN_ONLY_RETRY_FAILED_EXECUTIONS | 400 |
JOB_EXECUTION_NOT_FOUND | 404 |
GLOBAL_PAUSE_ACTIVE | 409 |
GLOBAL_PAUSE_ACTIVE: while a global worker pause is active, no single instance can be resumed — the global pause has to be lifted as a whole first (resume-all).
- ✓ Separate job-worker, distributed lock
- ✓ 28 actions, 23 built-in templates
- ✓ SLA monitor as a job, every 2 minutes
- ✓ Built-in jobs are created automatically at start; admin changes are preserved
cronjobs.view/create/edit/delete/restore/viewDeletedcronjobs.enableDisable/executeManually/dryRun/retrycronjobs.pauseWorkers/hideWorkers
Auth/role model: Permissions & RBAC
- SLA Management API – the sla_monitor runs as a job
- Notification System – reminder/escalation notifications
- Workflows API – event-/form-triggered automation
- Reports & Custom Reports API – retention of report executions (retention_purge)
- Privacy & GDPR – retention_purge (retention periods & audit purge)
- Reopen & Lifecycle Governance – lifecycle_auto_close / lifecycle_reopen_escalation
- Tickets API – sub-tickets: why lifecycle_wc_auto_resolve skips parent tickets with open sub-tickets