Service desk & customer portal
Iskue's service-desk layer lets external customers raise and follow requests without an account, while agents work the same tickets from queues inside the project. Six pieces cooperate: contacts (per-project customer identities), the customer portal (/api/v1/portal/**), request forms (portal intake bound to custom fields), queues (shared TQL-filtered work views), the knowledge base, and CSAT and SLA for quality tracking. This page covers each in turn, with the exact endpoints and limits.
The customer portal
The portal is a public, token-authenticated surface. A customer presents an opaque capability token in the X-Portal-Token header — not a user JWT — and sees only the requests they raised, in the one project the token was issued for. Everything is filtered to the token's (contact, project) pair; only PUBLIC comments are shown; internal agents appear by display name only (never email or id, with Support as the fallback); and any ticket key that is not theirs returns a uniform 404 Request not found, so a token cannot be used to enumerate projects or tickets.
| Endpoint | Purpose |
|---|---|
| GET /api/v1/portal/me | Contact email/name plus project key/name for the token |
| PUT /api/v1/portal/me/locale | Set the customer's email language — en or fr (204) |
| GET /api/v1/portal/requests | The contact's requests, most recently updated first (no descriptions) |
| GET /api/v1/portal/requests/{ticketKey} | One request in detail: key, title, status, priority, updatedAt, description |
| POST /api/v1/portal/requests | Raise a request (201); title ≤ 255 chars, description ≤ 50,000 |
| GET /api/v1/portal/requests/{ticketKey}/comments | Customer-visible thread, oldest first |
| POST /api/v1/portal/requests/{ticketKey}/comments | Customer reply (201); body ≤ 50,000 chars |
| GET /api/v1/portal/forms | The project's intake forms, with field types and options resolved |
All portal paths require the X-Portal-Token header. They are permitted without a JWT in the security configuration and rate-limited per IP at app.ratelimit.portal-per-min (default 60 requests/minute), on top of the token's 256 bits of entropy.
Issuing portal links (agent side)
Agents manage a contact's portal access from the JWT-authenticated API. Issuing a link generates 256 bits of randomness (base64url); the raw token is returned once and only its SHA-256 hash is stored. A link is valid for 14 days, and each contact has one active link — issuing a new one revokes the previous. With ?notify=true, Iskue emails the customer a link of the form {PORTAL_BASE_URL}/portal?token=<raw token>. All portal-link and contact-management writes require project write membership; contact and requester reads require membership.
# Issue (or re-issue) a portal link for a contact and email it to them
curl -X POST \
"https://issuehub.example.com/api/v1/projects/SUP/contacts/$CONTACT_ID/portal-link?notify=true" \
-H "Authorization: Bearer $JWT"
# -> {"token":"<raw token, shown once>","expiresAt":"..."}| Endpoint | Access | Purpose |
|---|---|---|
| GET/POST/PATCH/DELETE /api/v1/projects/{projectKey}/contacts | member read; write member manage | Customer directory. Email is unique per project, stored lower-cased |
| GET /api/v1/tickets/{ticketKey}/requester | member | The contact who requested a ticket (null if internal) |
| PUT / DELETE /api/v1/tickets/{ticketKey}/requester | write member | Set or clear the requester; the contact must belong to the ticket's project |
| GET /api/v1/projects/{projectKey}/contacts/{contactId}/portal-link | write member | Link status — never exposes the token |
| POST … /portal-link?notify=false|true | write member | Issue a fresh link (201); revokes any prior one |
| DELETE … /portal-link | write member | Revoke (idempotent) |
Request forms
Request forms shape what the portal asks when a customer raises a request. Each question has a label (≤ 200 chars, unique within the form, case-insensitive), a required flag, and an optional fieldId binding it to one of the project's custom fields. Form names are ≤ 120 chars and unique per project (case-insensitive); a form needs at least one question, and a fieldId must reference an existing custom field of the project. In v1 forms are create and delete only — there is no in-place edit. Listing is open to members; create and delete require a project admin, and both are recorded in the configuration audit log.
curl -X POST "https://issuehub.example.com/api/v1/projects/SUP/request-forms" \
-H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \
-d '{
"name": "Report a problem",
"description": "Tell us what went wrong.",
"questions": [
{"label": "What happened?", "fieldId": null, "required": true},
{"label": "Affected environment", "fieldId": "1f0d9a52-7c1e-4b3a-9a5e-2f8c4d6e7a90", "required": false}
]
}'On the portal, GET /api/v1/portal/forms returns each question with the bound field's type and options resolved (free-form questions render as TEXT). When the customer submits with a formId, answers are keyed by question label. Required questions must be answered; bound answers are validated against the custom field's type and stored as custom-field values; free-form answers are appended to the ticket description as a **label** / answer Q&A section.
From request to queue
A portal submission creates a ticket with a null reporter — the customer is recorded separately as the requester in ticket_requester — using the project's default ticket type, its workflow's initial status, and MEDIUM priority. No screens, watchers or additional validation apply: a portal request is intentionally minimal. Automation rules on the created trigger still fire. Email intake follows the same path: an inbound message posted to POST /api/v1/email/webhook/{projectKey} (verified by the per-project secret in X-IssueHub-Email-Token) upserts a contact by sender address and links it as requester.
Agents pick these tickets up from queues: named, ordered, project-shared views defined by a TQL filter (≤ 5,000 chars). The filter is always evaluated as project = "<KEY>" AND (<query>), so a queue can never leak another project's tickets; a blank query means all tickets in the project, and the caller's own visibility restrictions still apply on top. Members list queues (each with a live count of matching tickets — count is null if the stored TQL can no longer be evaluated) and run them; create, update, delete and reorder are project-admin actions, all config-audited.
curl -X POST "https://issuehub.example.com/api/v1/projects/SUP/queues" \
-H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \
-d '{"name":"Unassigned","description":"New requests with no owner","query":"status = \"Open\" AND assignee is empty"}'| Endpoint | Access | Purpose |
|---|---|---|
| GET /api/v1/projects/{projectKey}/queues | member | Queues in display order, with live counts |
| GET … /queues/{id}/tickets?page=0&size=50 | member | Run a queue; size is capped at 200 |
| POST … /queues | project admin | Create (201); name ≤ 100 chars, unique per project |
| PATCH … /queues/{id} | project admin | Update name/description/query (blank query clears the filter) |
| DELETE … /queues/{id} | project admin | Delete (204) |
| POST … /queues/reorder | project admin | Reorder by list of queue ids |
Internal notes vs customer replies
Every ticket comment has a visibility: PUBLIC (shown to the customer on the portal and to members) or INTERNAL (agent-only, never reaches a customer). Agents comment via POST /api/v1/tickets/{ticketKey}/comments with {"body": "...", "internal": true|false} — internal: true makes an agent-only note. Customer replies posted from the portal are always PUBLIC and carry the contact as author (authorId is null). The portal thread shows only PUBLIC comments, oldest first; the internal ticket view shows both, and marks contact-authored comments with the contact's name and email.
internal defaults to false on the comment API: an agent comment without the flag is a public reply, visible on the portal and emailed to the requester when mail is enabled. Send "internal": true explicitly for notes the customer must not see.
Email to requesters
Two things email the customer. A public agent reply on a ticket with a requester triggers a notification to that contact — skipped if the contact has unsubscribed. A freshly issued portal link (with ?notify=true) is sent as a transactional message, not gated by unsubscribe. Reply notifications are multipart (text + HTML), carry RFC 5322 threading headers so the customer's mail client groups a ticket's messages, include an unsubscribe link when PORTAL_BASE_URL is set, and are localised to the contact's chosen locale (en or fr).
Outbound mail actually sends only when MAIL_ENABLED=true; otherwise the sender is a no-op that logs. When EMAIL_INBOUND_ADDRESS is set, each customer email gets a per-ticket Reply-To of the form support+SUP-42@example.com, so replies thread back to the right ticket even if the subject is edited — deliver them to the email webhook via your mail-parsing service.
# Backend environment (deploy/.env or compose)
PORTAL_BASE_URL=https://support.example.com # default: http://localhost:5173; feeds portal + unsubscribe links
MAIL_ENABLED=true # default: false — outbound mail is logged, not sent
MAIL_FROM=support@example.com # default: issuehub@localhost
EMAIL_INBOUND_ADDRESS=support@example.com # default: empty — enables the per-ticket Reply-ToKnowledge base
Each project has a knowledge base of articles that are either drafts or published. All project members read published articles; drafts and authoring require write membership, and deletion a project admin. Search uses the q parameter and is a case-insensitive substring match over both title and body, applied in memory to the project's articles. Titles are ≤ 200 chars and bodies ≤ 100,000. The knowledge base is an internal surface — there is no portal endpoint for it, so customers do not see articles.
| Endpoint | Access | Purpose |
|---|---|---|
| GET /api/v1/projects/{projectKey}/kb?q=&includeDrafts= | member | List, newest-updated first; includeDrafts=true requires write membership |
| GET … /kb/{id} | member | One article; a draft requires write membership |
| POST … /kb | write member | Create (201) with title, body, published |
| PUT … /kb/{id} | write member | Update; the published flag is applied as sent |
| DELETE … /kb/{id} | project admin | Delete (204) |
CSAT
Once a ticket reaches a DONE-category status, its reporter may rate it: score 1–5 with an optional comment (≤ 2,000 chars). Only the reporter can rate, the ticket must be resolved, and there is one rating per ticket — rating again updates it. Any project member can read a ticket's rating and the project roll-up, which returns the count, the average (rounded to two decimal places, 0.0 when empty) and a distribution with keys 1 through 5.
Portal-raised tickets have a null reporter, so they cannot be rated — CSAT applies to tickets with an internal reporter. There is no customer-facing rating endpoint on the portal in this release.
curl -X POST "https://issuehub.example.com/api/v1/tickets/SUP-42/csat" \
-H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \
-d '{"score":5,"comment":"Fast and friendly"}'
curl "https://issuehub.example.com/api/v1/projects/SUP/csat" -H "Authorization: Bearer $JWT"
# -> {"count":12,"average":4.42,"distribution":{"1":0,"2":1,"3":1,"4":2,"5":8}}SLA policies and timers
An SLA policy sets a first-response target and/or a resolution target in minutes, optionally scoped to one priority; a policy with no priority (ANY) is the fallback for priorities without a more specific policy. At least one of responseMinutes / resolutionMinutes must be set, and matching prefers the exact-priority policy, then the fallback — earliest-created wins among candidates. Policy administration, including listing, requires a project admin.
A ticket's timer row is created lazily from the matching active policy, with due times measured from the ticket's creation. First response is the first comment or status change by someone other than the reporter — customer portal replies carry no internal actor and never count. Resolution is reaching a DONE-category status; reopening clears it, and a later re-resolution restarts the clock check against the original due time. Breach status is derived at read time from due vs achieved vs now — it is never stored. If the project has a business calendar (sla-calendar: weekday mask, daily window and timezone, default Monday–Friday 09:00–17:00 UTC, plus holidays), policy minutes elapse only inside the working window; without one, targets run 24/7.
Hourly breach sweep
A scheduled sweep runs every hour (fixed delay 3,600,000 ms) and turns read-time breaches into an alert exactly once per breached target (first-response and resolution are notified independently). Each candidate is processed in its own transaction, and the notified flag is claimed with a compare-and-set before publishing, so two backend nodes racing the same breach produce a single SLA_BREACHED notification — routed through the project's notification scheme, which targets the assignee by default. Breached tickets with no assignee are claimed silently so the sweep does not reconsider them every hour: an unassigned breach produces no alert.
| Endpoint | Access | Purpose |
|---|---|---|
| GET /api/v1/projects/{projectKey}/sla-policies | project admin | List policies in creation order |
| POST … /sla-policies | project admin | Create (201): name ≤ 100, priority (or ANY/blank), responseMinutes, resolutionMinutes, active |
| PUT … /sla-policies/{id} | project admin | Update a policy |
| DELETE … /sla-policies/{id} | project admin | Delete (204); existing ticket timers keep their due times |
| GET /api/v1/tickets/{ticketKey}/sla | member | Timer view: due/achieved instants and derived responseBreached / resolutionBreached; tracked:false when no policy applies |
| GET/PUT/DELETE /api/v1/projects/{projectKey}/sla-calendar | member (GET); project admin (PUT/DELETE) | The business-hours calendar; absent = 24/7 targets |