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.

EndpointPurpose
GET /api/v1/portal/meContact email/name plus project key/name for the token
PUT /api/v1/portal/me/localeSet the customer's email language — en or fr (204)
GET /api/v1/portal/requestsThe 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/requestsRaise a request (201); title ≤ 255 chars, description ≤ 50,000
GET /api/v1/portal/requests/{ticketKey}/commentsCustomer-visible thread, oldest first
POST /api/v1/portal/requests/{ticketKey}/commentsCustomer reply (201); body ≤ 50,000 chars
GET /api/v1/portal/formsThe 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.

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.

GET the same path for link status (valid/expiry/last used); DELETE to revoke.
# 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":"..."}
EndpointAccessPurpose
GET/POST/PATCH/DELETE /api/v1/projects/{projectKey}/contactsmember read; write member manageCustomer directory. Email is unique per project, stored lower-cased
GET /api/v1/tickets/{ticketKey}/requestermemberThe contact who requested a ticket (null if internal)
PUT / DELETE /api/v1/tickets/{ticketKey}/requesterwrite memberSet or clear the requester; the contact must belong to the ticket's project
GET /api/v1/projects/{projectKey}/contacts/{contactId}/portal-linkwrite memberLink status — never exposes the token
POST … /portal-link?notify=false|truewrite memberIssue a fresh link (201); revokes any prior one
DELETE … /portal-linkwrite memberRevoke (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.

A free-form question (fieldId null) and a question bound to a custom field.
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.

Invalid TQL is rejected at save time with 400 Invalid TQL.
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"}'
EndpointAccessPurpose
GET /api/v1/projects/{projectKey}/queuesmemberQueues in display order, with live counts
GET … /queues/{id}/tickets?page=0&size=50memberRun a queue; size is capped at 200
POST … /queuesproject adminCreate (201); name ≤ 100 chars, unique per project
PATCH … /queues/{id}project adminUpdate name/description/query (blank query clears the filter)
DELETE … /queues/{id}project adminDelete (204)
POST … /queues/reorderproject adminReorder 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.

In the production compose file, PORTAL_BASE_URL is derived from PUBLIC_ORIGIN.
# 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-To

Knowledge 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.

EndpointAccessPurpose
GET /api/v1/projects/{projectKey}/kb?q=&includeDrafts=memberList, newest-updated first; includeDrafts=true requires write membership
GET … /kb/{id}memberOne article; a draft requires write membership
POST … /kbwrite memberCreate (201) with title, body, published
PUT … /kb/{id}write memberUpdate; the published flag is applied as sent
DELETE … /kb/{id}project adminDelete (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.

EndpointAccessPurpose
GET /api/v1/projects/{projectKey}/sla-policiesproject adminList policies in creation order
POST … /sla-policiesproject adminCreate (201): name ≤ 100, priority (or ANY/blank), responseMinutes, resolutionMinutes, active
PUT … /sla-policies/{id}project adminUpdate a policy
DELETE … /sla-policies/{id}project adminDelete (204); existing ticket timers keep their due times
GET /api/v1/tickets/{ticketKey}/slamemberTimer view: due/achieved instants and derived responseBreached / resolutionBreached; tracked:false when no policy applies
GET/PUT/DELETE /api/v1/projects/{projectKey}/sla-calendarmember (GET); project admin (PUT/DELETE)The business-hours calendar; absent = 24/7 targets