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
- API keys (your backend → ES) — Create tickets from your app, read ticket state back, attach customer identity.
- Webhooks (ES → your backend) — React to ticket events in real time: create, reply, status, assignment.
- Widget (browser → ES) — Drop a support launcher into your web app with one script tag.
- Email intake (email → ES) — Turn a support inbox into tickets; customers reply by email.
- Customer identity & verification (your backend ↔ ES) — Let ES attach your user IDs to tickets, show agents a verified-customer badge, and jump agents into your admin.
Which should I use?
- You already create tickets server-side (or want the tightest control). Use API keys — pass the customer’s email + your user ID on create and they’re verified from the start. Add webhooks to react to replies.
- Fastest install on a website or web app. Use the widget — one script tag. Pair it with SSO so logged-in visitors are recognized automatically.
- Support already lives in an inbox. Use email intake, then the webhook → enrich loop to link emails to your user records.
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.
| Secret | Where | Used for |
|---|---|---|
es_live_… API key | Settings → API keys | Server-to-server calls (Bearer auth). |
| SSO signing secret | Settings → Connections | Signing short-lived identity tokens. |
| Webhook signing secret | Settings → Webhooks | Verifying events ES sends you. |
Core concepts
- Ticket — one support conversation. Has a short reference
ref(e.g.A7K2M9Q), asubject, astatus(open/closed), apriority(high/med/low), and asource(api,widget,email,portal,sso_form,public_form— handle unknown future values gracefully). - Customer — the end user a ticket belongs to. Identified by email and, when you provide it, your own
externalId(your user ID). - Verified customer — one whose ticket carries an
externalIdyou supplied through a trusted channel (API key, SSO, or enrich). Agents see a badge and can open that user in your admin.
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
| Field | Required | Notes |
|---|---|---|
email | ✓ | The customer’s email. Finds or creates their record. |
subject | ✓ | Short summary. |
message | ✓ | Plain-text body. Use messageHtml for HTML. |
externalId | — | Your user ID for this customer. Providing it verifies the customer and links the ticket to your admin. Recommended. |
name | — | Display name. |
priority | — | high · med · low (default med). API-created tickets are the only ones that may set priority. |
type | — | Ticket type — its name (e.g. "Billing", case-insensitive) or id. Unknown → untyped. List via GET /api/ticket-types. |
locale | — | The 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" }
externalId— links the ticket’s customer to your user (verifies them).note— an internal note, visible only to agents (never the customer). Great for plan/tenure/flags.- At least one of
externalId/name/noteis required. A409means thatexternalIdis already linked to a different customer.
The typical pattern: listen for the ticket.created webhook, look the customer up by email, and call enrich. See Customer identity & verification.
Errors
| Status | Meaning |
|---|---|
400 | Missing/invalid field. |
401 | Missing or invalid API key. |
404 | Ticket not found (or not in this workspace). |
409 | externalId already linked to another customer (enrich). |
429 | Rate 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
| Event | Fires when |
|---|---|
ticket.created | A new ticket is filed (any source). |
ticket.agent_replied | An agent replies to the customer. |
ticket.customer_replied | The customer replies. |
ticket.status_changed | A ticket is opened or closed. |
ticket.assigned | A 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 }" }
}
ticketmatches the read-back API — so you can listen for the event, then fetch the detail.- A
messageblock (id + kind + time, no body) is included on the two reply events. Achangesblock ({ status: { from, to } }) is included onticket.status_changed. customer.externalIdisnullfor tickets that arrived without identity (widget / email) — your cue to run the enrich loop.workspace(and theX-ES-Workspaceheader) is your workspace slug — a stable identifier, never a host. It doesn’t change if you add or switch a custom domain, so key any per-workspace routing on it, not on a URL.
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
- At-least-once — de-duplicate on
X-ES-Delivery(unique per delivery). - Success = any
2xx. Failures retry with backoff (~30s → 2m → 10m → 1h → 4h), up to 6 attempts, then drop. Return2xxfast; do heavy work asynchronously. - Ordering isn’t guaranteed — treat each event as “state may have moved on; fetch if unsure.”
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
- Reply threading works when the sending domain passes normal email auth (SPF/DKIM); mail that fails opens a new ticket instead (rare for genuine mail).
- Attachments are imported when your workspace has Images & Files (R2 storage) configured — up to 10 files, ~30 MB total, standard file-type allowlist, scanned like any upload. Without R2, the text still becomes a ticket; only files are skipped.
- Unknown senders may get a one-time “confirm your email” step before a ticket is created, depending on your Settings.
- Regenerating the intake address in Settings breaks existing forwarding rules — update them.
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.
| Channel | How identity is set | When |
|---|---|---|
| API create | You pass externalId on create | You file tickets from your backend |
| SSO | Your signed token carries the user’s ID as sub | Logged-in users file tickets in the portal/widget |
| Enrich | You call /enrich after the ticket exists | Tickets 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=/
| Claim | Required | Meaning |
|---|---|---|
email | ✓ | The user’s email (must be valid). |
sub | — | Your user ID → becomes externalId (verifies + links). Recommended. |
name | — | Display name. |
jti | ✓ | Unique random ID for this token (single-use — prevents replay). |
iat / exp | ✓ | Issued-at / expiry (unix seconds). Keep it short. |
iss / locale | — | If 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:
- Account URL — a template with an
{account_id}placeholder (replaced with the customer’sexternalId):https://admin.yourapp.com/users/{account_id}. Clicking a verified customer opens it in a new tab. - Search URL (optional) — an
{email}template for customers who aren’t linked yet. - Label (optional) — the link text agents see.
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.)