Skip to content
Go to App

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.

Use the push delivery on this pageUse the SIEM feed
PurposeNotify a person, or trigger an automationRetain and search the complete record
ShapeOne request per eventPaged, cursor-based, backfillable
GuaranteeBest effortComplete

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.

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 an http one. 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.

Any https endpoint you control. Each event arrives as a POST with a JSON body.

Narrowing a destination to security-relevant events sends only these actions:

KindActions
Reach changedsharing.board_made_public, sharing.link_created, sharing.link_access_changed
Permission changedaccess.role_granted, access.role_changed, access.role_revoked
Refusedaccess.denied, auth.failed
Identity and tenancyconfig.sso_configured, config.retention_changed, governance.board_ownership_changed, governance.team_member_removed
Record copiedexport.audit_exported, export.data_requested
Trail incompletegovernance.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.

Three further actions are security-relevant only when they concern a guest:

ActionSent when
sharing.invite_senta guest is invited to a board
sharing.invite_accepteda guest accepts and gains access
sharing.invite_revokeda 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.

{
"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.

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 no subjectId. The invited person is target.name, which is their email address, and target.id is the invite id. On an acceptance the guest is actor.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 as metadata.subjectId. When that uid belongs to a guest rather than a member, it will not resolve. Correlate it against the actor.id of the earlier sharing.invite_accepted for the same board to recover the email.

If a signing secret is set, each request carries:

HeaderMeaning
X-Bizby-TimestampMilliseconds since the epoch, when the request was built
X-Bizby-Signaturesha256= 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.

  • 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.

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.