Read API

The webhook endpoint is public and write-only, it's how your apps push alerts in. To read alerts back out (build a CLI, sync to another tool, integrate with a dashboard), use the authenticated API with a Bearer token.

Authentication

All read endpoints require a Bearer token. Tokens are scoped to a single (user, workspace) pair, the user's channel access (including private channels they belong to) is inherited automatically.

Generate a token

From the dashboard: Settings → API Tokens → New Token. The full token is shown once at creation, copy it immediately. Zenhook stores only a SHA-256 hash, so a lost token can't be recovered (revoke and create a new one).

Token format
zhk_<48 hex chars>
# example: zhk_a1b2c3d4e5f6...

Sending the token

bash
curl https://zenhook.dev/api/channels \
  -H "Authorization: Bearer zhk_..."

List channels

Returns the channels the token's user can access (every public channel plus any private channel they're an explicit member of). Each channel includes an _count.alerts field containing the user's personal unread count.

GEThttps://zenhook.dev/api/channels

Responses

200 OK
json
[
  {
    "id": "clx_chan_1",
    "name": "production-errors",
    "slug": "production-errors",
    "icon": "🔴",
    "isPrivate": false,
    "webhookToken": "abc123...",
    "_count": { "alerts": 3 }
  }
]

Discord channels

A channel connected to Discord returns the same alert objects, plus three fields that let an agent reconstruct the conversation. This is the shape to read when you want an AI to gather feedback from a community.

New to this? Read the guide
json
{
  "id": "clx_alert_1",
  "message": "Le fait de pouvoir activer finement les notifications...",
  "createdAt": "2026-07-19T17:29:14.439Z",
  "author":  { "id": "1108...", "name": "Heri", "bot": false, "role": "member" },
  "replyTo": { "messageId": "1528...", "author": "Ayk", "excerpt": "..." },
  "attachments": [{ "url": "https://cdn.discordapp.com/...", "mime": "image/png" }]
}

Extra fields

authorobject
Who wrote the message: id,name, bot, androle.
author.rolestring
owner is whoever runs the Discord server, so their messages are the operator speaking, not feedback.bot is a bot or webhook.member is everyone else, and that is the real user feedback. Filter on it before counting or summarising anything.
replyToobject | null
Present when the message is a reply: the parent'smessageId, authorand a short excerpt. Without it a reply reads as a statement with no subject.
messagestring | null
The text as written, with Discord mentions resolved to names. Null when the message is only a file: the attachment is in attachments, and no text is invented to fill the gap.

Feedback in one call

bash
curl -s "https://zenhook.dev/api/channels/feedback/alerts?limit=50" \
  -H "Authorization: Bearer zhk_..." \
  | jq '[.alerts[] | select(.author.role == "member")]'

List alerts in a channel

Paginated, newest first. Each alert includes an isRead boolean computed for the calling user, marking an alert read affects only your own state.

GEThttps://zenhook.dev/api/channels/{id or slug}/alerts

Use the channel slug instead of its id and read a channel by name in a single call, with nothing to look up first:

bash
curl https://zenhook.dev/api/channels/deploys/alerts \
  -H "Authorization: Bearer zhk_..."

Query parameters

cursorstring
Alert ID returned as nextCursor in the previous page. Omit for the first page.
limitnumber
1-100. Defaults to 50.
levelenum
Filter by severity: info, success, warning, error.
afterstring
Alert ID. Returns alerts strictly newer, used for polling.

Responses

200 OK
json
{
  "alerts": [
    {
      "id": "clx_alert_1",
      "channelId": "clx_chan_1",
      "title": "Build Failed",
      "level": "ERROR",
      "emoji": "🔴",
      "message": "npm install timed out after 300s",
      "fields": [{ "label": "Branch", "value": "main" }],
      "linkUrl": "https://github.com/org/repo/actions/runs/123",
      "isRead": false,
      "createdAt": "2026-04-27T14:32:11.000Z"
    }
  ],
  "nextCursor": "clx_alert_42",
  "hasMore": true
}

Mark as read

Read state is per-user. Marking an alert read inserts a row scoped to the token's user, other workspace members' unread counts are unaffected.

PATCHhttps://zenhook.dev/api/alerts/{id}
bash
curl -X PATCH https://zenhook.dev/api/alerts/clx_alert_1 \
  -H "Authorization: Bearer zhk_..." \
  -H "Content-Type: application/json" \
  -d '{ "isRead": true }'

Mark every alert read

POSThttps://zenhook.dev/api/alerts/mark-all-read

Body is optional. Pass { "channelId": "..." } to scope to one channel; omit it to mark every accessible channel as read. Idempotent, re-running is a no-op.

Get a single alert

GEThttps://zenhook.dev/api/alerts/{id}

Returns the same alert shape as the list endpoint plus the channel's name / slug.

Errors

401 Unauthorized, missing or invalid Bearer token, or the token's user is no longer a workspace member.

404 Not Found, the resource doesn't exist or the token's user can't access it (e.g. private channel they're not on). The two cases are conflated on purpose so token holders can't enumerate private channels.

429 Too Many Requests, rate limit hit. Back off and retry.