Klarhet JSON API
Machine-facing HTTP API under /api/v1/*. Intended for bot integrations and external automation.
Authentication
All /api/v1/* endpoints require an API key via bearer token:
Authorization: Bearer klh_<prefix>_<secret>
- Session cookies are not accepted on
/api/v1/*. - Tokens are bound to a user (bot or human). Permissions live on the user, not the token. To split permission sets, create multiple bot users.
- Bot accounts inherit
@mentionresolution, project membership, and role checks from the existing user system.
Create tokens from the bot management page in the web UI. The plaintext secret is shown exactly once at creation time — the server only stores its hash.
Token format
klh_<8 hex prefix>_<32 base64url secret>
The prefix is a unique lookup key. The secret is hashed and verified with constant-time compare on every request.
Errors
Errors return JSON:
{"error": "not found"}
Status codes: 400 malformed body, 401 missing / invalid / disabled token, 403 permission / throttle, 404 not found or not visible, 500 server error.
Accounts that trigger the write-abuse threshold are automatically suspended pending admin review. This applies equally to API and web UI users.
Identity
GET /api/v1/me
Returns the user bound to the token. Useful for smoke-testing credentials.
{
"id": "a3Bf9kLm2xQz",
"login_name": "releasebot",
"display_name": "Release Bot",
"is_bot": true,
"permissions": 0
}
Projects
GET /api/v1/projects
Lists projects the bound user can see.
{
"projects": [
{
"id": "Xk9mP2nR4vLw",
"name": "klarhet",
"description": "…",
"visibility": "public",
"created_at": "2026-01-12T10:03:00Z"
}
]
}
GET /api/v1/projects/{id}
Returns a single project if visible. 404 otherwise. The {id} parameter is the project's public ID (e.g. Xk9mP2nR4vLw).
Tickets
GET /api/v1/projects/{id}/tickets
Lists tickets for a project. Query parameters:
| Param | Values | Default |
|---|---|---|
state |
open, closed |
all |
type |
feature, bug |
all |
priority |
urgent, normal, low |
all |
limit |
1–200 | 50 |
Response:
{
"tickets": [
{
"id": "Hn3kQ8wLp5Ry",
"project_id": "Xk9mP2nR4vLw",
"title": "Login redirect loops",
"description": "…",
"type": "bug",
"priority": "urgent",
"state": "open",
"created_by": "a3Bf9kLm2xQz",
"author": "Viktor",
"created_at": "2026-04-10T09:15:22Z"
}
]
}
GET /api/v1/tickets/{id}
Returns a single ticket if visible. Closed tickets also include closed_at and close_reason.
POST /api/v1/projects/{id}/tickets
Creates a ticket. Requires contributor role or higher on the project.
Request:
{
"title": "Login redirect loops",
"description": "Happens on Safari after OAuth.",
"type": "bug",
"priority": "normal"
}
titleis required.typedefaults tofeatureif omitted or unrecognized.prioritydefaults tonormal.
Response: 201 Created with the full ticket object. Subscribed webhooks fire for this event.
Comments
GET /api/v1/tickets/{id}/comments
Lists non-deleted comments on a ticket (oldest first).
{
"comments": [
{
"id": "Yt7wN1cR9mKx",
"ticket_id": "Hn3kQ8wLp5Ry",
"user_id": "a3Bf9kLm2xQz",
"author": "Viktor",
"body": "Repro steps: …",
"created_at": "2026-04-10T09:20:00Z"
}
]
}
POST /api/v1/tickets/{id}/comments
Adds a comment. Requires visibility on the ticket's project (members, or any authenticated user on public projects).
Request:
{"body": "Looks like a cookie mismatch."}
Response: 201 Created with the full comment object. Subscribed webhooks fire for this event.
Ticket relations
Directed links between tickets (blocks / duplicates / relates_to / parent_of). The relates_to type is symmetric — creating A→B with relates_to is indistinguishable from B→A.
GET /api/v1/tickets/{id}/relations
Lists every relation touching the ticket (either side).
{
"relations": [
{
"id": "Qm4jT6vBn8Fw",
"type": "blocks",
"outgoing": true,
"other_ticket_id": "Lp2sW7dK0xRv",
"other_title": "Migrate auth",
"other_state": "open",
"created_by": "a3Bf9kLm2xQz",
"created_at": "2026-04-15T11:02:00Z"
}
]
}
outgoing = true means this ticket is the from side (e.g. "Blocks"). false means it's the to side (e.g. "Blocked by"). Symmetric relations always return outgoing: true.
POST /api/v1/tickets/{id}/relations
Creates a relation. Requires contributor role or higher on the project. Both tickets must live in the same project.
Request:
{"type": "blocks", "target_id": "Lp2sW7dK0xRv"}
Valid type values: blocks, duplicates, relates_to, parent_of.
Response: 201 Created with the new relation object. Subscribed webhooks fire for this event.
DELETE /api/v1/tickets/{id}/relations/{relID}
Removes a relation. The relation must touch the named ticket. Returns 204 No Content. Subscribed webhooks fire for this event.
Notifications
Mentions (@login in tickets/comments) create per-user notification rows. Bots can poll these instead of (or alongside) webhook delivery.
GET /api/v1/notifications
Lists the bound user's notifications, newest first. Query parameters:
| Param | Values | Default |
|---|---|---|
unread |
1 for unread only |
all |
limit |
1–200 | 50 |
Response:
{
"notifications": [
{
"id": "Vn8cJ3qW5tRm",
"type": "mention",
"title": "Viktor mentioned you in Login redirect loops",
"link": "/projects/Xk9mP2nR4vLw/tickets/Hn3kQ8wLp5Ry#comment-Yt7wN1cR9mKx",
"read": false,
"created_at": "2026-04-10T09:20:00Z"
}
],
"unread_count": 1
}
The link field is a relative browser URL. Bots can parse it to recover the relevant project, ticket, and comment IDs.
POST /api/v1/notifications/{id}/read
Marks a single notification read. 404 if it isn't owned by the bound user.
POST /api/v1/notifications/read
Marks every unread notification for the bound user as read.
Webhooks
Per-project outbound HTTP hooks. Configured in the project settings page or via this API. Requires project admin role.
GET /api/v1/projects/{id}/webhooks
Lists webhooks for a project. Secrets are never returned here.
{
"webhooks": [
{
"id": "Gw5rK1nM7pTx",
"project_id": "Xk9mP2nR4vLw",
"url": "https://example.com/hook",
"topics": "ticket.created,comment.added",
"status": "active",
"created_by": "a3Bf9kLm2xQz",
"created_at": "2026-04-01T12:00:00Z"
}
]
}
POST /api/v1/projects/{id}/webhooks
Creates a webhook. The generated secret is returned only in this response; store it now or rotate.
Request:
{
"url": "https://example.com/hook",
"topics": "ticket.created,comment.added"
}
urlis required.topicsis a comma-separated subscription filter. Empty string = all supported topics.
Response: 201 Created
{
"id": "Gw5rK1nM7pTx",
"project_id": "Xk9mP2nR4vLw",
"url": "https://example.com/hook",
"topics": "ticket.created,comment.added",
"status": "active",
"created_by": "a3Bf9kLm2xQz",
"created_at": "2026-04-01T12:00:00Z",
"secret": "<base64url, shown once>"
}
DELETE /api/v1/projects/{id}/webhooks/{hookID}
Removes a webhook. Returns 204 No Content. Any in-flight deliveries for the removed hook are abandoned.
Webhook delivery protocol
When a subscribed event fires, a delivery is enqueued for each matching active webhook.
Request
- Method:
POST Content-Type: application/jsonX-Klarhet-Topic: <topic>X-Klarhet-Signature: sha256=<hex>— HMAC-SHA256 of the raw body using the webhook's secret
Body (envelope):
{
"topic": "comment.added",
"delivered_at": "2026-04-10T09:20:00Z",
"data": {
"comment_id": "Yt7wN1cR9mKx",
"ticket_id": "Hn3kQ8wLp5Ry",
"project_id": "Xk9mP2nR4vLw",
"user_id": "a3Bf9kLm2xQz"
}
}
Topics
| Topic | Payload fields |
|---|---|
ticket.created |
ticket_id, project_id, created_by |
ticket.updated |
ticket_id, project_id, … |
ticket.closed |
ticket_id, project_id, … |
ticket.reopened |
ticket_id, project_id, … |
comment.added |
comment_id, ticket_id, project_id, user_id |
ticket.relation_added |
relation_id, ticket_id, other_ticket_id, project_id, type, user_id |
ticket.relation_removed |
relation_id, ticket_id, other_ticket_id, project_id, type, user_id |
Receivers should re-fetch via the JSON API for the full resource — payloads carry only IDs.
Retries
Deliveries are attempted with a 10-second HTTP timeout.
- Success: any
2xxstatus →delivered. - Failure: non-2xx or transport error →
failed, next attempt scheduled per backoff. - Backoff (attempt → wait):
1 → 30s,2 → 2m,3 → 10m,4 → 1h,5 → 6h. - After 5 failed attempts →
dead. No further retries. - Delivered records are purged after 14 days.
Verifying signatures
Pseudocode:
expected = "sha256=" + hex(hmac_sha256(body_raw, hook_secret))
constant_time_compare(expected, request.headers["X-Klarhet-Signature"])
Use the raw request body (no re-serialization) for the HMAC input.
Rate limits
There is no per-token rate limit. Write operations are throttled — accounts that exceed the abuse threshold are automatically suspended pending admin review. This applies equally to API and web UI users. Reads are unthrottled.