العربية • Deutsch • English • Español • Français • Italiano • 日本語 • 한국어 • Nederlands • Polski • Português (BR) • Русский • Türkçe • 简体中文
A full-featured, embeddable support ticket system for Django. Drop it into any app — get a complete helpdesk with SLA tracking, escalation rules, agent workflows, and a customer portal. No external services required.
escalated.dev — Learn more, view demos, and compare Cloud vs Self-Hosted options.
Three hosting modes. Run entirely self-hosted, sync to a central cloud for multi-app visibility, or proxy everything to the cloud. Switch modes with a single config change.
- Ticket lifecycle — Create, assign, reply, resolve, close, reopen with configurable status transitions
- SLA engine — Per-priority response and resolution targets, business hours calculation, automatic breach detection
- Escalation rules — Condition-based rules that auto-escalate, reprioritize, reassign, or notify
- Agent dashboard — Ticket queue with filters, bulk actions, internal notes, canned responses
- Customer portal — Self-service ticket creation, replies, and status tracking
- Admin panel — Manage departments, SLA policies, escalation rules, tags, and view reports
- File attachments — Drag-and-drop uploads with configurable storage and size limits
- Activity timeline — Full audit log of every action on every ticket
- Email notifications — Configurable per-event notifications with webhook support
- Department routing — Organize agents into departments with auto-assignment (round-robin)
- Tagging system — Categorize tickets with colored tags
- Inertia.js + Vue 3 UI — Shared frontend via
@escalated-dev/escalated - Ticket splitting — Split a reply into a new standalone ticket while preserving the original context
- Ticket snooze — Snooze tickets with presets (1h, 4h, tomorrow, next week);
python manage.py wake_snoozed_ticketsmanagement command auto-wakes them on schedule - Saved views / custom queues — Save, name, and share filter presets as reusable ticket views
- Embeddable support widget — Lightweight
<script>widget with KB search, ticket form, and status check - Email threading — Outbound emails include proper
In-Reply-ToandReferencesheaders for correct threading in mail clients - Branded email templates — Configurable logo, primary color, and footer text for all outbound emails
- Real-time broadcasting — Opt-in broadcasting via Django Channels with automatic polling fallback
- Knowledge base toggle — Enable or disable the public knowledge base from admin settings
- Python 3.10+
- Django 4.2+
- Node.js 18+ (for frontend assets)
pip install escalated-django
npm install @escalated-dev/escalatedINSTALLED_APPS = [
# ...
'django.contrib.contenttypes',
'inertia',
'escalated',
]from django.urls import path, include
urlpatterns = [
# ...
path("support/", include("escalated.urls")),
]python manage.py migrate escalatedVisit /support — you're live.
Escalated uses Inertia.js with Vue 3. The frontend components are provided by the @escalated-dev/escalated npm package.
Add the Escalated package to your Tailwind content config so its classes aren't purged:
// tailwind.config.js
content: [
// ... your existing paths
'./node_modules/@escalated-dev/escalated/src/**/*.vue',
],Add the Escalated pages to your Inertia page resolver:
// frontend/main.js
import { createApp, h } from 'vue'
import { createInertiaApp } from '@inertiajs/vue3'
createInertiaApp({
resolve: name => {
if (name.startsWith('Escalated/')) {
const escalatedPages = import.meta.glob(
'../node_modules/@escalated-dev/escalated/src/pages/**/*.vue',
{ eager: true }
)
const pageName = name.replace('Escalated/', '')
return escalatedPages[`../node_modules/@escalated-dev/escalated/src/pages/${pageName}.vue`]
}
const pages = import.meta.glob('./pages/**/*.vue', { eager: true })
return pages[`./pages/${name}.vue`]
},
setup({ el, App, props, plugin }) {
createApp({ render: () => h(App, props) })
.use(plugin)
.mount(el)
},
})Register the EscalatedPlugin to render Escalated pages inside your app's layout — no page duplication needed:
import { EscalatedPlugin } from '@escalated-dev/escalated'
import BaseLayout from '@/layouts/BaseLayout.vue'
createInertiaApp({
setup({ el, App, props, plugin }) {
createApp({ render: () => h(App, props) })
.use(plugin)
.use(EscalatedPlugin, {
layout: BaseLayout,
theme: {
primary: '#3b82f6',
radius: '0.75rem',
}
})
.mount(el)
},
})Your layout component must accept a #header slot and a default slot. Escalated will render its sub-navigation in the header and page content in the default slot. Without the plugin, Escalated uses its own standalone layout.
See the @escalated-dev/escalated README for full theming documentation and CSS custom properties.
Everything stays in your database. No external calls. Full autonomy.
ESCALATED = {
"MODE": "self_hosted",
}Local database + automatic sync to cloud.escalated.dev for unified inbox across multiple apps. If the cloud is unreachable, your app keeps working — events queue and retry.
ESCALATED = {
"MODE": "synced",
"HOSTED_API_URL": "https://cloud.escalated.dev/api/v1",
"HOSTED_API_KEY": "your-api-key",
}All ticket data proxied to the cloud API. Your app handles auth and renders UI, but storage lives in the cloud.
ESCALATED = {
"MODE": "cloud",
"HOSTED_API_URL": "https://cloud.escalated.dev/api/v1",
"HOSTED_API_KEY": "your-api-key",
}All three modes share the same views, UI, and business logic. The driver pattern handles the rest.
Add to your settings.py:
ESCALATED = {
"MODE": "self_hosted", # self_hosted | synced | cloud
"TABLE_PREFIX": "escalated_",
"ROUTE_PREFIX": "support",
"DEFAULT_PRIORITY": "medium",
# Tickets
"ALLOW_CUSTOMER_CLOSE": True,
"AUTO_CLOSE_RESOLVED_AFTER_DAYS": 7,
"MAX_ATTACHMENTS": 5,
"MAX_ATTACHMENT_SIZE_KB": 10240,
# SLA
"SLA": {
"ENABLED": True,
"BUSINESS_HOURS_ONLY": False,
"BUSINESS_HOURS": {
"START": "09:00",
"END": "17:00",
"TIMEZONE": "UTC",
"DAYS": [1, 2, 3, 4, 5],
},
},
# Notifications
"NOTIFICATION_CHANNELS": ["email"],
"WEBHOOK_URL": None,
# Cloud/Synced mode
"HOSTED_API_URL": "https://cloud.escalated.dev/api/v1",
"HOSTED_API_KEY": None,
}# Check SLA deadlines and fire breach notifications
python manage.py check_sla
# Evaluate escalation rules against open tickets
python manage.py evaluate_escalations
# Auto-close tickets resolved more than N days ago
python manage.py close_resolved --days 7
# Purge old activity logs
python manage.py purge_activities --days 90Schedule these with cron, Celery Beat, or django-crontab for automated enforcement.
All routes use the configurable prefix (default: support).
| Route | Method | Description |
|---|---|---|
/support/tickets/ |
GET | Customer ticket list |
/support/tickets/create/ |
GET | New ticket form |
/support/tickets/<id>/ |
GET | Ticket detail |
/support/agent/ |
GET | Agent dashboard |
/support/agent/tickets/ |
GET | Agent ticket queue |
/support/agent/tickets/<id>/ |
GET | Agent ticket view |
/support/admin/reports/ |
GET | Admin reports |
/support/admin/departments/ |
GET | Department management |
/support/admin/sla-policies/ |
GET | SLA policy management |
/support/admin/escalation-rules/ |
GET | Escalation rule management |
/support/admin/tags/ |
GET | Tag management |
/support/admin/canned-responses/ |
GET | Canned response management |
/support/agent/tickets/bulk/ |
POST | Bulk actions on multiple tickets |
/support/agent/tickets/<id>/follow/ |
POST | Follow/unfollow a ticket |
/support/agent/tickets/<id>/macro/ |
POST | Apply a macro to a ticket |
/support/agent/tickets/<id>/presence/ |
POST | Update presence on a ticket |
/support/agent/tickets/<id>/pin/<reply_id>/ |
POST | Pin/unpin an internal note |
/support/tickets/<id>/rate/ |
POST | Submit satisfaction rating |
Connect to ticket lifecycle events:
from escalated.signals import ticket_created, ticket_resolved
@receiver(ticket_created)
def on_ticket_created(sender, ticket, user, **kwargs):
print(f"New ticket: {ticket.reference}")
@receiver(ticket_resolved)
def on_ticket_resolved(sender, ticket, user, **kwargs):
print(f"Resolved: {ticket.reference}")Available signals: ticket_created, ticket_updated, ticket_status_changed, ticket_assigned, ticket_unassigned, ticket_priority_changed, ticket_escalated, ticket_resolved, ticket_closed, ticket_reopened, reply_created, internal_note_added, sla_breached, sla_warning, tag_added, tag_removed, department_changed.
Escalated supports framework-agnostic plugins built with the Plugin SDK. Plugins are written once in TypeScript and work across all Escalated backends.
- Node.js 20+
@escalated-dev/plugin-runtimeinstalled in your project
npm install @escalated-dev/plugin-runtime
npm install @escalated-dev/plugin-slack
npm install @escalated-dev/plugin-jira# settings.py
ESCALATED = {
# ... existing config ...
"SDK_ENABLED": True,
}SDK plugins run as a long-lived Node.js subprocess managed by @escalated-dev/plugin-runtime, communicating with Django over JSON-RPC 2.0 via stdio. Every ticket lifecycle signal is dual-dispatched — first to Django signal handlers, then forwarded to the plugin runtime.
import { definePlugin } from '@escalated-dev/plugin-sdk'
export default definePlugin({
name: 'my-plugin',
version: '1.0.0',
actions: {
'ticket.created': async (event, ctx) => {
ctx.log.info('New ticket!', event)
},
},
})- Plugin SDK — TypeScript SDK for building plugins
- Plugin Runtime — Runtime host for plugins
- Plugin Development Guide — Full documentation
See the detailed SDK Plugin Bridge section below for the full architecture, supported ctx.* callbacks, hook event mapping, and resilience documentation.
The Plugin Bridge connects your Django app to the Node.js
@escalated-dev/plugin-runtime process via JSON-RPC 2.0 over stdio.
It enables SDK plugins — JavaScript/TypeScript packages that hook into
ticket lifecycle events, expose custom API endpoints, and persist data
through the host ORM — without requiring any Node.js code in your Django
project.
- On startup Django spawns
node @escalated-dev/plugin-runtimeas a long-lived subprocess. - A protocol handshake is performed and plugin manifests are exchanged.
- URL patterns for plugin pages, API endpoints, and webhooks are dynamically registered.
- Every ticket lifecycle signal (created, replied, resolved, etc.) is dual-dispatched — first to the standard Django signal handlers and then to the bridge, which forwards the event to the runtime.
- Plugin code can call back into Django via
ctx.*methods (ctx.tickets.find,ctx.store.set,ctx.config.get, etc.) over the same bidirectional JSON-RPC channel.
- Node.js 18+
@escalated-dev/plugin-runtimeinstalled in your project'snode_modules
1. Install the runtime
npm install @escalated-dev/plugin-runtime2. Enable the bridge in settings
ESCALATED = {
# ... existing config ...
# SDK plugin bridge
"SDK_ENABLED": True,
# Optional overrides (defaults shown):
# "RUNTIME_COMMAND": "node node_modules/@escalated-dev/plugin-runtime/dist/index.js",
# "RUNTIME_CWD": BASE_DIR, # working directory for the Node subprocess
}3. Run the migration
python manage.py migrate escalatedThis creates the escalated_plugin_store table used by ctx.store.* and
ctx.config.* callbacks.
Plugin manifests can declare three types of routes. All are automatically
registered under the configured ROUTE_PREFIX (default support):
| Category | URL pattern | Auth |
|---|---|---|
| Pages | /{prefix}/admin/plugins/{plugin}/{route} |
Admin required |
| Endpoints | /{prefix}/api/plugins/{plugin}/{path} |
Admin required |
| Webhooks | /{prefix}/webhooks/plugins/{plugin}/{path} |
None (public) |
| Method | Description |
|---|---|
ctx.config.all / ctx.config.get / ctx.config.set |
Per-plugin config blob |
ctx.store.get / ctx.store.set / ctx.store.query / ctx.store.insert / ctx.store.update / ctx.store.delete |
Per-plugin key/value store |
ctx.tickets.find / ctx.tickets.query / ctx.tickets.create / ctx.tickets.update |
Ticket ORM access |
ctx.replies.find / ctx.replies.query / ctx.replies.create |
Reply ORM access |
ctx.contacts.find / ctx.contacts.findByEmail / ctx.contacts.create |
User model access |
ctx.tags.all / ctx.tags.create |
Tag access |
ctx.departments.all / ctx.departments.find |
Department access |
ctx.agents.all / ctx.agents.find |
Agent (user) access |
ctx.broadcast.toChannel / ctx.broadcast.toUser / ctx.broadcast.toTicket |
Django Channels broadcast (optional) |
ctx.emit |
Fire another action hook from inside a plugin |
ctx.log |
Log to Django's logger |
Every ticket signal fires a corresponding SDK hook:
| Django signal | SDK hook |
|---|---|
ticket_created |
ticket.created |
ticket_updated |
ticket.updated |
ticket_status_changed |
ticket.status_changed |
ticket_assigned |
ticket.assigned |
ticket_priority_changed |
ticket.priority_changed |
ticket_resolved |
ticket.resolved |
ticket_closed |
ticket.closed |
ticket_escalated |
ticket.escalated |
reply_created |
reply.created |
sla_breached |
sla.breached |
- The bridge is spawned lazily on first use — health-check requests are never slowed down.
- If the Node.js runtime crashes it is automatically restarted with exponential backoff (up to 5 minutes between attempts).
- Action hooks degrade gracefully (drop with a warning) when the runtime is unavailable. Filter hooks return the unmodified value.
- The action queue is capped at 1 000 in-flight entries to prevent memory growth.
- Escalated for Laravel — Laravel Composer package
- Escalated for Rails — Ruby on Rails engine
- Escalated for Django — Django reusable app (you are here)
- Escalated for AdonisJS — AdonisJS v6 package
- Escalated for Filament — Filament v3 admin panel plugin
- Shared Frontend — Vue 3 + Inertia.js UI components
Same architecture, same Vue UI, same three hosting modes — for every major backend framework.
pip install -e ".[dev]"
pytestMIT