Summary
An outbound event webhook pushes KC Safety events to a URL you control, within seconds and with no API quota consumed. This article is the reference: the event catalog, the delivery payload, signature verification, retries and auto-disable, the management endpoints, and the destination URL rules. For a step-by-step setup walkthrough, see How to Configure Outbound Event Webhooks in KC Safety.
About
An outbound event webhook is a URL you register in KC Safety. When a supported event occurs — an incident is created, a status changes — KC Safety sends an HTTP POST to that URL with a JSON body describing the event. Your receiver reacts in real time.
Where the Public REST API lets another system pull data on its own schedule, webhooks push events to you within seconds and consume no API quota. This is the recommended pattern for driving another product from KC Safety activity.
Delivery is at-least-once, with retries. Webhooks are registered and managed under Admin Panel → Settings → Integrations → Webhooks, which requires the Super Admin role.
Event catalog
Subscribe to specific events, or to * to receive all of them.
| Event | Fires when | data payload |
|---|---|---|
incident.created | A new incident is created, through any channel. | { "incident": { … } } |
incident.status_changed | An incident's status changes. | { "incident": { … }, "oldStatus": "…", "newStatus": "…" } |
investigation.created | An investigation is opened. | { "investigation": { … } } |
action_item.created | A corrective or action item is created. | { "action": { … } } |
test | An administrator sends a test delivery. | { "message": "Test delivery" } |
These five are the complete set of events delivered today.
Additional events in the selector are not yet delivered. The Webhooks screen lets you select some events beyond the five above — includingincident.updated,investigation.updated,action_item.updated,action_item.completed,compliance.decision_made, andtraining.assigned. These are not yet dispatched, so a subscription to them will never deliver. Do not build against them. To detect a status change — including closure — subscribe toincident.status_changedand read the new status from the payload.
Delivery payload
Every delivery is a POST carrying this envelope:
{
"event": "incident.created",
"timestamp": "2026-08-11T10:30:00.000Z",
"account_id": "<your account id>",
"data": {
"incident": {
"id": "…",
"incident_number": "INC-2026-0001",
"status": "open"
}
}
}- Top-level keys are
event,timestamp(ISO-8601, UTC),account_id, anddata. datais nested by entity — the object sits underdata.incident,data.investigation, ordata.action. It is not flattened ontodata.- The entity object is the full record, using
snake_casefield names. It is not a curated subset, and the set of fields may grow over time. Read the fields you need by name, and do not assume a fixed schema. incident.status_changedcarriesoldStatusandnewStatus(in camelCase) alongside the fullincidentobject.
HTTP request details
| Property | Value |
|---|---|
| Method | POST |
| Body | The JSON envelope above |
| Headers sent | Content-Type: application/json, User-Agent: KC-Safety-Webhooks/1.0, and X-KC-Safety-Signature when the webhook has a secret |
| Timeout | 10 seconds |
| Redirects | Not followed — a 3xx counts as a failed delivery |
| Success | Any HTTP 2xx |
No event-type header and no per-delivery ID header are sent — the event name and timestamp are in the body.
Anything other than a 2xx — including a 3xx, a timeout, or a connection error — is a failure and will be retried. Your response body is never stored by KC Safety; only its size is recorded. Return a small 2xx quickly, and do your processing asynchronously.
Verifying the signature
When a webhook has a secret, KC Safety signs the exact raw request body with HMAC-SHA-256 and sends the lowercase hex digest in the X-KC-Safety-Signature header. Recompute it over the raw bytes you received — before any JSON parsing — and compare using a timing-safe check. Reject anything that does not match.
In Node.js, that check looks like this:
const crypto = require('crypto');
function isValid(rawBody, signatureHeader, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody, 'utf8') // the exact bytes received, before JSON.parse
.digest('hex');
return !!signatureHeader && crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signatureHeader)
);
}Only the raw JSON body is signed — there is no timestamp prefix or separately constructed signing string. The timestamp is covered because it is inside the body.
If a webhook has no secret, no signature header is sent and deliveries arrive unsigned. Always set a secret, and reject unsigned or mismatched deliveries.
The signing secret
- The secret is set by the administrator when creating or editing the webhook. It is an optional field, and KC Safety does not generate one for you.
- It is retained and remains viewable to Super Admins in the webhook settings — it is not a one-time reveal.
Treat it as a shared secret. If it may have been exposed, rotate it by editing the webhook and updating your receiver at the same time.
Retries and auto-disable
- A failed delivery is retried up to 5 attempts, with exponential backoff: 30s → 2m → 8m → 32m → 2h.
- The retry worker runs approximately every 60 seconds.
- A successful delivery resets the endpoint's failure counter to zero.
- After 10 consecutive failures, the webhook is automatically disabled to protect your account. Re-enable it in the Webhooks screen once your receiver is fixed; re-enabling resets the failure counter.
Because delivery is at-least-once and retried, your receiver should be idempotent — the same event may arrive more than once.
Delivery logs
Every attempt is recorded and visible in the Webhooks screen, and through the logs endpoint below. Each log entry exposes the event, the payload that was sent, the response_status (the HTTP code, or empty on a timeout or network error), a success flag, and an error_message when the attempt failed. Your response body is not captured.
Managing webhooks (API)
Registered webhooks can be managed in the app, or through these endpoints. All are on the API host, authenticated with an administrator session, and restricted to Super Admin.
| Method and path | Purpose |
|---|---|
POST /api/v1/admin/webhooks | Create — body { name, url, events[], secret? }. The URL is validated (see Destination URL rules) and the account's active-webhook quota applies. |
GET /api/v1/admin/webhooks | List all webhooks for the account. |
GET /api/v1/admin/webhooks/:id | Fetch one webhook. |
PUT /api/v1/admin/webhooks/:id | Update { name?, url?, events?, secret?, is_active? }. |
DELETE /api/v1/admin/webhooks/:id | Delete the webhook, along with its logs and any queued retries. |
POST /api/v1/admin/webhooks/:id/test | Send a test event — see the note below. |
GET /api/v1/admin/webhooks/:id/logs | Paginated delivery logs (page, perPage, filterable by event and success). |
name is required and is the identifying label. events must be a non-empty array. The number of active webhooks may be capped by your plan — check with your account team.
How the test action behaves. Sending a test does not target only the webhook you triggered it from. It fires atestevent that fans out to every active webhook in the account subscribed totestor*. A webhook not subscribed totestor*receives nothing, even when you test it directly.
Subscription matching
When an event fires, KC Safety delivers it to every active webhook in the account whose events array contains either the exact event string or the wildcard *.
Matching is exact, element by element. There is no prefix or glob matching beyond * — for example, incident.* does not match anything.
Destination URL rules
Destination URLs are validated when you save them, and re-validated with a fresh DNS resolution before every delivery.
- Production requires
https://. Plainhttp://is accepted only in non-production environments. - Internal and non-public destinations are rejected:
localhost, loopback addresses, private ranges (RFC 1918), link-local addresses including the cloud metadata address169.254.169.254, unique-local addresses, carrier-grade NAT ranges, and multicast, broadcast, and reserved ranges. - The hostnames and suffixes
metadata,*.local,*.internal,*.localhost, and*.cluster.localare rejected. - A host that cannot be resolved, or that resolves to any non-public address, is rejected.
Point webhooks only at public, internet-reachable HTTPS endpoints. If your receiver runs inside a private network, put a public endpoint or gateway in front of it.
Workflow webhook action
Separately from registered webhooks, a custom workflow status transition can fire a one-off webhook. Configure it at Settings → Workflows by adding a Trigger webhook action to a transition.
Use it when you want a webhook tied to one specific workflow step rather than a global event subscription. It behaves differently from a registered webhook:
| Registered webhook | Workflow action | |
|---|---|---|
| URL | Registered endpoint, listed in Webhooks | Stored on the workflow rule; not listed |
| Payload | data envelope, nested by entity | Flat, no data envelope |
| Signature | Signed when a secret is set | Always unsigned |
| Retries | Up to 5, with backoff | None |
| Delivery logs | Yes | No |
The payload is flat:
{
"event": "workflow.transition",
"timestamp": "…",
"account_id": "…",
"entity_type": "…",
"entity_id": "…",
"from_status": "…",
"to_status": "…"
}It is fire-and-forget: if the delivery fails, the status transition still proceeds. The same destination URL rules, 10-second timeout, and POST/JSON mechanics apply.