API & Webhooks
Iskue exposes a JSON REST API under /api/v1. Everything the web UI does goes through this API, so anything you see in the interface can be scripted. Examples below use http://localhost:8088 — the full-stack Docker Compose port, whose bundled proxy forwards /api/ to the backend. Substitute your own host.
The interactive OpenAPI reference (springdoc) is served by the backend at /swagger-ui.html, with the raw specification at /v3/api-docs. Both are reachable without authentication, alongside /api/v1/auth/** and /actuator/health; every other endpoint requires a JWT. Because the unauthenticated docs publish a complete map of the API, internet-facing instances can switch them off with APP_API_DOCS_ENABLED=false, which makes both routes 404 outright.
In the full-stack Compose profile the frontend proxy forwards only /api/, the OAuth2 paths and /actuator/health — not the Swagger paths. To browse /swagger-ui.html, reach the backend directly on port 8080: either run the backend locally, or publish the port by adding ports: ["8080:8080"] to the backend service in docker-compose.yml.
Authentication
The API is stateless and uses JWT bearer tokens. Obtain a token from the auth endpoint with an email and password (accounts are created via POST /api/v1/auth/register with email, password and displayName, or by an administrator). Tokens are signed with JWT_SECRET and expire after JWT_EXPIRATION_MINUTES (default 480 minutes).
curl -s -X POST http://localhost:8088/api/v1/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"admin@example.com","password":"your-password"}'
# → {"mfaRequired":false,"mfaToken":null,"token":"<jwt>","user":{...}}
TOKEN='<jwt>'If the account has two-factor authentication enabled, the login response instead returns {"mfaRequired":true,"mfaToken":"..."}. Complete the login with POST /api/v1/auth/login/mfa and a body of {"mfaToken":"...","code":"<totp-or-recovery-code>"}; the response then carries the real token. Send the token on every request as a bearer header.
curl -s -H "Authorization: Bearer $TOKEN" \
'http://localhost:8088/api/v1/projects/PROJ/tickets?page=0&size=50'Built-in rate limits apply per minute: 10 requests per IP on /api/v1/auth/**, 120 per IP on the inbound webhook paths, 60 per IP on the customer-portal paths (/api/v1/portal/**), and 240 per user elsewhere. Exceeding a limit returns HTTP 429. The caps are the configuration properties app.ratelimit.auth-per-min, app.ratelimit.webhook-per-min and app.ratelimit.api-per-min.
Outbound webhooks
Each project can register HTTP endpoints that receive a signed POST whenever ticket activity occurs. Webhooks are managed per project by a project admin; the shared secret is write-only and never returned by the API.
| Method and path | Purpose |
|---|---|
GET /api/v1/projects/{projectKey}/webhooks | List webhooks (id, url, events, active, createdAt) |
POST /api/v1/projects/{projectKey}/webhooks | Create a webhook: url (http/https, max 500 chars), secret (16–100 chars), optional events |
GET /api/v1/projects/{projectKey}/webhooks/{webhookId}/deliveries | Last 50 delivery attempts (event, status, attempts, lastError) |
DELETE /api/v1/projects/{projectKey}/webhooks/{webhookId} | Remove a webhook |
curl -s -X POST http://localhost:8088/api/v1/projects/PROJ/webhooks \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"url":"https://example.com/hooks/iskue","secret":"a-strong-shared-secret","events":"STATUS_CHANGED,COMMENTED"}'events is either ALL (the default) or a comma-separated list of event types, matched case-insensitively. The event catalogue is: CREATED, UPDATED, COMMENTED, STATUS_CHANGED, ASSIGNED, MENTIONED, SLA_BREACHED, APPROVAL_REQUESTED.
Delivery format
Each delivery is an HTTP POST with Content-Type: application/json and three headers: X-IssueHub-Event (the event type), X-IssueHub-Delivery (a unique delivery UUID — use it for idempotency), and X-IssueHub-Signature (lower-case hex HMAC-SHA256 of the raw body, keyed with the webhook secret — no sha256= prefix). The body has a fixed shape: event, ticketKey, message, timestamp (ISO-8601 UTC, set when the event was enqueued).
POST /hooks/iskue HTTP/1.1
Content-Type: application/json
X-IssueHub-Event: STATUS_CHANGED
X-IssueHub-Delivery: 0f6c9d2e-6a4e-4b7e-9df0-3a1b2c3d4e5f
X-IssueHub-Signature: 4f9c1a...e21a
{"event":"STATUS_CHANGED","ticketKey":"PROJ-42","message":"...","timestamp":"2026-08-03T12:34:56.789Z"}Verify the signature before trusting a payload: recompute the HMAC over the exact raw request body and compare it (constant-time) with the header value.
# BODY must be the raw request body, byte for byte
printf '%s' "$BODY" \
| openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" -hex \
| awk '{print $NF}'
# compare the output with the X-IssueHub-Signature headerRetries and delivery history
Deliveries use an outbox: the payload is stored in the same database transaction as the change, so events are never lost on a crash. A scheduled poller runs every 15 seconds, picks up to 20 due deliveries per pass, and claims each with a 60-second lease so exactly one node delivers when several backends share the database. Any response status below 400 counts as success; timeouts are 5 s to connect and 10 s per request. Failures retry with exponential backoff up to 5 attempts in total; inspect the per-webhook deliveries endpoint to see status, attempt count and last error.
| Attempt | When |
|---|---|
| 1 | Next poller pass after the event (within about 15 s) |
| 2 | 30 s after the first failure |
| 3 | 60 s after the second failure |
| 4 | 2 min after the third failure |
| 5 | 4 min after the fourth failure — the final try; if it fails the delivery is marked FAILED |
Slack notifications
Each project can relay ticket activity to one Slack channel via a Slack incoming webhook. The URL must match https://hooks.slack.com/… and is write-only: GET /api/v1/projects/{projectKey}/slack returns only configured, events and active. On update, a blank webhookUrl keeps the existing one. The events filter uses the same values as outbound webhooks (ALL or a comma-separated list). Messages are posted asynchronously after the transaction commits, so a slow or unreachable Slack never delays API requests. All endpoints require a project admin.
# Configure (or update) the integration
curl -s -X PUT http://localhost:8088/api/v1/projects/PROJ/slack \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"webhookUrl":"https://hooks.slack.com/services/T000/B000/XXXXXXXX","events":"ASSIGNED,COMMENTED,STATUS_CHANGED","active":true}'
# Send a synchronous test message
curl -s -X POST http://localhost:8088/api/v1/projects/PROJ/slack/test \
-H "Authorization: Bearer $TOKEN"
# → {"delivered":true}
# Disconnect
curl -s -X DELETE http://localhost:8088/api/v1/projects/PROJ/slack \
-H "Authorization: Bearer $TOKEN"Inbound Git webhooks
Iskue links commits, branches and pull requests to tickets by listening to pushes from your Git host. First, a project admin configures the integration with a shared token (16–100 chars, write-only): GET/PUT/DELETE /api/v1/projects/{projectKey}/git with body fields token, active and smartCommitsEnabled.
curl -s -X PUT http://localhost:8088/api/v1/projects/PROJ/git \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"token":"change-me-32-characters-minimum!","active":true,"smartCommitsEnabled":false}'The receiving endpoint is POST /api/v1/git/webhook/{projectKey}. It carries no user authentication; each request is verified against the project token in one of two ways. GitHub: set the webhook *Secret* to the token and send Content-Type: application/json; GitHub signs the body as X-Hub-Signature-256: sha256=<hex HMAC-SHA256>, which Iskue checks in constant time. Subscribe the webhook to the push and pull request events — X-GitHub-Event: pull_request routes to PR handling, any other (or absent) value is treated as a push. GitLab or generic clients: send the token verbatim in X-Gitlab-Token (GitLab does this automatically) or X-IssueHub-Git-Token.
curl -s -X POST http://localhost:8088/api/v1/git/webhook/PROJ \
-H 'Content-Type: application/json' \
-H 'X-IssueHub-Git-Token: change-me-32-characters-minimum!' \
-d '{
"ref": "refs/heads/PROJ-7-fix-login",
"repository": {"html_url": "https://github.com/acme/repo"},
"commits": [{
"id": "9f2c1e7d8a5b4c3f2e1d0c9b8a7f6e5d4c3b2a19",
"message": "PROJ-7 fix login redirect",
"url": "https://github.com/acme/repo/commit/9f2c1e7",
"author": {"name": "Dev", "email": "dev@example.com"}
}]
}'
# → {"linkedCommits":2,"appliedCommands":0}Ticket references are extracted as <KEY>-<number> on a word boundary, case-insensitively (XPROJ-1 does not match project PROJ). Commits mentioning a ticket become commit links; a push to refs/heads/<branch> whose branch name references a ticket becomes a branch link; a pull_request event whose title or head branch references a ticket becomes a PR link whose state (OPEN/MERGED/CLOSED) updates in place on re-delivery. All linking is idempotent — repeated deliveries never duplicate rows. The response reports linkedCommits and appliedCommands.
Smart commits (#comment, #time, #<status> in commit messages) act *as the commit author*, matched by email against Iskue users — an identity the push payload asserts but does not prove. They are therefore off by default and only run where a project admin has explicitly set smartCommitsEnabled: true. Commit linking itself is unaffected by this switch.
CSV export and import
Any project member can export tickets as CSV from GET /api/v1/projects/{projectKey}/tickets.csv. The export is capped at 10,000 tickets (fetched internally in 50 pages of 200) and returns columns key,type,title,status,priority,assignee,reporter,estimate,labels,created, with labels pipe-separated and a Content-Disposition filename of <projectkey>-tickets.csv.
curl -s -H "Authorization: Bearer $TOKEN" \
-o proj-tickets.csv \
http://localhost:8088/api/v1/projects/PROJ/tickets.csvImport tickets with POST /api/v1/projects/{projectKey}/import/csv; the body is JSON with the CSV in a csv field. The header row decides the mapping — only title is required; type, priority, description, estimate and labels (pipe-separated) are also recognised, along with the Jira CSV aliases summary → title, issue type → type and story points → estimate. Imports are limited to 1,000 data rows per request. Each row is created in its own transaction, so one bad row does not roll back the rest; the response lists per-line errors.
curl -s -X POST http://localhost:8088/api/v1/projects/PROJ/import/csv \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"csv":"title,type,priority,labels\nFix login redirect,Bug,High,auth|frontend\nDark mode,Story,Medium,"}'
# → {"imported":2,"errors":[]}Jira importer
For larger migrations Iskue can import directly from Jira Cloud or Data Center over Jira's REST API. The feature is off by default and its endpoints only exist when the property app.jira-import.enabled is true — set APP_JIRA_IMPORT_ENABLED=true in the backend environment and restart. All endpoints require the global ADMIN role; the probe GET /api/v1/admin/jira-import/enabled is always mapped so tooling can check availability cleanly. Authentication to Jira uses authType "basic" (Jira Cloud: email + API token) or "bearer" (Data Center: Personal Access Token). Per project, at most maxIssuesPerProject issues are imported (default 500); attachments are only fetched when includeAttachments is true. The import runs in the background — poll its status (RUNNING, COMPLETED or FAILED, with counts and a log) via the returned importId.
# Probe availability (ADMIN token required)
curl -s -H "Authorization: Bearer $TOKEN" \
http://localhost:8088/api/v1/admin/jira-import/enabled
# → {"enabled":true}
# Test the Jira connection
curl -s -X POST http://localhost:8088/api/v1/admin/jira-import/test-connection \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"baseUrl":"https://your-site.atlassian.net","authType":"basic","username":"you@example.com","secret":"<jira-api-token>"}'
# List importable Jira projects
curl -s -X POST http://localhost:8088/api/v1/admin/jira-import/projects \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"baseUrl":"https://your-site.atlassian.net","authType":"basic","username":"you@example.com","secret":"<jira-api-token>"}'
# Start an import
curl -s -X POST http://localhost:8088/api/v1/admin/jira-import/run \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"baseUrl":"https://your-site.atlassian.net","authType":"basic","username":"you@example.com","secret":"<jira-api-token>","projectKeys":["PROJ"],"maxIssuesPerProject":500,"includeAttachments":false}'
# → {"importId":"..."}
# Poll progress
curl -s -H "Authorization: Bearer $TOKEN" \
http://localhost:8088/api/v1/admin/jira-import/status/<importId>