All endpoints live under /api/v1/. Responses are JSON. All requests require authentication.
Authentication
All endpoints require an API key passed as a bearer token:
Authorization: Bearer klh_<prefix>_<secret>
Create API keys from the bot management page in the admin panel. The key is shown once at creation — store it somewhere safe.
Tokens are tied to a user account (bot or human). The user's roles and project memberships determine what the token can access. To separate permission sets, create multiple bot accounts.
Errors
Errors return JSON:
{"error": "not found"}
| Status | Meaning |
|---|---|
400 |
Malformed request body |
401 |
Missing or invalid token |
403 |
Insufficient permissions |
404 |
Not found or not visible |
500 |
Server error |
Identity
GET /api/v1/me
Returns the user tied to the token.
{
"id": "a3Bf9kLm2xQz",
"login_name": "releasebot",
"display_name": "Release Bot",
"is_bot": true,
"permissions": 0
}
Projects
GET /api/v1/projects
Lists projects visible to the authenticated user.
{
"projects": [
{
"id": "Xk9mP2nR4vLw",
"name": "klarhet",
"description": "…",
"visibility": "public",
"created_at": "2026-01-12T10:03:00Z"
}
]
}
GET /api/v1/projects/{id}
Returns a single project. 404 if not visible.
Tickets
GET /api/v1/projects/{id}/tickets
Lists tickets in a project.
| Param | Values | Default |
|---|---|---|
state |
open, closed |
all |
type |
feature, bug |
all |
priority |
p1, p2, p3 |
all |
search |
text | — |
limit |
1–200 | 50 |
Response:
{
"tickets": [
{
"id": "Hn3kQ8wLp5Ry",
"project_id": "Xk9mP2nR4vLw",
"title": "Login redirect loops",
"description": "…",
"type": "bug",
"priority": "p1",
"state": "open",
"status": "in-progress",
"assigned_to": "b4Cg0lNn3yRa",
"assignee": "Alice",
"created_by": "a3Bf9kLm2xQz",
"author": "Viktor",
"created_at": "2026-04-10T09:15:22Z"
}
]
}
assigned_to and assignee are only present when someone is assigned. Closed tickets also include closed_at and close_reason.
GET /api/v1/tickets/{id}
Returns a single ticket. Includes labels when the ticket has any:
{
"id": "Hn3kQ8wLp5Ry",
"labels": [
{"id": "Km8nT4wQ2xRv", "name": "backend", "color": "#6366f1"}
]
}
POST /api/v1/projects/{id}/tickets
Creates a ticket. Requires contributor role or higher.
{
"title": "Login redirect loops",
"description": "Happens on Safari after OAuth.",
"type": "bug",
"priority": "p2"
}
titleis required.typedefaults tofeature.prioritydefaults top2(normal). Values:p1(urgent),p2(normal),p3(low).
Returns 201 with the full ticket object.
PATCH /api/v1/tickets/{id}
Updates a ticket. All fields are optional — only include what you want to change.
{
"title": "Updated title",
"description": "New description",
"type": "bug",
"priority": "p1",
"status": "in-progress",
"assigned_to": "b4Cg0lNn3yRa"
}
| Field | Values |
|---|---|
type |
feature, bug |
priority |
p1, p2, p3 |
status |
todo, in-progress, review, done |
assigned_to |
User ID, or "" to unassign |
Returns 200 with the updated ticket.
POST /api/v1/tickets/{id}/close
Closes a ticket.
{"close_reason": "fixed"}
close_reason is optional. Values: fixed (default), wontfix, notapplicable.
Returns 200 with the updated ticket.
POST /api/v1/tickets/{id}/reopen
Reopens a closed ticket. No request body needed.
Returns 200 with the updated ticket.
Comments
GET /api/v1/tickets/{id}/comments
Lists 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.
{"body": "Looks like a cookie mismatch."}
Returns 201 with the comment.
Ticket relations
Links between tickets: blocks, duplicates, relates_to, parent_of.
GET /api/v1/tickets/{id}/relations
Lists all relations touching this ticket.
{
"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 source (e.g. "blocks"). false means it's the target (e.g. "blocked by").
POST /api/v1/tickets/{id}/relations
Creates a relation. Both tickets must be in the same project.
{"type": "blocks", "target_id": "Lp2sW7dK0xRv"}
Valid types: blocks, duplicates, relates_to, parent_of.
Returns 201 with the relation.
DELETE /api/v1/tickets/{id}/relations/{relID}
Removes a relation. Returns 204.
Notifications
Mentions (@user in tickets and comments) and watcher events create notifications.
GET /api/v1/notifications
Lists notifications for the authenticated user, newest first.
| Param | Values | Default |
|---|---|---|
unread |
1 |
all |
limit |
1–200 | 50 |
{
"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
}
POST /api/v1/notifications/{id}/read
Marks a single notification as read.
POST /api/v1/notifications/read
Marks all notifications as read.
Webhooks
Per-project outbound HTTP hooks. Requires project admin role.
GET /api/v1/projects/{id}/webhooks
Lists webhooks for a project. Secrets are never returned.
{
"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 secret is returned only in this response — store it immediately.
{
"url": "https://example.com/hook",
"topics": "ticket.created,comment.added"
}
urlis required.topicsis a comma-separated filter. Empty = subscribe to everything.
Returns 201. The response includes a secret field.
DELETE /api/v1/projects/{id}/webhooks/{hookID}
Removes a webhook. Returns 204.
Webhook delivery
When a matching event fires, Klarhet sends a POST to each subscribed webhook.
Headers
Content-Type: application/jsonX-Klarhet-Topic— the event topicX-Klarhet-Signature— HMAC-SHA256 signature of the request body, formatted assha256=<hex>
Payload
{
"topic": "comment.added",
"delivered_at": "2026-04-10T09:20:00Z",
"data": {
"comment_id": "Yt7wN1cR9mKx",
"ticket_id": "Hn3kQ8wLp5Ry",
"project_id": "Xk9mP2nR4vLw",
"user_id": "a3Bf9kLm2xQz"
}
}
Payloads contain IDs only. Fetch the full resource via the API if you need more detail.
Topics
| Topic | Data 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 |
Verifying signatures
expected = "sha256=" + hex(hmac_sha256(raw_body, webhook_secret))
valid = constant_time_compare(expected, request.headers["X-Klarhet-Signature"])
Use the raw request body for the HMAC input — don't re-serialize.
Retries
Deliveries time out after 10 seconds. On failure, retries follow this schedule:
| Attempt | Wait |
|---|---|
| 1 | 30 seconds |
| 2 | 2 minutes |
| 3 | 10 minutes |
| 4 | 1 hour |
| 5 | 6 hours |
After 5 failures the delivery is marked dead. Delivered records are cleaned up after 14 days.