العربية • Deutsch • English • Español • Français • Italiano • 日本語 • 한국어 • Nederlands • Polski • Português (BR) • Русский • Türkçe • 简体中文
Embeddable support ticket system for Go applications. Works with standard net/http, Chi, and any router that accepts http.HandlerFunc.
- Tickets with statuses, priorities, types, and SLA tracking
- Replies (public, internal notes, system messages)
- Departments and tags
- SLA policies with per-priority response/resolution targets
- Agent dashboard and admin configuration
- Inertia.js UI or headless JSON API mode
- PostgreSQL and SQLite support
- Framework-agnostic HTTP handlers
- Embedded SQL migrations
- 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); a background goroutine scheduler 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 updates — Server-Sent Events (SSE) endpoint for live ticket updates with automatic polling fallback
- Knowledge base toggle — Enable or disable the public knowledge base from admin settings
- Ticket subjects — Attach host-app entities (Project, Customer, …) a ticket is about; polymorphic links with UI presentation via
TicketSubjectcontract
go get github.com/escalated-dev/escalated-gopackage main
import (
"database/sql"
"log"
"net/http"
"github.com/go-chi/chi/v5"
_ "github.com/lib/pq"
escalated "github.com/escalated-dev/escalated-go"
"github.com/escalated-dev/escalated-go/migrations"
"github.com/escalated-dev/escalated-go/router"
)
func main() {
db, err := sql.Open("postgres", "postgres://localhost/myapp?sslmode=disable")
if err != nil {
log.Fatal(err)
}
// Run migrations
if err := migrations.Migrate(db, "escalated_"); err != nil {
log.Fatal(err)
}
// Configure
cfg := escalated.DefaultConfig()
cfg.DB = db
cfg.RoutePrefix = "/support"
cfg.AdminCheck = func(r *http.Request) bool {
// Your admin check logic
return r.Header.Get("X-Admin") == "true"
}
cfg.AgentCheck = func(r *http.Request) bool {
// Your agent check logic
return r.Header.Get("X-Agent") == "true"
}
cfg.UserIDFunc = func(r *http.Request) models.UserID {
// Extract user ID from session/JWT/etc. models.UserID is a string-
// backed type, so it works for integer and UUID/string user keys alike.
return models.UserID("")
}
esc, err := escalated.New(cfg)
if err != nil {
log.Fatal(err)
}
// Mount routes
r := chi.NewRouter()
router.MountChi(r, esc)
log.Println("Listening on :8080")
http.ListenAndServe(":8080", r)
}package main
import (
"database/sql"
"log"
"net/http"
_ "github.com/mattn/go-sqlite3"
escalated "github.com/escalated-dev/escalated-go"
"github.com/escalated-dev/escalated-go/migrations"
"github.com/escalated-dev/escalated-go/router"
)
func main() {
db, err := sql.Open("sqlite3", "escalated.db")
if err != nil {
log.Fatal(err)
}
// Run SQLite migrations
if err := migrations.MigrateSQLite(db, "escalated_"); err != nil {
log.Fatal(err)
}
// Configure (headless API mode)
cfg := escalated.DefaultConfig()
cfg.DB = db
cfg.UIEnabled = false
// NewSQLite says it explicitly; escalated.New would detect the same thing
// from the connection.
esc, err := escalated.NewSQLite(cfg)
if err != nil {
log.Fatal(err)
}
// Mount on stdlib mux (Go 1.22+)
mux := http.NewServeMux()
router.MountStdlib(mux, esc)
log.Println("Listening on :8080")
http.ListenAndServe(":8080", mux)
}| Field | Type | Default | Description |
|---|---|---|---|
RoutePrefix |
string |
/escalated |
URL prefix for all routes |
UIEnabled |
bool |
true |
Mount Inertia UI routes; false for JSON API only |
TablePrefix |
string |
escalated_ |
Database table name prefix |
AdminCheck |
func(*http.Request) bool |
false |
Returns true for admin users |
AgentCheck |
func(*http.Request) bool |
false |
Returns true for agent users |
UserIDFunc |
func(*http.Request) models.UserID |
"" |
Extracts current user's ID from request |
DB |
*sql.DB |
required | The connection Escalated's own tables live on — see Separate databases |
UserDirectory |
handlers.UserDirectory |
nil | Your bridge to your own users table, for the admin users page. When nil that page lists nothing and the role endpoint responds 501 |
SkillAgentDirectory |
handlers.SkillAgentDirectory |
nil | Lists agents for the Skills form. When nil, available_agents is empty |
TicketSubjectResolver |
func(type, id string) (models.TicketSubject, bool) |
nil | Loads host models for subject presentation |
TicketSubjectTypes |
[]string |
nil | Allowlist of subject_type values for API attach; empty disables API attach |
escalated.New works out whether Config.DB is connected to PostgreSQL or
SQLite and picks the matching store. You do not tell it, and you cannot tell it
wrong:
db, _ := sql.Open("sqlite", "escalated.db") // or "postgres", "pgx", ...
cfg := escalated.DefaultConfig()
cfg.DB = db
esc, err := escalated.New(cfg) // SQLite store, no flag neededDetection reads the driver's import path first — github.com/lib/pq,
github.com/jackc/pgx/..., modernc.org/sqlite, github.com/mattn/go-sqlite3
and the rest are recognised without touching the database. A driver it does not
recognise, such as a tracing or proxy wrapper, is asked directly with one
statement.
A database Escalated has no store for — MySQL, say — is named in the error rather than guessed at, because a wrong guess does not fail at startup. It fails on the first query whose SQL happens to differ.
To skip detection, set the dialect yourself:
cfg.DatabaseDialect = escalated.DialectPostgresescalated.DetectDialect(db) is exported if you want the answer for your own
code — an installer choosing which migrations to run, for instance.
Config.DB is a *sql.DB you open and hand over, so Escalated's tables go
wherever that connection points. Nothing requires it to be your application's
database — it can be a schema shared with a legacy system, a separate reporting
store, or simply a database you would rather not mix support data into:
support, err := sql.Open("postgres", os.Getenv("SUPPORT_DATABASE_URL"))
if err != nil {
log.Fatal(err)
}
cfg := escalated.DefaultConfig()
cfg.DB = support // Escalated's tables
cfg.UserDirectory = myUsers // your users, on your own connectionRun Escalated's migrations against that same connection. Your application's own migrations stay where they are.
This package issues no SQL against a table it does not own. It never queries a
users table, and a test fails the build if any statement in the package
references one.
Host user data arrives through two interfaces you implement — UserDirectory
(the admin users page: list, fetch, flip role flags) and SkillAgentDirectory
(the agent dropdown on the Skills form). Both run on your connection, with
your query, against your schema. Escalated only ever holds ids.
That is also why there is no foreign key from escalated_tickets.requester_id
to your users table: it is a plain unconstrained column, so the two databases
need never meet. No query joins across them, because no database can join across
two connections.
The trade is that Escalated cannot filter or sort its tables by a user's own columns. Skill routing, agent load and assignment all work because they resolve ids from Escalated's tables first and then ask your directory for the people.
A ticket has a requester (who raised it) and a subject line (free text).
Tickets can also be about host-app entities — a Project, Customer, asset — via
polymorphic ticket subjects. Implement models.TicketSubject on your host
types and wire TicketSubjectResolver plus TicketSubjectTypes (for API safety).
type Project struct { ID, Name string }
func (p Project) TicketSubjectTitle() string { return p.Name }
func (p Project) TicketSubjectSubtitle() *string { s := "Project"; return &s }
func (p Project) TicketSubjectURL() *string {
s := "/projects/" + p.ID
return &s
}
func (p Project) TicketSubjectColor() *string { s := "#2563eb"; return &s }
func (p Project) TicketSubjectIcon() *string { s := "folder"; return &s }
cfg.TicketSubjectTypes = []string{"Project"}
cfg.TicketSubjectResolver = func(subjectType, id string) (models.TicketSubject, bool) {
if subjectType != "Project" {
return nil, false
}
p, err := loadProject(id)
if err != nil {
return nil, false
}
return p, true
}Ticket JSON includes subjects[] with {type,id,role,title,subtitle,url,color,icon,missing}.
Attach/detach via POST / DELETE … /api/tickets/{id}/subjects (and agent UI routes).
subject_id is stored as a string (integer, UUID, or other host keys).
Use services.TicketSubjectService for programmatic AttachSubject, DetachSubject, and SyncSubjects.
Host user ids are stored as models.UserID (a string-backed type that accepts
integers and UUID/strings, and JSON-encodes numeric ids as numbers for
back-compat). The DB column type defaults to BIGINT. If your host app's user
primary key is a UUID or other string, set ESCALATED_USER_KEY_TYPE before
running migrations so the host-user columns are created as VARCHAR(255):
# one of: int (default) | bigint | uuid | string
export ESCALATED_USER_KEY_TYPE=uuidExisting integer-keyed installs need no change — the default produces BIGINT
columns and numeric JSON ids exactly as before.
All routes are prefixed with RoutePrefix (default /escalated).
| Method | Path | Description |
|---|---|---|
GET |
/api/tickets |
List tickets (with filters) |
POST |
/api/tickets |
Create a ticket |
GET |
/api/tickets/{id} |
Get ticket with replies and activities |
PATCH |
/api/tickets/{id} |
Update a ticket |
POST |
/api/tickets/{id}/replies |
Add a reply |
POST |
/api/tickets/{id}/subjects |
Attach a ticket subject (type, id, optional role) |
DELETE |
/api/tickets/{id}/subjects/{subject} |
Detach a subject link by join-row id |
GET |
/api/departments |
List departments |
GET |
/api/tags |
List tags |
The ticket, department and tag routes above require AgentCheck or
AdminCheck and return 403 to anyone else. The /api/auth/*, /api/guest/*
and /api/kb/* routes are public.
These routes require a signed-in user, meaning UserIDFunc returns a
non-empty id, and return 401 otherwise. A customer can view and reply only to
tickets they requested. Any other ticket returns 403.
| Method | Path | Description |
|---|---|---|
GET |
/tickets |
My tickets |
POST |
/tickets |
Submit a ticket |
GET |
/tickets/{id} |
View ticket |
POST |
/tickets/{id}/replies |
Reply to ticket |
| Method | Path | Description |
|---|---|---|
GET |
/attachments/{id}/download |
Download an attachment |
Agents and admins can download any attachment. A signed-in customer can download attachments on tickets they requested, but not attachments on internal notes. Anyone else gets 401 or 403.
| Method | Path | Description |
|---|---|---|
GET |
/agent/ |
Agent dashboard |
GET |
/agent/tickets |
Ticket queue |
GET |
/agent/tickets/{id} |
Ticket detail |
POST |
/agent/tickets/{id}/assign |
Assign ticket |
POST |
/agent/tickets/{id}/replies |
Reply / internal note |
POST |
/agent/tickets/{id}/status |
Change status |
POST |
/agent/tickets/{id}/subjects |
Attach a ticket subject |
DELETE |
/agent/tickets/{id}/subjects/{subject} |
Detach a ticket subject |
| Method | Path | Description |
|---|---|---|
GET/POST/PATCH/DELETE |
/admin/departments |
Manage departments |
GET/POST/DELETE |
/admin/tags |
Manage tags |
GET/POST/DELETE |
/admin/sla-policies |
Manage SLA policies |
GET/PUT |
/admin/settings/public-tickets |
Runtime guest-policy mode (unassigned / guest_user / prompt_signup). See docs.escalated.dev/public-tickets. |
Add host-defined action buttons to the agent ticket screen via
Config.TicketActions. Each visible action is exposed on the ticket responses
(custom_actions, plus a top-level customActions prop on the agent screen),
and triggering it records an internal note and invokes Config.OnCustomAction:
import "github.com/escalated-dev/escalated-go/actions"
cfg := escalated.DefaultConfig()
cfg.TicketActions = []actions.TicketAction{
{
Key: "sync-crm",
Label: "Sync CRM",
Variant: "primary", // primary | secondary | danger
Confirmation: "Sync this ticket to the CRM?",
Metadata: map[string]any{"icon": "refresh-cw"},
// Visible/Enabled are optional; nil means always visible/enabled.
Enabled: func(t *models.Ticket, userID int64) bool { return t.ResolvedAt == nil },
},
}
cfg.OnCustomAction = func(ctx context.Context, e actions.CustomActionEvent) error {
if e.Action == "sync-crm" {
// e.Ticket, e.UserID, e.Payload, e.Metadata
}
return nil
}Triggering an action (POST {prefix}/agent/tickets/{id}/actions/{key} or the
/api equivalent) returns 404 if the action is unknown or not visible, 403 if
it is disabled, otherwise records the audit note and calls OnCustomAction.
Implement the store.Store interface to use a different database:
esc, _ := escalated.New(cfg)
esc.Store = myCustomStore // satisfies store.Store interface| Value | Name |
|---|---|
| 0 | open |
| 1 | in_progress |
| 2 | waiting_on_customer |
| 3 | waiting_on_agent |
| 4 | escalated |
| 5 | resolved |
| 6 | closed |
| 7 | reopened |
| Value | Name |
|---|---|
| 0 | low |
| 1 | medium |
| 2 | high |
| 3 | urgent |
| 4 | critical |
Strings come from the central escalated-locale
Go module so they stay in sync with every other Escalated plugin. The thin
loader at internal/i18n deep-merges optional local overrides on top of the
upstream data:
import "github.com/escalated-dev/escalated-go/internal/i18n"
label := i18n.T("ticket.status.open", "fr", nil)
msg := i18n.T("validation.required", "en", map[string]any{"field": "Email"})To override a single key without forking the locale file, drop a JSON file at
internal/i18n/overrides/{locale}.json — only the keys you list there are
overridden, everything else falls through to the central package. See
internal/i18n/overrides/README.md for the full pattern.
This PR establishes the schema, models, and renderer for the newsletter system. The DB-bound planner / dispatcher / tracker services need integration with the host's store/ layer, which is a follow-up.
import (
"github.com/escalated-dev/escalated-go/services/newsletter"
"github.com/yuin/goldmark"
"bytes"
)
cfg := newsletter.Config{
BaseURL: "https://support.example.com",
DefaultTheme: "default",
TrackingEnabled: true,
ThemesDir: "/path/to/templates/newsletter_themes",
MarkdownToHTML: func(md string) string {
var buf bytes.Buffer
_ = goldmark.Convert([]byte(md), &buf)
return buf.String()
},
Brand: newsletter.Brand{
Name: "Acme",
Accent: "#2563eb",
PhysicalAddress: "Acme Inc. · 123 Main St",
},
EnableNewsletters: true,
}
r := newsletter.NewRenderer(cfg)
html, _ := r.Render(&delivery, &n, &contact, nil)The package ships:
models/newsletter.go— 5 entity structs + Contact gainsMarketingOptOutAtservices/newsletter/renderer.go— Markdown → theme → click rewrite → pixel injectionmigrations/20260522000001_create_escalated_newsletter_system.sql— goose SQL migration
Follow-up PR will add the Store interfaces for newsletters (postgres + sqlite) and the planner/dispatcher/tracker services that consume them.
MIT