العربية • 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.
Escalated for Django consumes translations from the central
escalated-locale
PyPI package. The package ships canonical JSON catalogues alongside
pre-compiled gettext artifacts at
escalated_locale/locale/<lang>/LC_MESSAGES/django.{po,mo}, which
plug directly into Django's translation system via LOCALE_PATHS.
The package is installed automatically as a dependency of
escalated-django. Wire it into your project's settings.py:
# settings.py
from escalated.locale_paths import get_locale_paths
LOCALE_PATHS = get_locale_paths(
# Optional: your project's own override directory (highest priority)
BASE_DIR / "locale",
)LOCALE_PATHS is searched in order, so the layering becomes:
- Your project's
locale/(if you passed one toget_locale_paths) - The plugin-local
escalated/locale/override directory - The central
escalated_locale/locale/baseline
Translation contributions should be sent to
escalated-locale.
The plugin-local escalated/locale/ directory is reserved for
Django-specific overrides that shouldn't ship to other framework
plugins.
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",
}Agent actions taken in the cloud portal come back through a signed webhook. Set the site's signing secret (shown on the connected site in the cloud dashboard) and give the cloud this URL:
ESCALATED = {
"MODE": "synced",
"HOSTED_API_URL": "https://cloud.escalated.dev/api/v1",
"HOSTED_API_KEY": "your-api-key",
"HOSTED_SIGNING_SECRET": "whsec_...", # webhook URL: https://your-app.example.com/support/cloud/webhook/
}The receiver verifies X-Escalated-Signature (HMAC-SHA256 of the raw body), ignores replayed
event_ids for 24 hours, and applies subject, description, priority and status from
ticket.updated / ticket.status_changed to the local ticket whose reference matches the
projection's external_id. Changes run through the local driver, so your signal handlers fire,
and are not echoed back to the cloud.
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,
}Escalated works with any AUTH_USER_MODEL primary key type — integer, UUID, or
string. References to your users via ForeignKey(AUTH_USER_MODEL) (assignee,
follower, author, etc.) already match your user PK automatically. The remaining
host-user references — Contact.user_id and the generic-relation object_id
columns for ticket requester, activity causer, satisfaction rated_by, and API
token owner — are stored as CharField, so they hold an integer id (as its
string form) or a UUID/string id transparently. No configuration is required;
just run migrations.
A ticket has a requester (who raised it) and a subject line (free text). You can also attach host-app entities the ticket is about — a project, customer, or asset — so agents see context and can jump back into your app.
Implement the presentation contract on any model (or use the mixin for defaults):
from django.db import models
from escalated.contracts import TicketSubjectMixin
class Project(models.Model, TicketSubjectMixin):
name = models.CharField(max_length=255)
def ticket_subject_subtitle(self):
return f"Project · {self.customer.name}"
def ticket_subject_url(self):
return reverse("projects:detail", args=[self.pk])Attach, detach, or replace subjects on a ticket:
ticket.attach_subject(project, role="project")
ticket.attach_subject(customer, role="account")
ticket.sync_subjects([project, (customer, "account")])
ticket.detach_subject(project)Each link is serialized on the ticket as
{ type, id, role, title, subtitle, url, color, icon, missing }.
object_id is stored as a string so integer, UUID, and string primary keys all work.
To allow attaching via the agent or REST API (and block arbitrary model resolution), list permitted models in settings:
ESCALATED = (
{
# ...
"TICKET_SUBJECT_TYPES": [
"myapp.Project",
"myapp.Customer",
],
},
)Leave TICKET_SUBJECT_TYPES empty to disable API attach; programmatic
attach_subject() still works for any model when the allowlist is empty.
Agent/admin routes: POST /…/tickets/<id>/subjects, DELETE /…/tickets/<id>/subjects/<link_id>.
REST API: POST /tickets/<reference>/subjects, DELETE /tickets/<reference>/subjects/<link_id>.
# 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.
Host projects can add custom buttons to the agent ticket screen and handle clicks with a normal Django signal receiver. Register actions in settings:
# settings.py
ESCALATED = {
# ...
"TICKET_ACTIONS": [
{
"key": "sync-crm",
"label": "Sync CRM",
"variant": "primary", # primary | secondary | danger
"confirmation": "Sync this ticket to the CRM?",
"metadata": {"icon": "refresh-cw"},
# visible / enabled may be a bool or a callable(ticket, user)
"enabled": lambda ticket, user: not ticket.metadata.get("crm_synced"),
},
],
}Visible actions are exposed on the agent ticket page as customActions and on the
API ticket detail response as custom_actions (each with a url and method).
Triggering one (POST /support/agent/tickets/<id>/actions/<key>/ or the API route
/<prefix>/tickets/<reference>/actions/<key>/) validates the action is visible
(404) and enabled (403), then sends the custom_action_triggered signal:
from django.dispatch import receiver
from escalated.signals import custom_action_triggered
@receiver(custom_action_triggered)
def on_custom_action(sender, ticket, user, action_key, payload, metadata, **kwargs):
if action_key != "sync-crm":
return
# your handlerEscalated also records an internal note on the ticket whenever an action fires, for auditability.
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]"
pytestAdmin-only broadcast feature for sending Markdown emails to contacts. Off by default — set ESCALATED["enable_newsletters"] = True to turn it on.
# settings.py
ESCALATED = {
"enable_newsletters": True,
"app_url": "https://support.example.com",
"newsletter_default_from": "hi@example.com",
"newsletter_default_theme": "default",
"newsletter_brand_accent": "#2563eb",
"newsletter_brand_physical_address": "Acme Inc. · 123 Main St · Springfield USA",
# Plug in a Markdown converter (markdown package, mistune, etc.)
"newsletter_markdown_renderer": lambda md: __import__("markdown").markdown(md),
}Schedule the dispatcher every minute (Celery beat, django-q, cron, etc.):
from escalated.services.newsletter import NewsletterDispatcher
NewsletterDispatcher().dispatch_batch()Custom themes go in escalated/templates/escalated/newsletter_themes/<slug>.html. The context receives subject, body (pre-rendered safe HTML), unsubscribe_url, view_in_browser_url, and brand.
By default Escalated's tables live on your project's default database. Name a
different one to keep them somewhere else — a schema shared with a legacy
system, a multi-tenant split, a separate reporting store, or simply out of your
primary database.
Django has no per-model connection setting; routing is the mechanism, so this takes two pieces of configuration:
# settings.py
DATABASES = {
"default": {...},
"support": {...},
}
DATABASE_ROUTERS = ["escalated.routers.EscalatedRouter"]
ESCALATED = {
"DATABASE": "support",
}Leave DATABASE unset (or omit the router) for the default database — the
historical behaviour, and what almost every project wants. The router returns
"no opinion" for everything when nothing is configured, so adding it to a
project that has not set DATABASE changes nothing.
Migrate Escalated's tables onto it with:
python manage.py migrate escalated --database=supportEscalated's relations to AUTH_USER_MODEL are declared db_constraint=False.
Django cannot create a foreign key across databases, so while those
constraints existed the setting could not work at all — migrate failed on the
first user-referencing table.
This does not change Django's behaviour. on_delete is implemented in Python by
the deletion collector, not by a database ON DELETE clause, and Django never
emits one; cascades and SET_NULL work exactly as before. What is given up is
the database's own referential check against direct SQL writes that bypass the
ORM — which is also the only way the two sides could ever live apart.
Migration 0028_drop_host_user_fk_constraints applies it. It runs on every
install, including single-database ones.
Your user table does not move. The router has no opinion about it, so it stays wherever your project keeps it.
Single-object traversal works: ticket.assigned_to is resolved with a second
query, routed by the router, so a ticket on one database resolves its assignee
on another.
A join cannot span two databases — nothing can do that. So a queryset that
filters or select_relateds through the user model, like
Ticket.objects.select_related("assigned_to") or
.filter(assigned_to__email=...), is not available when the two are separated.
Filter on the key instead (assigned_to_id=...) and load the users in a second
query.
MIT - Copyright (c) Escalated.dev. See LICENSE.