Docs/Guides
Events and webhooks
Things that happen in a project (a customer starts a conversation, a changelog entry goes public, a pull request merges) are published as domain events on one bus. You can
- receive them on your own server as signed webhooks, each endpoint choosing the events it wants,
- start a sequence from one (see automations), and
- in the code, listen to them in-process with
onDomainEvent(server/utils/domainEvents.ts).
Webhook endpoints are part of the Event webhooks plan feature on the hosted edition; self-hosted has no limits.
The event#
Every delivery is a POST with a JSON body:
{ "id": "evt_5f0c…", "type": "changelog.entry.published", "createdAt": "2026-10-21T09:30:00.000Z", "projectId": "AbC123", "data": { "entryId": "…" } }data carries ids and a few small fields, never whole rows: fetch the rest with the API. Dedupe on id (a retry after a
timeout can arrive twice) and ignore old createdAts.
type |
data |
|---|---|
contact.created |
{ contact: { id, externalId, email } } |
contact.subscription_changed |
{ contact: { id, externalId, email }, from, to, unsubscribedTopics, topics?: { added, removed } } |
conversation.created |
{ conversationId, contact: { id, externalId, email }, type } |
conversation.status_changed |
{ conversationId, from, to, statusCategory, contact: { id } } |
conversation.feedback_linked |
{ conversationId, postId, linked, created } (linked is false when an operator unlinks it; created when the post was filed from the conversation) |
feedback.post.created |
{ postId, number, contact?: { id, externalId } } (the author, when a person wrote it) |
feedback.post.status_changed |
{ postId, number, from, to } |
feedback.post.voted |
{ postId, number, voterExternalId } (only when a vote is added) |
board.item.created |
{ itemId, number } |
board.item.status_changed |
{ itemId, number, from, to } |
changelog.entry.published |
{ entryId, number, releaseId, feedbackPostIds, boardItemIds, conversationIds } |
errors.filed_on_board |
{ itemId, provider, issueId } |
github.commit.referenced |
{ sha, repo, targetKind, targetId } |
github.pull_request.merged |
{ number, repo, title, url, targets: [{ kind, id, closes }] } |
email.suppressed |
{ email, reason: 'bounce' | 'complaint', detail } |
cms.entry.published |
{ entry: { id, slug, type, version, publishedAt } } |
cms.entry.unpublished |
{ entry: { id, slug, type } } |
blog.post.published |
{ post: { id, slug, title, visibility, publishedAt, url } } (BLOG.md) |
blog.post.updated / blog.post.unpublished / blog.post.deleted |
the same |
onboarding.completed |
{ flow, outcome: 'completed' | 'skipped', contact: { id, externalId, email } } |
webhook.test |
{ message }: only from "Send a test event" |
The machine-readable list is DOMAIN_EVENTS in shared/domainEvents.ts.
Endpoints#
Developer → Webhook endpoints in the panel, or the API (an operator session or an ak_ admin key):
# Add one. The signing secret comes back once, in this response.
curl -X POST "$HELPBITES/v1/admin/projects/$PROJECT/webhooks" \
-H "Authorization: Bearer $ADMIN_KEY" -H 'content-type: application/json' \
-d '{ "url": "https://example.com/hooks/helpbites", "events": ["changelog.entry.published", "feedback.*"] }'
curl "$HELPBITES/v1/admin/projects/$PROJECT/webhooks" -H "Authorization: Bearer $ADMIN_KEY"
curl -X PATCH "$HELPBITES/v1/admin/projects/$PROJECT/webhooks/$ID" -H "Authorization: Bearer $ADMIN_KEY" -H 'content-type: application/json' -d '{ "enabled": false }'
curl -X PATCH "$HELPBITES/v1/admin/projects/$PROJECT/webhooks/$ID" -H "Authorization: Bearer $ADMIN_KEY" -H 'content-type: application/json' -d '{ "rotateSecret": true }'
curl -X POST "$HELPBITES/v1/admin/projects/$PROJECT/webhooks/$ID/test" -H "Authorization: Bearer $ADMIN_KEY"
curl "$HELPBITES/v1/admin/projects/$PROJECT/webhooks/deliveries?endpoint=$ID" -H "Authorization: Bearer $ADMIN_KEY"
curl -X DELETE "$HELPBITES/v1/admin/projects/$PROJECT/webhooks/$ID" -H "Authorization: Bearer $ADMIN_KEY"events is a list of filters: * (everything, the default), group.* (one group: contact, conversation, feedback,
board, changelog, errors, github, email, cms, blog, onboarding) or an exact type. Up to 10 endpoints per project.
The project's original single event webhook (Settings → Event webhook, MARKETING.md) keeps
working and keeps receiving the events it always did (contact.subscription_changed, email.suppressed,
cms.entry.published, cms.entry.unpublished and the four blog.post.*); add an endpoint for anything else.
Verifying and retrying#
Each request carries x-helpbites-signature: sha256=<hex HMAC-SHA256 of the raw body with the endpoint's secret> and
x-helpbites-event: <type>.
import crypto from 'node:crypto'
const expected = 'sha256=' + crypto.createHmac('sha256', process.env.HELPBITES_WEBHOOK_SECRET).update(rawBody).digest('hex')
const got = Buffer.from(req.headers['x-helpbites-signature'] || '')
if (got.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(expected), got)) return res.status(401).end()import CryptoKit
// Verify on your own server (Vapor, Hummingbird…): the same HMAC over the raw body.
func isValid(body: Data, header: String, secret: String) -> Bool {
let mac = HMAC<SHA256>.authenticationCode(for: body, using: SymmetricKey(data: Data(secret.utf8)))
let expected = "sha256=" + mac.map { String(format: "%02x", $0) }.joined()
return expected == header
}Anything but a 2xx is retried after 1, 5, 30, 120 and 720 minutes, then given up on. A suspended workspace's events wait.
The latest deliveries and their errors are in the panel and at /webhooks/deliveries.