Webhook Implementation Guide
Webhooks deliver real-time events from MaintenanceOS to your systems. This guide covers building a robust consumer.
Event Delivery Guarantees
- • At-least-once delivery — Events may be duplicated; handle idempotently
- • Ordering — Events per resource delivered in order; cross-resource ordering not guaranteed
- • Retries — 3 attempts with exponential backoff (1m, 5m, 15m)
- • Timeout — 10 seconds per attempt
- • Dead letter — After 3 failures, event logged in MaintenanceOS (Settings → Webhooks → Failed Deliveries)
Signature Verification
Always verify signatures to prevent spoofing.
Header: `X-MaintenanceOS-Signature: sha256=
Algorithm: HMAC-SHA256(payload, webhook_secret)
Example (Node.js):
```javascript
const crypto = require('crypto');
function verifySignature(payload, signatureHeader, secret) {
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(payload, 'utf8')
.digest('hex');
// Timing-safe comparison
return crypto.timingSafeEqual(
Buffer.from(signatureHeader),
Buffer.from(expected)
);
}
app.post('/webhooks/maintenanceos', express.raw({type: 'application/json'}), (req, res) => {
const signature = req.headers['x-maintenanceos-signature'];
if (!verifySignature(req.body, signature, process.env.MOS_WEBHOOK_SECRET)) {
return res.status(401).send('Invalid signature');
}
// Process event...
res.status(200).send('OK');
});
```
Example (Python):
```python
import hmac
import hashlib
from fastapi import Request, HTTPException
async def verify_webhook(request: Request, secret: str):
body = await request.body()
signature = request.headers.get('X-MaintenanceOS-Signature')
expected = 'sha256=' + hmac.new(
secret.encode(), body, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(signature, expected):
raise HTTPException(status_code=401, detail='Invalid signature')
```
Idempotent Event Processing
Events may be delivered multiple times. Design your consumer to be idempotent.
Strategy 1: Event ID deduplication
```javascript
const processedEvents = new Set(); // Use Redis in production
app.post('/webhooks/maintenanceos', async (req, res) => {
const event = req.body;
if (processedEvents.has(event.id)) {
return res.status(200).send('Already processed');
}
await processEvent(event);
processedEvents.add(event.id);
// TTL cleanup for memory
setTimeout(() => processedEvents.delete(event.id), 24 60 60 * 1000);
res.status(200).send('OK');
});
```
Strategy 2: Database unique constraint
```sql
CREATE TABLE webhook_events (
event_id VARCHAR(64) PRIMARY KEY,
event_type VARCHAR(64),
payload JSONB,
processed_at TIMESTAMPTZ DEFAULT NOW()
);
-- In consumer:
INSERT INTO webhook_events (event_id, event_type, payload)
VALUES ($1, $2, $3)
ON CONFLICT (event_id) DO NOTHING;
-- Only process if row inserted
```
Event Structure
All events follow this envelope:
```json
{
"id": "evt_abc123def456",
"event": "repair.completed",
"timestamp": "2025-01-15T14:32:00Z",
"workspace_id": "wks_xyz789",
"data": { ... },
"previous_data": { ... } // For .updated events
}
```
Key Event Types
Repair Events
| Event | When | Data Includes |
| `repair.created` | New repair submitted | Full repair object |
| `repair.updated` | Any field changed | Full repair + `previous_data` |
| `repair.assigned` | Contractor assigned | Repair + contractor_id |
| `repair.completed` | Contractor marks complete | Repair + completion photos |
| `repair.verified` | Manager verifies | Repair + verifier_id |
| `repair.closed` | Auto-closed after 7 days | Repair |
| Event | When | Data Includes |
| `safety_latch.triggered` | Safety rule matched | Repair + rule_id + matched_keywords |
| `safety_latch.cleared` | Safety Officer cleared | Repair + officer_id + clearance_note |
| `safety_latch.escalated` | No response in time | Repair + escalation_level |
| Event | When | Data Includes |
| `compliance.expiring` | 30/7/1 days before expiry | Property + compliance_type + expiry_date |
| `compliance.expired` | On expiry date | Property + compliance_type |
| `contractor.compliance_changed` | Cert added/removed/expired | Contractor + cert_type + status |
| Pitfall | Solution |
| Not verifying signatures | Always verify — reject invalid |
| Processing synchronously | Queue immediately, respond 200 |
| No idempotency | Use event_id deduplication |
| Ignoring `previous_data` | Use for audit / change detection |
| Hardcoding event types | Handle unknown types gracefully |
| No dead letter handling | Monitor MaintenanceOS failed deliveries |