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).
zhk_<48 hex chars>
# example: zhk_a1b2c3d4e5f6...Sending the token
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.
Responses
[
{
"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{
"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
id,name, bot, androle.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.messageId, authorand a short excerpt. Without it a reply reads as a statement with no subject.attachments, and no text is invented to fill the gap.Feedback in one call
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.
Use the channel slug instead of its id and read a channel by name in a single call, with nothing to look up first:
curl https://zenhook.dev/api/channels/deploys/alerts \
-H "Authorization: Bearer zhk_..."Query parameters
nextCursor in the previous page. Omit for the first page.info, success, warning, error.Responses
{
"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.
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
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
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.