Integrate Essential Support into your app

Overview

Create and read support tickets, receive real-time events, embed support in your product, and have Essential Support recognize and verify your customers using your own account data.

Base URL for every request — your canonical host: https://YOUR-WORKSPACE.essential.support

Replace YOUR-WORKSPACE with your workspace subdomain. Everything below is relative to this base URL. If you serve your workspace on a custom domain, that becomes your canonical host — see below.

Custom domains — use your canonical host

You can run your workspace on your own domain (e.g. support.yourapp.com) — add and verify it in Settings. Once verified it becomes the canonical host automatically and works identically for the API, SSO, and the widget. Point your code at the canonical host: requests to the non-canonical host get a 301 redirect, and most server-side HTTP clients won’t re-issue a POST after a redirect — so a create / enrich / SSO call aimed at the wrong host can silently fail. Aim your backend base URL, SSO redirect URL, and widget <script src> at the canonical host. Two things never move to your custom domain: the email-intake address (always @in.essential.support) and the webhook workspace identifier (X-ES-Workspace = your workspace slug, not a host).

Four ways to connect — plus the one that ties them together

Which should I use?

Authentication & secrets

Each secret is created in the ES web app under Settings and shown once — copy it then and store it in your secret manager. Never place any of them in a browser, mobile app, or other client.

SecretWhereUsed for
es_live_… API keySettings → API keysServer-to-server calls (Bearer auth).
SSO signing secretSettings → ConnectionsSigning short-lived identity tokens.
Webhook signing secretSettings → WebhooksVerifying events ES sends you.

Core concepts

Conventions: JSON bodies (Content-Type: application/json); ISO-8601 UTC timestamps; errors return { "code", "message" }.

Methods

API keys

Use a workspace API key to create tickets from your servers, attach the customer’s identity, and read ticket state back. This is server-to-server — the key is secret. Get one in Settings → API keys. Rate limit: 120 requests/minute per key (429 over that — back off).

Create a ticket

{
  "email": "dana@customer.com",
  "externalId": "u_9",
  "name": "Dana Lee",
  "subject": "Can't export my data",
  "message": "The export button spins forever on the billing page.",
  "priority": "high",
  "type": "Billing"
}

POST /api/tickets

FieldRequiredNotes
emailThe customer’s email. Finds or creates their record.
subjectShort summary.
messagePlain-text body. Use messageHtml for HTML.
externalIdYour user ID for this customer. Providing it verifies the customer and links the ticket to your admin. Recommended.
nameDisplay name.
priorityhigh · med · low (default med). API-created tickets are the only ones that may set priority.
typeTicket type — its name (e.g. "Billing", case-insensitive) or id. Unknown → untyped. List via GET /api/ticket-types.
localeThe customer’s language (e.g. fr), if known.

Response 201 returns the ticket (store ref or id to read it later):

{ "ticket": {
  "id": "66a…", "ref": "A7K2M9Q", "number": 1042,
  "subject": "Can't export my data", "status": "open", "priority": "high",
  "source": "api", "createdAt": "…", "lastMessageAt": "…",
  "customer": { "id": "66a…", "email": "dana@customer.com", "externalId": "u_9", "name": "Dana Lee" }
} }

Read one ticket

GET /api/tickets/{idOrRef}

Accepts the id or the human ref (case-insensitive). Returns the ticket plus its customer-visible conversation — customer messages and agent replies only; internal notes are never included. Attachments carry metadata + a scanState but no download URL (files are served only inside the portal). Missing/foreign ticket → 404.

List a customer’s tickets

GET /api/tickets?externalId=u_9&status=open&limit=25&skip=0

Scope to a specific customer with externalId or email (one required). Optional status (open/closed), limit (≤100, default 25), skip. Unknown customer → { "total": 0, "tickets": [] }. Use it to render “You have N open tickets” in your own UI.

List ticket types

GET /api/ticket-types

Returns your workspace’s types so you can set type on create by name or id:

{ "types": [ { "id": "66a…", "name": "Billing" }, { "id": "66a…", "name": "Technical" } ] }

Enrich a ticket — attach identity after the fact

POST /api/tickets/{idOrRef}/enrich

When a ticket arrives from the widget, email, or a public form — where you didn’t set identity up front — link it to your user record and verify the customer:

{ "externalId": "u_9", "name": "Dana Lee", "note": "Pro plan · signed up 2024-01-10" }

The typical pattern: listen for the ticket.created webhook, look the customer up by email, and call enrich. See Customer identity & verification.

Errors

StatusMeaning
400Missing/invalid field.
401Missing or invalid API key.
404Ticket not found (or not in this workspace).
409externalId already linked to another customer (enrich).
429Rate limit exceeded — back off and retry.

Idempotency

ES doesn’t de-duplicate creates. If a create times out, read back by externalId before retrying, or keep a client-side guard, to avoid double-filing.

Webhooks

ES POSTs a small JSON event to your endpoint whenever a ticket changes. Set one up in Settings → Webhooks: enter your HTTPS URL, choose events, and copy the signing secret (shown once).

Events

EventFires when
ticket.createdA new ticket is filed (any source).
ticket.agent_repliedAn agent replies to the customer.
ticket.customer_repliedThe customer replies.
ticket.status_changedA ticket is opened or closed.
ticket.assignedA ticket’s assignee changes.

Request shape

POST https://your-app.com/es-webhook
X-ES-Event: ticket.created
X-ES-Delivery: 66b2a1c0e4b0a1234567890f
X-ES-Workspace: your-workspace
X-ES-Signature: t=1754146800,v1=6f2a…e91
{
  "deliveryId": "66b2a1c0e4b0a1234567890f",
  "event": "ticket.created",
  "occurredAt": "2026-08-02T15:04:05.000Z",
  "workspace": "your-workspace",
  "ticket": { "…": "same shape as the read-back API, incl. customer{ externalId }" }
}

Verify every webhook

The X-ES-Signature header is t=<unix-seconds>,v1=<hex HMAC-SHA256( secret, "<t>.<raw-body>" )>. Recompute it over the raw body with your endpoint’s secret, compare in constant time, and reject stale timestamps.

import crypto from 'node:crypto';

app.post('/es-webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const { t, v1 } = Object.fromEntries((req.get('X-ES-Signature') || '').split(',').map(p => p.split('=')));
  const body = req.body.toString('utf8');
  if (!t || Math.abs(Date.now() / 1000 - Number(t)) > 300) return res.sendStatus(400);
  const expected = crypto.createHmac('sha256', process.env.ES_WEBHOOK_SECRET)
    .update(`${t}.${body}`).digest('hex');
  const ok = v1 && v1.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
  if (!ok) return res.sendStatus(401);
  const event = JSON.parse(body);            // dedupe on X-ES-Delivery, handle async
  res.sendStatus(200);
});

Delivery semantics

Widget

Add a support launcher to any page with one script tag — no build, no secrets in the browser. Configure it in Settings → Widget (enable, button label, position, and the domains you’ll embed on).

<script src="https://YOUR-WORKSPACE.essential.support/widget.js" async></script>

The launcher opens a compact contact form served in an iframe by ES, so your page never handles customer data. The form is verify-first: the visitor enters their email, ES sends a confirmation link, and the ticket is created when they click it (source: "widget").

Custom domain? Embed from the canonical host

The launcher derives its config fetch and the form iframe from the script’s own origin. If you’ve verified a custom domain, load the script from it (https://support.yourapp.com/widget.js) — pointing <script src> at the non-canonical host, which then 301-redirects, makes the launcher and iframe disagree on origin and the panel won’t open.

Recognize logged-in customers

To skip the email step and mark a signed-in visitor as verified, deep-link them into support with an SSO token instead of the anonymous form — or keep the widget and attach identity later with the webhook → enrich loop.

Add every embedding origin

The support form loads only on the origins you allow-list in Settings → Widget. On an unlisted origin the button may appear but the panel opens blank — the common “why is my widget empty?” cause.

Email intake

Let customers reach support by email. Enable it in Settings → Email intake and copy your intake address:

es_in_9f3a1b2c4d5e6f708192a3b4@in.essential.support

Either forward your existing inbox (support@yourapp.com) to it — route externally if your provider short-circuits same-domain mail — or publish the address directly. Inbound mail becomes a ticket (source: "email", customer = the From address); agent replies thread back automatically via a per-ticket reply-to. ES handles the threading — just route mail in.

Good to know

Connect your backend

Customer identity & verification

The idea that ties everything together: ES identifies a customer by email and, when you provide it, by your own user ID — externalId. A customer becomes verified the moment a ticket carries an externalId you supplied through a trusted channel. That one value links the ticket to your system, shows agents the badge, and powers the jump-to-your-admin link.

ChannelHow identity is setWhen
API createYou pass externalId on createYou file tickets from your backend
SSOYour signed token carries the user’s ID as subLogged-in users file tickets in the portal/widget
EnrichYou call /enrich after the ticket existsTickets from widget / email / public form

SSO — sign your logged-in users into support

When a logged-in user opens support, your backend vouches for them with a short-lived signed token. ES trusts it, creates a verified session, and the user files tickets and sees their history — no email confirmation. Get the signing secret in Settings → Connections (rotatable with an overlap window; keep it server-side only).

Mint a JWT signed with HS256 and redirect the user to the handoff URL:

GET /sso?token=<JWT>&next=/

ClaimRequiredMeaning
emailThe user’s email (must be valid).
subYour user ID → becomes externalId (verifies + links). Recommended.
nameDisplay name.
jtiUnique random ID for this token (single-use — prevents replay).
iat / expIssued-at / expiry (unix seconds). Keep it short.
iss / localeIf present, iss must equal your workspace subdomain; locale sets language.
import jwt from 'jsonwebtoken';
import crypto from 'node:crypto';

function supportSsoUrl(user) {
  const now = Math.floor(Date.now() / 1000);
  const token = jwt.sign(
    { sub: user.id, email: user.email, name: user.displayName,
      jti: crypto.randomUUID(), iat: now, exp: now + 120 },  // 2 minutes — use it right away
    process.env.ES_SSO_SIGNING_SECRET, { algorithm: 'HS256' });
  return `https://YOUR-WORKSPACE.essential.support/sso?token=${encodeURIComponent(token)}&next=/`;
}

On success ES sets the session and redirects to next. A bad or expired token drops the user to the portal’s email-link sign-in, so a misconfigured token never hard-fails them. Build the /sso URL from your canonical host (your custom domain once verified) — a handoff to a non-canonical host triggers a 301 that can consume the single-use token in the redirect chain.

Enrich — verify tickets that arrived without identity

For widget / email / public-form tickets, link them to your users in your webhook handler:

if (event.event === 'ticket.created' && !event.ticket.customer.externalId) {
  const user = await db.users.findByEmail(event.ticket.customer.email);
  if (user) {
    await fetch(`https://YOUR-WORKSPACE.essential.support/api/tickets/${event.ticket.id}/enrich`, {
      method: 'POST',
      headers: { Authorization: `Bearer ${process.env.ES_API_KEY}`, 'Content-Type': 'application/json' },
      body: JSON.stringify({ externalId: user.id, name: user.displayName,
        note: `${user.plan} plan · customer since ${user.createdAt}` })
    });
  }
}

Jump from a ticket into your admin

Once customers carry an externalId, point ES at your own admin in Settings → Customer accounts:

URLs must be https, and the placeholder may appear only in the path or query — not the domain. Changes apply to the next ticket an agent opens; no redeploy.

Automate

Use with your AI assistant

Building with an AI coding assistant (Claude, etc.)? Hand it the self-contained skill below — a focused integration playbook it can follow to implement any of the methods above. It contains only what’s needed to integrate; it deliberately withholds anything about how Essential Support works internally. Paste it into your assistant’s context, or save it as Skills.md in your project.

---
name: essential-support-integration
description: Integrate Essential Support (a help-desk product) into an app — create/read tickets from a backend, receive webhook events, embed the support widget, route email to tickets, or sign logged-in users into support (SSO) so customers are verified.
---

# Essential Support — Integration Skill

You are helping a developer connect THEIR app to Essential Support (ES), a help-desk
product their company uses. Implement the integration on the developer's side.

## Scope & boundaries (read first)
- Help ONLY with integrating an external app with ES using the public methods here:
  API keys, webhooks, widget, email intake, and customer identity/verification.
- This document is the complete, authoritative integration contract. If something isn't
  here, treat it as out of scope — point the user to their ES Settings.
- You have NO knowledge of ES's internal implementation and don't need any to integrate.
  Do not speculate about ES internals or help probe them. Security rests on the shared
  secrets, not on hidden detail.
- Customer-facing setup (keys, secrets, widget/email/customer-account settings) happens
  in the ES web app under Settings — point the user to the right page.

## Base URL
All API/SSO/widget requests target the workspace's CANONICAL host:
https://YOUR-WORKSPACE.essential.support  (or the workspace's custom domain).
Ask the user for their canonical host and substitute it everywhere.

Custom domains: a workspace can be served on the user's own domain (set up + verified in
ES Settings). Once verified the custom domain BECOMES the canonical host and works
identically for API, SSO, and the widget. Always use the canonical host in code —
requests to the non-canonical host get a 301, and most server-side HTTP clients won't
re-issue a POST after a redirect, so a create/enrich/SSO call to the wrong host can
silently fail. Point the backend base URL, the SSO redirect URL, and the widget
<script src> at the canonical host. Two exceptions stay on the ES domain regardless: the
email-intake address (es_in_…@in.essential.support) and the webhook workspace identifier
(X-ES-Workspace / workspace = the workspace slug, not a host).

## Secrets (from the ES web app; each shown once — store in a secret manager)
- API key (es_live_…) — Settings → API keys. Backend only. Bearer auth.
- SSO signing secret — Settings → Connections. Backend only. Signs SSO tokens.
- Webhook signing secret — Settings → Webhooks (per endpoint). Verifies incoming events.
Never place any of these in browser/mobile/client code.

## Method 1 — API keys (backend → ES)
Server-to-server. `Authorization: Bearer es_live_…`. ~120 req/min per key (429 over).

Create — POST /api/tickets
  { "email", "externalId", "name", "subject", "message", "priority", "type", "locale" }
  Required: email, subject, message. priority ∈ high|med|low (default med).
  externalId = the app's user ID → verifies + links the customer (send it when known).
  type = a ticket-type name (case-insensitive) or id; unknown → untyped.
  → 201 { ticket: { id, ref, number, subject, status, priority, source, createdAt,
    lastMessageAt, customer:{ id,email,externalId,name } } }

Read — GET /api/tickets/{idOrRef} → { ticket, messages }. Customer-visible messages only
  (kind = customer|agent_reply); attachments carry scanState, no download URL.
List — GET /api/tickets?externalId=…|email=…&status=&limit=&skip= (one of
  externalId/email required) → { total, tickets:[…] }. Unknown → { total:0, tickets:[] }.
Ticket types — GET /api/ticket-types → { types:[{id,name}] } (to set `type` on create).
Enrich — POST /api/tickets/{idOrRef}/enrich { externalId, name, note } (≥1 required).
  Attaches the user ID (verifies) and/or an internal agent-only note. 409 if the
  externalId already belongs to a different customer.

## Method 2 — Webhooks (ES → backend)
Settings → Webhooks (URL + events + signing secret). Events: ticket.created,
ticket.agent_replied, ticket.customer_replied, ticket.status_changed, ticket.assigned.
Headers: X-ES-Event, X-ES-Delivery (dedupe key), X-ES-Workspace, X-ES-Signature.
Body: { deliveryId, event, occurredAt, workspace, ticket:{…,customer{…}},
  message?:{id,kind,createdAt} (reply events only), changes?:{status:{from,to}}
  (status_changed only) }. customer.externalId is null for widget/email tickets.

Verify every request: X-ES-Signature = `t=<unix>,v1=<hex HMAC-SHA256(secret,"<t>.<rawBody>")>`.
  const { t, v1 } = Object.fromEntries((sig||'').split(',').map(p=>p.split('=')));
  if (!t || Math.abs(Date.now()/1000 - Number(t)) > 300) reject;
  const expected = crypto.createHmac('sha256', SECRET).update(`${t}.${rawBody}`).digest('hex');
  constant-time compare v1 vs expected.
Semantics: at-least-once (dedupe on X-ES-Delivery), success = any 2xx, retries w/ backoff
  up to 6 attempts, no ordering guarantee. Respond 2xx fast; work async.

## Method 3 — Widget (browser → ES)
Settings → Widget (enable, label, position, allowed origins). Install:
  <script src="https://YOUR-WORKSPACE.essential.support/widget.js" async></script>
Renders a launcher + verify-first contact form (email-confirmed; source:"widget"). No
secrets in the page. For logged-in users prefer SSO so they're verified, not anonymous.
The form loads only on allow-listed origins (blank panel otherwise).

## Method 4 — Email intake (email → ES)
Settings → Email intake → an address like es_in_…@in.essential.support. Forward the
support inbox there (route externally if same-domain), or publish it. Inbound → ticket
(source:"email", customer = From). Agent replies thread back automatically. Attachments
are imported when the workspace has R2 (Images & Files) configured, else text-only.
Link to user records via the webhook→enrich loop. Email identity isn't cryptographic
proof — treat as "likely this user."

## Customer identity & verification (connect ES to the backend)
A customer is verified when their ticket has an externalId (the app's user ID) set via a
trusted channel: (1) API create, (2) SSO, (3) enrich (webhook→/enrich for widget/email).

SSO handoff — backend mints an HS256 JWT with the SSO signing secret, redirects to
  https://YOUR-WORKSPACE.essential.support/sso?token=<JWT>&next=/
Claims: email (required), sub (user ID → externalId), name, jti (required, unique,
  single-use), iat (required), exp (required), optional iss (workspace subdomain), locale.
  Mint per click, use within ~2 minutes. A bad token → portal email-link sign-in.
  const now = Math.floor(Date.now()/1000);
  jwt.sign({ sub:user.id, email:user.email, name:user.displayName, jti:crypto.randomUUID(),
    iat:now, exp:now+120 }, SSO_SECRET, { algorithm:'HS256' });

Agent jump-to-admin — Settings → Customer accounts: Account URL template with
  {account_id} (= externalId), e.g. https://admin.yourapp.com/users/{account_id};
  optional Search URL with {email}. https only; placeholder in path/query, not the domain.

## Choosing an approach
Authenticated app → SSO (verified, no email step) + API create for backend-filed tickets.
Public site → widget (verify-first). Existing inbox → email intake + webhook→enrich.
Add webhooks to react to replies/status; use the read-back API to show ticket state in
the app. Always set Customer accounts templates so agents can open the account.

## Errors & limits
400 bad/missing field · 401 bad/missing API key · 404 not found · 409 externalId already
linked · 429 rate limited (back off). API keys ~120/min. SSO tokens ~2 min, single-use.
Webhooks: at-least-once, dedupe on X-ES-Delivery, verify signatures, respond 2xx fast.
(A failed webhook signature check is YOUR handler's 401, not one ES returns.)