العربية • Deutsch • English • Español • Français • Italiano • 日本語 • 한국어 • Nederlands • Polski • Português (BR) • Русский • Türkçe • 简体中文
An embeddable helpdesk system for Spring Boot applications. Add a full-featured support desk to any Java application with a single dependency.
- Ticket CRUD -- Full lifecycle management with statuses, priorities, and assignments
- SLA Policies -- Configurable SLAs with business hours support and holiday calendars
- Automations -- Time-based rules for auto-closing resolved tickets and auto-assignment
- Escalation Rules -- Automatic escalation on SLA breach with reassignment and notifications
- Macros & Canned Responses -- Pre-defined actions and response templates for agents
- Custom Fields -- Extensible ticket data with multiple field types
- Knowledge Base -- Articles and categories with search, view counts, and feedback
- Webhooks -- HMAC-signed webhook delivery with retry logic
- API Tokens -- SHA-256 hashed token authentication for API access
- Roles & Permissions -- Granular role-based access control
- Audit Logging -- Complete audit trail for all actions
- Import System -- Bulk ticket import from structured data
- Side Conversations -- Private threaded conversations within tickets
- Ticket Merging & Linking -- Merge duplicate tickets and link related ones
- Ticket Splitting -- Split complex tickets into separate issues
- Ticket Snooze -- Snooze tickets with automatic wake-up via
@Scheduled - Email Threading -- Branded HTML email templates via Thymeleaf with proper Message-ID threading
- Saved Views -- Custom filtered/sorted ticket views per agent
- Widget API -- Public REST endpoints for embedding a support widget
- Real-time Broadcasting -- WebSocket via STOMP/SockJS (opt-in)
- Capacity Management -- Track and enforce agent workload limits
- Skill-based Routing -- Route tickets to agents with matching skills
- CSAT Ratings -- Customer satisfaction surveys with token-based access
- 2FA (TOTP) -- Time-based one-time password support for agent accounts
- Guest Access -- Token-based ticket access without authentication
- Inbound Email -- Single webhook endpoint with Postmark + Mailgun + AWS SES parsers, signed Reply-To verification, and Message-ID-based ticket resolution
- Java 17+
- Spring Boot 3.2+
- A relational database (PostgreSQL, MySQL, or H2 for development)
Add the dependency to your build.gradle.kts:
implementation("dev.escalated:escalated-spring:0.1.0")Or pom.xml:
<dependency>
<groupId>dev.escalated</groupId>
<artifactId>escalated-spring</artifactId>
<version>0.1.0</version>
</dependency>Add to your application.properties or application.yml:
# Enable/disable the helpdesk
escalated.enabled=true
# Route prefix (default: escalated)
escalated.route-prefix=escalated
# Feature toggles
escalated.knowledge-base.enabled=true
escalated.broadcasting.enabled=false
escalated.two-factor.enabled=true
escalated.widget.enabled=true
escalated.guest-access.enabled=true
# SLA checking interval
escalated.sla.check-interval-seconds=60
# Snooze wake-up interval
escalated.snooze.check-interval-seconds=60
# Webhook settings
escalated.webhook.max-retries=3
# Inbound email (symmetric secret used for signed Reply-To + webhook verification)
escalated.email.domain=support.yourapp.com
escalated.email.inbound-secret=${ESCALATED_INBOUND_SECRET}
# Database (example for PostgreSQL)
spring.datasource.url=jdbc:postgresql://localhost:5432/myapp
spring.datasource.username=user
spring.datasource.password=secret
# Escalated runs its own migrations; see Database Setup
spring.jpa.hibernate.ddl-auto=validateA 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 — that are not people. Attach them as ticket subjects so agents see what the ticket concerns and can jump to the entity in your app.
Implement the dev.escalated.contracts.TicketSubject interface on host models and
register a TicketSubjectResolver bean to resolve type/id pairs for the UI:
@Component
public class ProjectTicketSubjectResolver implements TicketSubjectResolver {
private final ProjectRepository projects;
@Override
public TicketSubject resolve(String subjectType, String subjectId) {
if (!"com.example.Project".equals(subjectType)) {
return null;
}
return projects.findById(subjectId).orElse(null);
}
}Allow types the admin API may attach (prevents arbitrary type injection):
escalated:
ticket-subjects:
types:
- com.example.Project
- com.example.CustomerAttach or detach via the admin API:
POST /escalated/api/admin/tickets/{ticketId}/subjects { "type", "id", "role"? }
DELETE /escalated/api/admin/tickets/{ticketId}/subjects/{linkId}
Ticket detail responses include subjects[] with
{ type, id, role, title, subtitle, url, color, icon, missing }. When no
resolver is registered or the entity is gone, title falls back to type#id
and missing is true.
Programmatic attach via TicketSubjectService works for any type when the
allowlist is empty; the API only accepts allowlisted types.
Host applications can add custom buttons to the agent ticket screen and handle
clicks with a Spring @EventListener. Register actions in application.yml:
escalated:
ticket-actions:
- key: sync-crm
label: Sync CRM
variant: primary # primary | secondary | danger
confirmation: "Sync this ticket to the CRM?"
metadata:
icon: refresh-cwVisible actions are exposed on the agent ticket detail response as
custom_actions (each with a url and method). Triggering one
(POST /escalated/api/agent/tickets/{id}/actions/{action}) validates the action
is visible (404) and enabled (403), records an internal note for auditability,
and publishes a CustomActionTriggeredEvent:
@Component
public class CrmSyncListener {
@EventListener
public void onCustomAction(CustomActionTriggeredEvent event) {
if (!"sync-crm".equals(event.getAction())) {
return;
}
// event.getTicket(), event.getUserEmail(), event.getPayload(), event.getMetadata()
}
}Escalated creates its tables (all prefixed escalated_) with Flyway migrations of its own and runs them on startup, before Hibernate validates anything. It does this itself: your application needs no Flyway configuration, and Escalated's migrations never mix with yours.
- Where they live:
classpath:db/escalated/postgresqlandclasspath:db/escalated/mysql(MariaDB uses the MySQL edition). They are deliberately outsidedb/migration, the location Boot's Flyway reads, so your own migrations and Escalated's cannot collide on a version number. - History: recorded in
escalated_flyway_schema_history, never in yourflyway_schema_history. - Engines: PostgreSQL and MySQL/MariaDB. On anything else, set
escalated.datasource.migrate=falseand create the schema yourself. - Opting out:
escalated.datasource.migrate=falseskips them, whether Escalated shares your database or has its own.
Upgrading from 0.1.0, which shipped the MySQL scripts in db/migration:
- If your own Flyway applied them, Escalated finds their rows in your
flyway_schema_history, baselines its own history at the highest of them, and removes those rows from yours, so your Flyway does not report them missing. Nothing is run twice. - If Hibernate
ddl-autocreated Escalated's tables instead, they are left alone and a warning is logged, as before.
By default Escalated shares your application's DataSource, EntityManagerFactory and transaction manager — its tables sit alongside yours. Point it somewhere else with one property:
escalated.datasource.url=jdbc:postgresql://support-db:5432/supportThat is enough. Escalated then gets a persistence unit of its own: its repositories bind to it, its transactions open on it, and its Flyway migrations run against it under a schema history table of their own (escalated_flyway_schema_history). Your application's database is left alone entirely — no Escalated tables are created in it and no Escalated transaction is opened on it.
Everything else falls back to your own spring.datasource.*, so a second database on the same server needs one line rather than five:
| Property | Default | |
|---|---|---|
escalated.datasource.url |
unset | The only property that decides. Unset, or blank, means "share the host's database" |
escalated.datasource.username |
your spring.datasource.username |
|
escalated.datasource.password |
your spring.datasource.password |
|
escalated.datasource.driver-class-name |
derived from the URL | |
escalated.datasource.platform |
detected | Hibernate dialect |
escalated.datasource.ddl-auto |
none |
Schema management belongs to Flyway |
escalated.datasource.migrate |
true |
Run Escalated's migrations on startup; applies to the shared database too |
The two need not be the same engine — your application on MySQL and Escalated on PostgreSQL is a supported arrangement.
Escalated owns no user entity. The admin roles page reads is_admin / is_agent on escalated_agent_profiles, which is one of Escalated's own tables, and ticket columns that reference one of your users hold a plain value with no foreign key behind it.
That is deliberate, and it is what makes the split possible at all: 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 own tables first.
Every @Transactional in the package names escalatedTransactionManager explicitly. While Escalated shares your database that name is an alias of your own transaction manager, so it is the same bean and the same transaction it always was. A host @Transactional method that calls into Escalated joins one transaction as before.
Once Escalated has a database of its own, the two are separate transactions, because they are separate connections. A host transaction that rolls back will not roll back the Escalated work it triggered.
Translations are consumed from the central dev.escalated:escalated-locale Maven artifact, which ships bundles at META-INF/escalated/locale/messages_{locale}.properties on the classpath. The auto-configured MessageSource chains two basenames so host apps can override keys without forking the central bundle:
classpath:i18n/overrides/messages— sparse host-app overrides (first match wins)classpath:META-INF/escalated/locale/messages— central artifact (canonical strings)
Drop a messages_{locale}.properties file under src/main/resources/i18n/overrides/ to override individual keys. See src/main/resources/i18n/overrides/README.md for examples. To fix a typo or mistranslation that affects every host plugin, open a PR against the central escalated-locale repo instead.
Point your Postmark, Mailgun, or AWS SES (via SNS HTTP subscription) inbound webhook at:
POST /escalated/webhook/email/inbound?adapter=postmark
POST /escalated/webhook/email/inbound?adapter=mailgun
POST /escalated/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, which is compared with MessageDigest.isEqual (timing-safe).
The service resolves inbound messages to existing tickets via, in order: canonical Message-ID headers, signed Reply-To verification, and subject-reference tags. Unmatched messages with real content create 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.
| Method | Path | Description |
|---|---|---|
| GET | /tickets |
List tickets (paginated, filterable) |
| POST | /tickets |
Create ticket |
| GET | /tickets/{id} |
Get ticket |
| PUT | /tickets/{id} |
Update ticket |
| POST | /tickets/{id}/assign |
Assign ticket |
| POST | /tickets/{id}/status |
Change status |
| POST | /tickets/{id}/snooze |
Snooze ticket |
| POST | /tickets/{id}/merge |
Merge tickets |
| POST | /tickets/{id}/split |
Split ticket |
| DELETE | /tickets/{id} |
Delete ticket |
| GET/POST | /departments |
CRUD departments |
| GET/POST | /agents |
CRUD agents |
| GET/POST | /webhooks |
CRUD webhooks |
| GET/POST | /roles |
CRUD roles |
| GET/POST | /custom-fields |
CRUD custom fields |
| GET/POST | /settings |
Manage settings |
| GET/PUT | /settings/public-tickets |
Runtime guest-policy mode (unassigned / guest_user / prompt_signup). See docs.escalated.dev/public-tickets. |
| GET | /audit-logs |
View audit logs |
| POST | /import/tickets |
Import tickets |
| GET/POST | /kb/categories |
Manage KB categories |
| GET/POST | /kb/articles |
Manage KB articles |
| Method | Path | Description |
|---|---|---|
| GET | /tickets |
List assigned/filtered tickets |
| GET | /tickets/{id} |
View ticket |
| POST | /tickets/{id}/replies |
Add reply |
| POST | /tickets/{id}/macro/{macroId} |
Apply macro |
| POST | /tickets/{id}/side-conversations |
Create side conversation |
| POST | /tickets/{id}/links |
Link tickets |
| GET/POST | /saved-views |
Manage saved views |
| GET/POST | /canned-responses |
Manage canned responses |
| Method | Path | Description |
|---|---|---|
| GET | /tickets?email= |
List customer tickets |
| POST | /tickets |
Create ticket |
| POST | /tickets/{id}/replies |
Add reply |
| Method | Path | Description |
|---|---|---|
| POST | /tickets |
Create ticket (public) |
| GET | /tickets/{token} |
View ticket by guest token |
| POST | /tickets/{token}/replies |
Reply via guest token |
| GET | /kb/search?query= |
Search knowledge base |
| POST | /csat/{token} |
Submit satisfaction rating |
| Method | Path | Description |
|---|---|---|
| GET | /tickets/{token} |
View ticket |
| GET | /tickets/{token}/replies |
View replies |
| POST | /tickets/{token}/replies |
Add reply |
dev.escalated/
config/ Auto-configuration, properties, WebSocket config
models/ JPA entities with full relationships
repositories/ Spring Data JPA repositories
services/ Business logic (transactional)
controllers/
admin/ Admin REST API
agent/ Agent REST API
customer/ Customer REST API
widget/ Public widget API
events/ Spring application events + webhook listener
security/ API token auth filter, security config, 2FA
scheduling/ @Scheduled tasks (snooze, SLA, automations)
| Path | Requires |
|---|---|
/escalated/api/admin/** |
an admin |
/escalated/api/agent/** |
an agent or an admin |
/escalated/api/customer/**, /escalated/api/attachments/** |
any authenticated user |
/escalated/api/widget/**, /escalated/api/guest/**, /escalated/api/csat/** |
nothing |
/escalated/api/v1/auth/** |
nothing; the endpoints check host-issued tokens through your EscalatedApiAuthenticator |
/escalated/webhook/email/inbound |
the X-Escalated-Inbound-Secret header |
An admin is a caller whose escalated_agent_profiles row, matched by principal name (email), is active with is_admin set; an agent is the same with is_agent or is_admin. If your roles live in your own user store instead, grant your users ROLE_ESCALATED_ADMIN or ROLE_ESCALATED_AGENT (EscalatedAuthorization.ADMIN_ROLE / AGENT_ROLE) and no profile lookup is made.
The API chain is stateless: it authenticates Escalated API tokens and does not read your application's HTTP session.
API endpoints use Bearer token authentication. Create tokens via the admin API:
curl -X POST /escalated/api/admin/tokens \
-H "Content-Type: application/json" \
-d '{"name": "My API Token", "agent_id": 1}'The response includes the plain-text token (shown only once). Use it in subsequent requests:
curl -H "Authorization: Bearer <token>" /escalated/api/agent/ticketsEnable with escalated.broadcasting.enabled=true. Connect to /escalated/ws via SockJS/STOMP.
# Build
./gradlew build
# Run tests
./gradlew test
# Run checkstyle
./gradlew checkstyleMain checkstyleTestJPA entities + renderer for the admin-only newsletter broadcast feature. Schema is auto-derived by Hibernate from the new @Entity classes when spring.jpa.hibernate.ddl-auto=update. Production hosts using Flyway / Liquibase generate the SQL migration with mvn spring-boot:run or mvn flyway:migrate after referencing this package.
import dev.escalated.services.newsletter.NewsletterRenderer;
var opts = new NewsletterRenderer.Options();
opts.baseUrl = "https://support.example.com";
opts.defaultTheme = "default";
opts.trackingEnabled = true;
opts.themesDir = "src/main/resources/templates/escalated/newsletter_themes";
opts.markdownToHtml = md -> /* plug in flexmark, commonmark-java, etc. */;
opts.brandName = "Acme";
opts.brandAccent = "#2563eb";
var renderer = new NewsletterRenderer(opts);
var html = renderer.render(delivery, newsletter, contact, template);Ships: models/newsletter/*.java (5 JPA entities), models/Contact.java (gains marketingOptOutAt), services/newsletter/NewsletterRenderer.java, resources/templates/escalated/newsletter_themes/{default,branded}.html.
Follow-up PR: Flyway / Liquibase migration files, planner/dispatcher/tracker services using Spring JpaRepositorys, Spring MVC controllers.
MIT License. See LICENSE for details.