Webhooks
CondoPulse pushes events to your endpoints as signed JSON POSTs. This page is
the receiver’s contract: what arrives, when, and how to prove it’s genuine.
(Creating and managing hooks: /api/admin/webhooks
or the /admin → Webhooks tab; workflow recipes:
Webhooks & n8n.)
Events
Section titled “Events”| Event | Trigger |
|---|---|
incident.created | New incident from web, mobile, or email-in |
incident.status_changed | Lifecycle transition: acknowledged, in progress, resolved, verified, or reopened |
incident.sla_breached | The sweep escalated an incident past its acknowledgement target |
incident.merged | A duplicate was merged into a target incident |
asset.status_changed | An asset’s live status changed |
Each webhook subscribes to a subset; only subscribed events are delivered to it.
Delivery format
Section titled “Delivery format”POST <your-url>Content-Type: application/jsonx-condopulse-signature: sha256=<hex HMAC-SHA256 of the raw body>Body shape - event, occurredAt, and property always; incident or
asset depending on the event:
{ "event": "incident.status_changed", "occurredAt": "2026-06-09T16:40:00.000Z", "incident": { "id": 17, "title": "Elevator A2 stuck on floor 7", "severity": "critical", "status": "resolved", "slaBreached": true, "source": "web", "assetId": 2 }, "property": { "id": 1, "name": "Harborview Towers" }}{ "event": "asset.status_changed", "occurredAt": "2026-06-09T08:00:00.000Z", "asset": { "id": 2, "name": "Elevator A2", "category": "elevator", "status": "down" }, "property": { "id": 1, "name": "Harborview Towers" }}Delivery semantics
Section titled “Delivery semantics”- Timeout: 5 seconds. Respond
200fast and process asynchronously. - No retries - delivery is fire-and-forget. The outcome is recorded on
the hook (
lastStatus:"200","failed: timeout", …) and visible in/admin→ Webhooks and the CRUD API. Treat webhooks as a low-latency signal and reconcile againstGET /api/incidentsif you need guaranteed completeness. - Ordering is not guaranteed across events; use
occurredAtand the entity’sidto sequence on your side.
Verifying the signature
Section titled “Verifying the signature”Every delivery is signed: the x-condopulse-signature header carries
sha256= followed by the hex HMAC-SHA256 of the raw request body, keyed
with the webhook’s secret (set when you created the hook). Verify before
processing - and compare with a timing-safe function, not ===.
Node (no dependencies):
import crypto from 'node:crypto';
/** * @param {Buffer|string} rawBody the exact bytes received - not re-serialized JSON * @param {string} signatureHeader the x-condopulse-signature header value * @param {string} secret the webhook's secret */function verifyCondoPulseSignature(rawBody, signatureHeader, secret) { const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex'); const a = Buffer.from(signatureHeader ?? ''); const b = Buffer.from(expected); return a.length === b.length && crypto.timingSafeEqual(a, b);}Express receiver - note express.raw(): you must HMAC the exact bytes
on the wire. Parsing to an object and re-stringifying can reorder or
re-escape characters and break verification:
import express from 'express';
const app = express();
app.post('/hooks/condopulse', express.raw({ type: 'application/json' }), (req, res) => { if (!verifyCondoPulseSignature(req.body, req.get('x-condopulse-signature'), process.env.CONDOPULSE_WEBHOOK_SECRET)) { return res.status(401).end(); } res.status(200).end(); // ack immediately
const { event, incident, asset, property } = JSON.parse(req.body); // … handle async: queue, log, notify …});
app.listen(8080);n8n - in the Webhook trigger node enable Raw Body, then verify in a Code node:
const crypto = require('crypto');
const raw = $input.first().binary?.data ? Buffer.from($input.first().binary.data.data, 'base64') : Buffer.from(JSON.stringify($input.first().json.body));const header = $input.first().json.headers['x-condopulse-signature'];const expected = 'sha256=' + crypto.createHmac('sha256', $env.CONDOPULSE_WEBHOOK_SECRET).update(raw).digest('hex');
if (header !== expected) { throw new Error('Bad CondoPulse signature');}return $input.all();Verification checklist
Section titled “Verification checklist”- Read the raw body bytes before any JSON parsing middleware touches them.
- Compute
HMAC-SHA256(rawBody, secret), hex-encoded, prefixed withsha256=. - Compare to the header with a timing-safe comparison.
- Reject on mismatch with
401- and alert yourself; a stream of bad signatures means a misconfigured secret or someone probing your endpoint.
Testing locally
Section titled “Testing locally”Create a hook pointed at a local catcher and trigger an event (report an
incident, or run the sweep). The development seed includes an inactive hook
aimed at http://localhost:5678/webhook/condopulse - n8n’s default local
webhook address - which you can activate from /admin → Webhooks. To
inspect raw deliveries without n8n:
# terminal 1 - a dumb catcher that prints whatever arrivesnpx -y http-echo-server 5678
# terminal 2 - cause an eventcurl -H "Authorization: Bearer dev-mobile-key" -H "Content-Type: application/json" \ -d '{"assetId":2,"title":"Webhook test","severity":"low"}' \ http://localhost:3000/api/incidentsCheck the hook’s lastStatus afterwards - "200" means the round trip
worked.