Slack and Webhook Event Delivery
Bizby can send a team’s audit events outward as they happen, to a Slack channel or to an endpoint you control. This is the push half of the audit trail. The audit log and SIEM integration remains the pull half, and the two are meant to be used together.
Choosing between them
Section titled “Choosing between them”| Use the push delivery on this page | Use the SIEM feed | |
|---|---|---|
| Purpose | Notify a person, or trigger an automation | Retain and search the complete record |
| Shape | One request per event | Paged, cursor-based, backfillable |
| Guarantee | Best effort | Complete |
Delivery here is best effort, deliberately. It runs from a trigger on the audit record after that record is already durable, so a receiver being unavailable can never slow down or fail the action that produced the event. A failed delivery is logged and dropped rather than retried, because a retry loop against a down endpoint costs invocations and delivers nothing new.
That is why the SIEM feed exists and why it is the authoritative copy. If you need a guarantee that nothing was missed, reconcile against the feed; do not treat these notifications as a ledger.
Configuring a destination
Section titled “Configuring a destination”In Team settings → Event destinations. Only the team owner can see or change these: a Slack incoming-webhook URL is the authorisation to post into that channel, so it is treated as a credential and is not readable by other members.
Each destination takes:
- URL: must be
https. The payload names people and what they did, and is not sent in the clear. Redirects are not followed. An endpoint answering 3xx is reported as a failed delivery, because following one would let the destination move the payload to a URL your configuration never named, including anhttpone. Configure the final URL. - Send: all audit events, or security-relevant events only.
- Signing secret: webhook only, optional. See below.
Saving is also what the Send test event button acts on. The test is sent by Bizby’s servers using the stored settings, so it is disabled while you have unsaved changes: otherwise it would report on the previous URL and secret rather than the ones on screen.
Create an incoming webhook in your Slack workspace and paste the URL. Each event arrives as one line naming the action, the person and the thing, with the outcome and category beneath it.
Slack does not verify a signature on an incoming webhook, so the URL itself is the credential and no secret is sent. Treat the URL accordingly.
Webhook
Section titled “Webhook”Any https endpoint you control. Each event arrives as a POST with a JSON body.
What “security-relevant” means
Section titled “What “security-relevant” means”Narrowing a destination to security-relevant events sends only these actions:
| Kind | Actions |
|---|---|
| Reach changed | sharing.board_made_public, sharing.link_created, sharing.link_access_changed |
| Permission changed | access.role_granted, access.role_changed, access.role_revoked |
| Refused | access.denied, auth.failed |
| Identity and tenancy | config.sso_configured, config.retention_changed, governance.board_ownership_changed, governance.team_member_removed |
| Record copied | export.audit_exported, export.data_requested |
| Trail incomplete | governance.audit_gap_recorded |
Permission changes are listed in both directions. A revocation can itself be the thing you want to know about, and “who can reach this, and when did that change” is one question rather than two.
Guest access
Section titled “Guest access”Three further actions are security-relevant only when they concern a guest:
| Action | Sent when |
|---|---|
sharing.invite_sent | a guest is invited to a board |
sharing.invite_accepted | a guest accepts and gains access |
sharing.invite_revoked | a guest’s invitation or access is removed |
These same actions also fire for team invitations, which are not sent to a
security-scoped destination. Inviting a colleague into your tenant is routine
administration; letting somebody outside your directory onto a board is not, and
only the second is an access event worth an alert. The two are told apart by
metadata.scope, which is board for a guest invitation and team for a team
one. An event carrying no scope is treated as a team invitation.
This is an allow-list of named actions rather than whole categories. Categories grow, and a new action added to one should not silently start paging people. An alert channel that fires on routine activity becomes a channel nobody reads, which is worse than no alerting at all.
governance.audit_gap_recorded is on the list because it is the one event that
says the others may be incomplete.
The JSON payload
Section titled “The JSON payload”{ "type": "bizby.audit.event", "version": 1, "sentAt": "2026-10-02T15:04:05.000Z", "event": { "action": "access.role_revoked", "category": "access", "outcome": "success", "at": "2026-10-02T15:04:04.812Z", "teamId": "team_abc123", "securityRelevant": true, "actor": { "id": "uid_123", "type": "user" }, "target": { "type": "board", "id": "board_456", "name": "Service blueprint" }, "metadata": { "subjectId": "uid_789", "role": "editor" } }}version and the namespaced type are there because the receiver is code you
wrote and we cannot redeploy. securityRelevant is included so your receiver
does not have to carry a copy of the action list above.
metadata carries the detail that is specific to the action, and for several
events it is the substance rather than a footnote. An access event names the
BOARD as its target, because the board is the thing whose access changed, so the
affected person is metadata.subjectId. A role change carries from and to. A
guest invite carries scope, boardId and role. It is omitted when the event
has none, so you can test for its presence. Values are always scalars: strings,
numbers and booleans, never nested objects.
Identifying people
Section titled “Identifying people”People are identified by uid. actor.email and actor.displayName appear only
when they are already known at the time of the event, so treat both as optional
and do not key on them.
GET /v1/users/{uid} on the administration API resolves a uid
to an email and a name for members of your team, including members who have
been deactivated. It does not resolve a guest: the endpoint is scoped to your
team’s membership and returns 404 for any uid outside it, and a guest is by
definition not a member.
Where that bites, and what to do instead:
- Invite events (
sharing.invite_*) carry nosubjectId. The invited person istarget.name, which is their email address, andtarget.idis the invite id. On an acceptance the guest isactor.id. So the email is already in the payload and no lookup is needed. - Permission events (
access.role_granted,access.role_changed,access.role_revoked) identify the affected person only asmetadata.subjectId. When that uid belongs to a guest rather than a member, it will not resolve. Correlate it against theactor.idof the earliersharing.invite_acceptedfor the same board to recover the email.
Verifying a request
Section titled “Verifying a request”If a signing secret is set, each request carries:
| Header | Meaning |
|---|---|
X-Bizby-Timestamp | Milliseconds since the epoch, when the request was built |
X-Bizby-Signature | sha256= followed by a hex HMAC-SHA256 digest |
The digest is taken over the string {timestamp}.{body} using your secret, where
body is the exact bytes of the request. Verify against the raw body, not a
re-serialised object: any difference in key order would make a correct signature
fail.
const crypto = require('node:crypto')
function verify(rawBody, headers, secret) { const timestamp = headers['x-bizby-timestamp'] const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex') const given = headers['x-bizby-signature'] ?? '' // Constant-time, or the comparison itself leaks the signature a byte at a time. const a = Buffer.from(expected) const b = Buffer.from(given) if (a.length !== b.length) return false if (!crypto.timingSafeEqual(a, b)) return false // Reject anything older than five minutes, so a captured request cannot be // replayed indefinitely. return Math.abs(Date.now() - Number(timestamp)) < 5 * 60 * 1000}The timestamp is part of what is signed for exactly that reason. A receiver that does not check it is no worse off than one receiving an unsigned body, but it loses the replay protection.
Delivery behaviour
Section titled “Delivery behaviour”- One request per event per destination. Both destinations can be enabled at once and are independent.
- Requests time out after 5 seconds.
- Non-2xx responses and connection failures are logged and not retried.
- Configuration is read fresh for each event, so turning a destination off takes effect immediately rather than after a cache expires.
- Events with no team are not delivered, since there is no team configuration to resolve.
Automating on events
Section titled “Automating on events”The JSON destination is the general case: anything that accepts an HTTPS POST can act on it. Common uses are opening a ticket when a board is made public, recording ownership changes in a system of record, or forwarding refused sign-ins to an on-call channel separate from the SIEM.
If you need the complete record rather than a notification, use the SIEM feed.