Skip to content

About

Escalated support ticket system for ASP.NET Core

Resources

Security policy

Stars

4 stars

Watchers

0 watching

Forks

Repository files navigation

العربية • Deutsch • English • Español • Français • Italiano • 日本語 • 한국어 • Nederlands • Polski • Português (BR) • Русский • Türkçe • 简体中文

Escalated for ASP.NET Core

Tests .NET License: MIT Views

A full-featured, embeddable support ticket system for ASP.NET Core. 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.

Features

  • 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
  • Automations -- Time-based rules with conditions and actions
  • 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 more
  • Macros and canned responses -- Batch actions and reusable reply templates
  • Custom fields -- Dynamic metadata with conditional display logic
  • Knowledge base -- Articles, categories, search, and feedback
  • File attachments -- Upload support with configurable storage and size limits
  • Activity timeline -- Full audit log of every action on every ticket
  • Webhooks -- HMAC-SHA256 signed with retry logic
  • API tokens -- Bearer auth with ability-based scoping
  • Roles and permissions -- Fine-grained access control
  • Audit logging -- All mutations tracked with old/new values
  • Import system -- Multi-step wizard with pluggable adapters
  • Side conversations -- Internal team threads on tickets
  • Ticket merging and linking -- Merge duplicate tickets and relate issues
  • Ticket subjects -- Attach host-app entities (Project, Customer, asset) a ticket is about
  • Ticket splitting -- Split a reply into a new ticket
  • Ticket snooze -- Snooze until a future date with auto-wake background service
  • Email threading -- In-Reply-To/References/Message-ID headers for proper threading
  • Inbound email -- Single webhook endpoint with Postmark + Mailgun + AWS SES parsers, signed Reply-To verification, and replies accepted only from the ticket's requester
  • Saved views -- Personal and shared filter presets
  • Embeddable widget API -- Public endpoints for KB search, guest tickets, status lookup
  • Real-time updates -- SignalR hubs for live ticket updates (opt-in)
  • Capacity management -- Per-agent workload limits by channel
  • Skill-based routing -- Match agents to tickets by skill tags
  • CSAT ratings -- Satisfaction surveys on resolved tickets
  • 2FA -- TOTP setup and verification with recovery codes
  • Guest access -- Anonymous ticket creation with magic token lookup
  • Inertia.js + Vue 3 UI -- Shared frontend via @escalated-dev/escalated

Requirements

  • .NET 8.0+
  • Entity Framework Core 9.0.x (9.0.21+)
  • SQL Server, SQLite, or PostgreSQL
  • Node.js 18+ (for frontend assets)

Quick Start

1. Install the Package

dotnet add package Escalated

2. Register Services

// Program.cs
using Escalated.Configuration;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddEscalated(builder.Configuration, options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("Escalated")));

// Your authentication: ASP.NET Core Identity, OpenID Connect, JWT bearer, cookies.
// Escalated authorizes whoever it signs in (see Authorization below).
builder.Services.AddAuthentication(/* ... */);

builder.Services.AddControllers();

var app = builder.Build();

app.UseAuthentication();
app.UseAuthorization();

app.MapControllers();
app.MapEscalated();

app.Run();

3. Configure

// appsettings.json
{
  "ConnectionStrings": {
    "Escalated": "Server=localhost;Database=MyApp;Trusted_Connection=true;"
  },
  "Escalated": {
    "RoutePrefix": "support",
    "TicketReferencePrefix": "ESC",
    "DefaultPriority": "medium",
    "AllowCustomerClose": true,
    "AutoCloseResolvedAfterDays": 7,
    "Sla": {
      "Enabled": true,
      "BusinessHoursOnly": false,
      "BusinessHours": {
        "Start": "09:00",
        "End": "17:00",
        "Timezone": "UTC",
        "Days": [1, 2, 3, 4, 5]
      }
    },
    "EnableRealTime": false,
    "Email": {
      "Domain": "support.yourapp.com",
      "InboundSecret": "a-long-random-value"
    }
  }
}

The Email.InboundSecret is symmetric -- it signs outbound Reply-To addresses and verifies inbound webhook requests, so forged emails targeting a stolen reply address are rejected via timing-safe HMAC.

Guests verify their email with an emailed code before they open a ticket, start a chat or look a ticket up, and get an expiring access grant; see docs/guest-access.md. That needs working outbound mail.

Notification emails (new ticket, replies, assignment, status, escalation, SLA breach, mentions) go out once Email.Smtp.Host is set; until then nothing is sent and that is logged once. See docs/notification-emails.md for the recipients, settings and threading.

4. Create the tables

The package ships its migrations for SQLite, PostgreSQL and SQL Server. Apply them at startup:

var app = builder.Build();
await app.Services.MigrateEscalatedDatabaseAsync();

or from the command line with dotnet ef database update --context EscalatedDbContext. A database created earlier with EnsureCreated() is adopted: the baseline is recorded as applied and only later migrations run.

Visit /support -- you're live.

Separate databases

EscalatedDbContext has its own connection string, so Escalated's tables go wherever ConnectionStrings:Escalated points. Nothing requires that 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:

{
  "ConnectionStrings": {
    "DefaultConnection": "Server=localhost;Database=MyApp;Trusted_Connection=true;",
    "Escalated": "Server=support-db;Database=Support;Trusted_Connection=true;"
  }
}

Nothing else changes. AddEscalated already resolves that string, and dotnet ef database update --context EscalatedDbContext migrates only that database — your own DbContext keeps its own migrations.

The two connections need not even be the same provider:

builder.Services.AddEscalated(builder.Configuration, options =>
    options.UseNpgsql(builder.Configuration.GetConnectionString("Escalated")));

Your users stay where they are

Escalated owns no User entity. The admin users page reads your users through IUserDirectory, which you implement against your DbContext, with your query, on your connection:

builder.Services.AddSingleton<IUserDirectory, MyUserDirectory>();

Registered before AddEscalated, your implementation wins; without one, a NullUserDirectory returns an empty page rather than querying a database that may have no users table in it.

That is also why no Escalated entity declares a foreign key to your users: Ticket.RequesterId, Ticket.AssignedTo and Reply.AuthorId are plain unconstrained columns, so the two databases need never meet. Tests fail the build if an entity, a foreign key or any raw SQL in the package names a host-owned table.

No query joins the two, because no database can join across two connections. The cost is that Escalated cannot filter or sort its tables by a column that lives on your user — assignment, skill routing and agent load all resolve ids from Escalated's tables first, then ask your directory for the people.

Host user keys (UUID / string support)

Escalated stores references to your app's users (ticket requester, assignee, reply author, etc.) as strings, so it works whether your user primary key is an int, a Guid, or any other string. A numeric id is simply stored and exchanged as its string form.

Your IUserDirectory implementation therefore exchanges user ids as string:

public Task<UserDirectoryEntry?> FindAsync(string id, CancellationToken ct = default)
{
    // look up your user by its (string) id and return:
    //   new UserDirectoryEntry(user.Id.ToString(), user.Name, user.Email)
}

Upgrade note: earlier versions used int here (FindAsync(int id) and UserDirectoryEntry(int Id, ...)); admin user APIs also now return user ids as JSON strings. Update your IUserDirectory implementation to the string signatures shown above. Integer ids keep working — pass them as strings.

Authorization

Escalated owns no authentication. It authorizes whoever your application signed in, reading them from HttpContext.User. Register your authentication and call UseAuthentication() and UseAuthorization() before MapControllers().

Every controller requires one of three policies:

Policy Surface Default rule
EscalatedPolicies.Admin (escalated-admin) /support/admin/*, /admin/newsletters* Signed in and holds the escalated-admin role
EscalatedPolicies.Agent (escalated-agent) /support/agent/* Signed in and holds escalated-agent or escalated-admin
EscalatedPolicies.Customer (escalated-customer) /support/tickets*, /support/attachments/* Signed in

The widget, guest, inbound email, newsletter tracking and newsletter ESP webhook endpoints are public, as is /support/api/v1/auth/*. They authenticate with guest tokens, shared secrets or your ApiAuth callbacks instead.

Nothing is allowed by default. An anonymous request gets 401, and a signed-in user without the role gets 403. An application with no authentication configured gets an ASP.NET Core configuration error, not an open endpoint.

Granting access

The roles are rows in Escalated's RoleUsers table: the same ones the admin users page grants and revokes (PATCH /support/admin/users/{id}/role). Grant the first admin directly:

var admin = new Role { Name = "Escalated Admin", Slug = AdminUsersController.AdminRoleSlug, IsSystem = true };
db.Roles.Add(admin);
await db.SaveChangesAsync();

db.RoleUsers.Add(new RoleUser { RoleId = admin.Id, UserId = "your-user-id" });
await db.SaveChangesAsync();

Or replace a policy with your own rule. A policy registered under the same name, before or after AddEscalated, takes precedence:

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy(EscalatedPolicies.Admin, p => p.RequireRole("SupportLead"));
    options.AddPolicy(EscalatedPolicies.Agent, p => p.RequireRole("SupportLead", "SupportAgent"));
});

The newsletter admin pages also check a permission: newsletters.manage, plus newsletters.send to send. The escalated-admin role holds both. If you replace the admin policy, attach them to your own roles in Escalated's RolePermissions table; NewsletterPermissionSeeder creates the two permissions and attaches them to escalated-admin.

Who a request acts as

The acting user is always the signed-in user: the requester of a ticket a customer creates, the author of a reply, the causer recorded on ticket activity, the owner of a mention inbox. Escalated reads the id from the NameIdentifier claim, then sub, then id. If your user id lives in another claim, set a resolver:

builder.Services.Configure<EscalatedOptions>(o =>
    o.UserIdResolver = user => user.FindFirst("employee_id")?.Value);

Ids in the query string or the request body (requesterId, agentId, userId, currentUserId, authorId, causerId, createdBy) are not read. A customer sees and acts on only the tickets they requested. Staff can download any attachment; a customer can download only attachments on their own tickets, and never one on an internal note.

Ticket subjects

A ticket has a requester (the person who raised it) and a subject line (free text). Sometimes a ticket is also about one or more host-app entities — a Project, a Customer, an asset — that aren't people. Attach them as ticket subjects so agents see what the ticket concerns and can jump straight to it in your app.

Implement ITicketSubject on any attachable host model:

public class Project : ITicketSubject
{
    public string TicketSubjectTitle() => Name;

    public string? TicketSubjectSubtitle() => Customer == null ? null : $"Project · {Customer.Name}";

    public string? TicketSubjectUrl() => $"/projects/{Id}";

    public string? TicketSubjectColor() => "#2563eb";

    public string? TicketSubjectIcon() => "folder";
}

Register a resolver so Escalated can load host models by stored (type, id):

builder.Services.AddSingleton<ITicketSubjectResolver, MyTicketSubjectResolver>();

Attach, detach, or sync subjects programmatically via TicketSubjectService, or through the admin API when types are allowlisted in config:

"Escalated": {
  "TicketSubjects": {
    "Types": [
      "App.Models.Project",
      "App.Models.Customer"
    ]
  }
}

Each attached subject is serialized on ticket responses as { type, id, role, title, subtitle, url, color, icon, missing }. When the resolver cannot find a host record, missing is true and title falls back to TypeName#id. subject_id is stored as a string, so integer, UUID, or string-keyed host models all work.

Admin endpoints:

POST   /support/admin/tickets/{id}/subjects       { "type", "id", "role?" }
DELETE /support/admin/tickets/{ticketId}/subjects/{subjectLinkId}

Programmatic attach works for any type when the allowlist is empty; the admin API only accepts allowlisted types.

Inbound email

Point your Postmark, Mailgun, or AWS SES (via SNS HTTP subscription) inbound webhook at:

POST /support/webhook/email/inbound?adapter=postmark
POST /support/webhook/email/inbound?adapter=mailgun
POST /support/webhook/email/inbound?adapter=ses

The adapter can be selected via the query parameter or the X-Escalated-Adapter header. Your provider must attach the shared secret as an X-Escalated-Inbound-Secret header.

The service resolves inbound messages to existing tickets. With InboundSecret set (the endpoint requires it), only the signed Reply-To address (reply+{id}.{hmac8}@domain) that outbound notifications carry identifies a ticket. Without a secret, the canonical Message-ID headers and subject-reference tags are used instead.

A matched message becomes a reply only when its From address (case-insensitive) is the ticket's requester: the guest email, the contact's email, or the requester user's email as returned by your IUserDirectory. The reply is posted as that requester, and only such a reply reopens a resolved or closed ticket. Staff identity is never taken from the From header, so agents reply in the app, not by email. Anything else with real content (no match, or a sender who is not the requester) creates a new ticket; SNS subscription confirmations and empty body+subject messages are skipped.

See the inbound email docs for provider setup, the response shape, and a ready-to-paste curl test recipe.

Frontend Integration

Escalated ships a Vue component library and default pages via the @escalated-dev/escalated npm package. Integrate with Inertia.js for seamless SPA rendering inside your existing layout.

npm install @escalated-dev/escalated

This package serves those pages over Inertia once Escalated:Ui:Enabled is true. It is off by default (escalated-laravel defaults it on), so an API-only host is unchanged. See docs/inertia-ui.md for the settings, the root view, the shared props and the pages served.

Architecture

src/Escalated/
  Models/           # 40+ EF Core entity models
  Data/             # EscalatedDbContext with full relationship mapping
  Services/         # Business logic (ticket, SLA, merge, split, snooze, etc.)
  Controllers/
    Admin/          # Admin panel API (CRUD for all settings)
    Agent/          # Agent ticket queue and actions
    Customer/       # Customer self-service portal
    Widget/         # Public widget API (KB search, guest tickets)
  Middleware/       # API token auth, permissions, rate limiting
  Events/           # Domain events (TicketCreated, SlaBreached, etc.)
  Notifications/    # Email notification interfaces and templates
  Configuration/    # DI registration, options, endpoint mapping
  Hubs/             # SignalR hub for real-time updates
  Enums/            # TicketStatus, TicketPriority, ActivityType

Models

Escalated includes 40+ EF Core entities covering the full helpdesk domain:

Category Models
Core Ticket, Reply, Attachment, TicketActivity, TicketStatusModel, TicketLink, TicketTag, Tag, Department, SatisfactionRating
SLA SlaPolicy, EscalationRule, BusinessSchedule, Holiday, Automation
Agents AgentProfile, AgentCapacity, Skill, AgentSkill
Messaging CannedResponse, Macro, SideConversation, SideConversationReply, InboundEmail
Admin Role, Permission, ApiToken, Webhook, WebhookDelivery, Plugin, AuditLog
Custom CustomField, CustomFieldValue, CustomObject, CustomObjectRecord
Import ImportJob, ImportSourceMap
Config EscalatedSettings, SavedView
Knowledge Base Article, ArticleCategory

All models have proper relationships, indexes, and query filters configured in EscalatedDbContext.

Services

Service Responsibility
TicketService Full ticket CRUD, status transitions, replies, tags, departments
SlaService Policy attachment, breach detection, warning checks, first response recording
AssignmentService Agent assignment, unassignment, auto-assign by workload
EscalationService Evaluate condition-based rules, execute escalation actions
AutomationRunner Time-based automation evaluation and action execution
MacroService Apply macro action sequences to tickets
TicketMergeService Merge source into target with reply transfer
TicketSplitService Split a reply into a new linked ticket
TicketSnoozeService Snooze/unsnooze with background wake service
WebhookDispatcher HMAC-signed webhook delivery with retry logic
CapacityService Per-agent concurrent ticket limits
SkillRoutingService Match agents by skills to ticket tags
BusinessHoursCalculator Business hours date math with holiday support
TwoFactorService TOTP secret generation, verification, recovery codes
AuditLogService Log and query entity mutations
KnowledgeBaseService Article/category CRUD, search, feedback
SavedViewService Personal and shared filter presets
SideConversationService Internal threaded conversations on tickets
ImportService Multi-step import with pluggable adapters
SettingsService Key-value settings store

Events

Every ticket action dispatches a domain event:

Event When
TicketCreatedEvent New ticket created
TicketStatusChangedEvent Status transition
TicketAssignedEvent Agent assigned
TicketUnassignedEvent Agent removed
ReplyCreatedEvent Public reply added
InternalNoteAddedEvent Agent note added
SlaBreachedEvent SLA deadline missed
SlaWarningEvent SLA deadline approaching
TicketEscalatedEvent Ticket escalated
TicketResolvedEvent Ticket resolved
TicketClosedEvent Ticket closed
TicketReopenedEvent Ticket reopened
TicketPriorityChangedEvent Priority changed
DepartmentChangedEvent Department changed
TagAddedEvent Tag added
TagRemovedEvent Tag removed
TicketCustomActionTriggeredEvent Agent triggered a custom ticket action

Every event is delivered, in order, to subscribed outbound webhooks, to matching Workflows, and then to each IEscalatedEventDispatcher your host application registers. Implement one to receive these events:

public class MyEventHandler : IEscalatedEventDispatcher
{
    public async Task DispatchAsync<TEvent>(TEvent @event, CancellationToken ct) where TEvent : class
    {
        if (@event is TicketCreatedEvent created)
        {
            // Handle new ticket
        }
    }
}

// Register in DI, before or after AddEscalated
services.AddSingleton<IEscalatedEventDispatcher, MyEventHandler>();

Registering a dispatcher adds a listener. It does not replace Escalated's own handling, so webhooks and Workflows keep running, and you can register several.

Webhook events

Webhooks subscribe to event names (or * for all of them):

Webhook event Domain event
ticket.created TicketCreatedEvent
ticket.updated TicketUpdatedEvent
ticket.status_changed TicketStatusChangedEvent
ticket.resolved TicketResolvedEvent
ticket.closed TicketClosedEvent
ticket.reopened TicketReopenedEvent
ticket.assigned TicketAssignedEvent
ticket.unassigned TicketUnassignedEvent
ticket.escalated TicketEscalatedEvent
ticket.priority_changed TicketPriorityChangedEvent
ticket.department_changed DepartmentChangedEvent
ticket.tag_added TagAddedEvent
ticket.tag_removed TagRemovedEvent
reply.created ReplyCreatedEvent
note.created InternalNoteAddedEvent
sla.breached SlaBreachedEvent
sla.warning SlaWarningEvent
chat.started ChatSessionStartedEvent
chat.assigned ChatSessionAcceptedEvent (an agent accepted, or routing assigned one at start)
chat.message ChatMessageEvent
chat.ended ChatSessionEndedEvent

These are the shared event vocabulary (escalated-developer-context ADR 2026-10-21, EventNames.Canonical). Workflow triggers use the same names. Names from earlier releases are still read: a webhook subscribed to ticket.tagged, ticket.untagged or internal_note.added, or a Workflow saved with ticket.replied, keeps working and receives the canonical name (EventNames.Legacy). Creating a webhook stores canonical names.

Workflows are managed under support/admin/workflows; see docs/workflows.md.

Each delivery is a signed POST (X-Escalated-Event, and X-Escalated-Signature when the webhook has a secret) with the body { "event", "payload", "timestamp" }. payload.ticket carries id, reference, subject, status and priority; reply events add payload.reply, tag events payload.tag, and ticket.assigned adds payload.agent_id. Chat events carry payload.chat (session_id, ticket_id, status, agent_id, department_id; chat.message adds reply_id and author_type), never the visitor's token or email. Every attempt is recorded as a WebhookDelivery.

Custom Ticket Actions

Host applications can add custom buttons to the agent ticket screen and handle clicks via the event dispatcher. Register actions in configuration:

{
  "Escalated": {
    "TicketActions": [
      {
        "Key": "sync-crm",
        "Label": "Sync CRM",
        "Variant": "primary",
        "Confirmation": "Sync this ticket to the CRM?",
        "Metadata": { "icon": "refresh-cw" }
      }
    ]
  }
}

Visible actions are exposed on the agent ticket response as custom_actions (each with a url and method). Triggering one (POST /support/agent/tickets/{id}/actions/{action}) validates the action is visible (404) and enabled (403), records an internal note for auditability, and dispatches TicketCustomActionTriggeredEvent to your IEscalatedEventDispatcher:

if (@event is TicketCustomActionTriggeredEvent triggered && triggered.Action == "sync-crm")
{
    // triggered.Ticket, triggered.UserId, triggered.Payload, triggered.Metadata
}

For dynamic per-ticket visibility, register your own ITicketActionRegistry.

API Endpoints

Customer

Method Route Description
GET /support/tickets List customer tickets
POST /support/tickets Create ticket
GET /support/tickets/{id} View ticket
POST /support/tickets/{id}/reply Reply to ticket
POST /support/tickets/{id}/close Close ticket
POST /support/tickets/{id}/reopen Reopen ticket

Agent

Method Route Description
GET /support/agent/tickets Ticket queue with filters
GET /support/agent/tickets/{id} Ticket detail
POST /support/agent/tickets/{id}/reply Reply
POST /support/agent/tickets/{id}/note Internal note
POST /support/agent/tickets/{id}/assign Assign agent
POST /support/agent/tickets/{id}/status Change status
POST /support/agent/tickets/{id}/priority Change priority
POST /support/agent/tickets/{id}/macro Apply macro
POST /support/agent/tickets/bulk Bulk actions
GET /support/agent/tickets/dashboard Agent workload

Admin

Method Route Description
GET/POST /support/admin/departments Manage departments
GET/POST /support/admin/tags Manage tags
GET/POST /support/admin/sla-policies Manage SLA policies
GET/POST /support/admin/escalation-rules Manage escalation rules
GET/POST /support/admin/webhooks Manage webhooks
GET/POST /support/admin/api-tokens Manage API tokens
GET/POST /support/admin/macros Manage macros
GET/POST /support/admin/automations Manage automations
GET/POST /support/admin/custom-fields Manage custom fields
GET/POST /support/admin/business-hours Business schedules
GET/POST /support/admin/skills Manage skills
GET/POST /support/admin/roles Manage roles
GET /support/admin/audit-logs Query audit logs
GET/POST /support/admin/settings App settings
GET/PUT /support/admin/settings/public-tickets Runtime guest-policy mode (unassigned / guest_user / prompt_signup). See docs.escalated.dev/public-tickets.
POST /support/admin/tickets/{id}/merge Merge tickets
POST /support/admin/tickets/{id}/split Split ticket
POST /support/admin/tickets/{id}/snooze Snooze ticket
POST /support/admin/tickets/{id}/link Link tickets

Widget (Public)

Method Route Description
GET /support/widget/config Widget settings and departments
GET /support/widget/embed.js Standalone widget script
GET /support/widget/articles?q= Search knowledge base ({ articles: [{ title, slug, excerpt }] })
GET /support/widget/articles/{slug} Published article ({ title, slug, body, category })
GET /support/widget/kb/search Search knowledge base (older shape)
POST /support/widget/tickets Create guest ticket (department_id or departmentId)
GET /support/widget/tickets/{token} Lookup by guest token
POST /support/widget/tickets/{token}/reply Guest reply
POST /support/widget/tickets/{token}/rate Submit CSAT rating
POST /support/widget/kb/articles/{id}/feedback Article feedback
GET /support/widget/chat/availability Whether live chat is available
POST /support/widget/chat/start Start a chat; id is the visitor's chat token
GET /support/widget/chat/{id}/messages Chat transcript (polled)
POST /support/widget/chat/{id}/messages Visitor message { body }
POST /support/widget/chat/{id}/typing Visitor typing indicator
POST /support/widget/chat/{id}/end End the chat
POST /support/widget/chat/{id}/rate Rate an ended chat { rating: 1-5, comment? }

Chat routes take the token returned as id by chat/start; numeric session ids are answered with 404.

To put the widget on another site, list that site's origin and add the script tag there; see embedding the widget.

Real-time Updates

Enable SignalR for live ticket updates:

{
  "Escalated": {
    "EnableRealTime": true
  }
}
// Program.cs
app.MapHub<EscalatedHub>("/support/hub");

Clients join ticket-specific groups to receive updates:

connection.invoke("JoinTicket", ticketId);
connection.on("TicketUpdated", (data) => { /* handle */ });

Middleware

API Token Authentication

Protect API endpoints with bearer token authentication:

app.UseMiddleware<ApiTokenAuthMiddleware>();

Tokens are stored as SHA-256 hashes. Create tokens via the admin API endpoint.

Rate Limiting

app.UseMiddleware<EscalatedRateLimitMiddleware>(60, 60); // 60 requests per 60 seconds

Guest endpoint rate limits

The public guest endpoints are rate-limited per client IP out of the box, with no middleware to add: POST /support/widget/tickets allows 5 tickets per IP per minute and POST /support/widget/tickets/{token}/reply 10 replies, each with its own counter. A request over the limit gets 429 with Retry-After. Replies are counted before the guest token is checked, so wrong-token guesses count too.

"Escalated": {
  "GuestRateLimit": { "Enabled": true, "TicketsPerMinute": 5, "RepliesPerMinute": 10 }
}

The client IP is HttpContext.Connection.RemoteIpAddress. Behind a reverse proxy or load balancer, configure ForwardedHeadersOptions with your trusted proxies and call app.UseForwardedHeaders(), or every guest shares the proxy's address. Counters are kept in memory per process; a multi-instance deployment should register its own shared IGuestRateLimiter (e.g. Redis-backed). Set Enabled to false only when you already throttle these routes upstream.

Localization

Escalated for ASP.NET Core consumes its translation catalog from the central Escalated.Locale NuGet package. AddEscalated() registers a chained IStringLocalizer stack that resolves keys in this order:

  1. Plugin-local overrides -- .resx files under src/Escalated/Resources/Overrides/. Empty by default.
  2. Optional shared provider -- a host that installs Escalated.Locale can supply its LocaleProvider localizer.
  3. Embedded catalog -- the JSON files under Resources/locales/ are shipped inside the Escalated assembly and work without the optional package.

Lookup uses CurrentUICulture, its parent cultures, and then English. For example, fr-CA reads the French catalog. Missing keys return the key with ResourceNotFound set. GetAllStrings(true) merges this fallback chain; GetAllStrings(false) reads only the selected culture's own catalog, with English used for invariant culture.

Catalog keys use dotted paths, such as nav.admin. Shared messages use named placeholders; pass a dictionary to name their values explicitly:

localizer["bulk.updated", new Dictionary<string, object?> { ["count"] = 3 }];

Positional arguments use the placeholder order in the English message. .resx overrides retain standard .NET composite formatting. Formatting follows CurrentCulture; unformatted lookups retain the catalog's placeholders.

Changes to the vendored catalogs ship with a new plugin build. Drop a resx into Resources/Overrides/ for wording specific to your plugin or host resource type. See src/Escalated/Resources/Overrides/README.md for naming conventions.

Testing

dotnet test

Integration tests use SQLite by default. CI runs the suite against PostgreSQL 17 as well; isolated unit tests also use Moq and EF Core InMemory.

To run the PostgreSQL profile against a disposable local test database:

ESCALATED_TEST_DATABASE=postgres \
ESCALATED_TEST_CONNECTION="Host=127.0.0.1;Port=5432;Database=escalated_test;Username=postgres;Password=postgres" \
dotnet test

The demo host has a separate project, so validate it explicitly:

dotnet build docker/host-app/EscalatedDemoHost.csproj

CI audits direct and transitive packages for both the solution and demo host. Known advisories and failures to obtain vulnerability data fail that job. Run the same checks locally with:

dotnet restore Escalated.sln -p:NuGetAuditMode=all -warnaserror:NU1900,NU1901,NU1902,NU1903,NU1904,NU1905
dotnet restore docker/host-app/EscalatedDemoHost.csproj -p:NuGetAuditMode=all -warnaserror:NU1900,NU1901,NU1902,NU1903,NU1904,NU1905

Coverage includes:

  • Ticket CRUD and status transitions
  • SLA breach detection and warnings
  • Ticket split, merge, and snooze
  • Assignment and workload calculation
  • Webhook subscription matching
  • 2FA secret generation and verification
  • Capacity management
  • Model validation and enum behavior

Also Available For

Same architecture, same Vue UI -- for every major backend framework.

Newsletters (optional, partial port)

Models + renderer for the admin-only newsletter broadcast feature. EF Core migration not bundled — host integrators generate one with dotnet ef migrations add NewsletterSystem after referencing this package.

using Escalated.Services.Newsletter;

var renderer = new NewsletterRenderer(new NewsletterRendererOptions
{
    BaseUrl = "https://support.example.com",
    DefaultTheme = "default",
    TrackingEnabled = true,
    ThemesDir = Path.Combine(env.ContentRootPath, "Views/NewsletterThemes"),
    MarkdownToHtml = md => Markdig.Markdown.ToHtml(md),
    Brand = new NewsletterBrand
    {
        Name = "Acme",
        Accent = "#2563eb",
        PhysicalAddress = "Acme Inc. · 123 Main St",
    },
});

var html = renderer.Render(delivery, newsletter, contact, template);

Ships: Models/Newsletter/*.cs (5 EF Core entities), Models/Contact.cs (gains MarketingOptOutAt), Services/Newsletter/NewsletterRenderer.cs (full renderer), Views/NewsletterThemes/{default,branded}.html (starter themes).

Follow-up PR: EF Core migration in-package, planner/dispatcher/tracker services using EscalatedDbContext, ASP.NET controllers.

License

MIT - Copyright (c) Escalated.dev. See LICENSE.

Configurable ticket access and routing

Opt-in department visibility, host-resolved account identity, account/type rule conditions and targeted SLA policies are described in Ticket access and routing. All restrictions are disabled by default.

About

Escalated support ticket system for ASP.NET Core

Resources

Security policy

Stars

4 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages