Skip to content

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

EventTrigger
incident.createdNew incident from web, mobile, or email-in
incident.status_changedLifecycle transition: acknowledged, in progress, resolved, verified, or reopened
incident.sla_breachedThe sweep escalated an incident past its acknowledgement target
incident.mergedA duplicate was merged into a target incident
asset.status_changedAn asset’s live status changed

Each webhook subscribes to a subset; only subscribed events are delivered to it.

POST <your-url>
Content-Type: application/json
x-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" }
}
  • Timeout: 5 seconds. Respond 200 fast 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 against GET /api/incidents if you need guaranteed completeness.
  • Ordering is not guaranteed across events; use occurredAt and the entity’s id to sequence on your side.

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();
  1. Read the raw body bytes before any JSON parsing middleware touches them.
  2. Compute HMAC-SHA256(rawBody, secret), hex-encoded, prefixed with sha256=.
  3. Compare to the header with a timing-safe comparison.
  4. Reject on mismatch with 401 - and alert yourself; a stream of bad signatures means a misconfigured secret or someone probing your endpoint.

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 window
# terminal 1 - a dumb catcher that prints whatever arrives
npx -y http-echo-server 5678
# terminal 2 - cause an event
curl -H "Authorization: Bearer dev-mobile-key" -H "Content-Type: application/json" \
-d '{"assetId":2,"title":"Webhook test","severity":"low"}' \
http://localhost:3000/api/incidents

Check the hook’s lastStatus afterwards - "200" means the round trip worked.