Register a URL with one POST. We push a signed JSON envelope to it the moment an event fires anywhere in your org — typically under 500 ms from API action to your receiver. Retries are automatic, signing is HMAC-SHA256, broken receivers auto-disable.
Distinct from the per-monitor alert webhook (one URL on a monitor row, fires when that monitor goes down). Subscriptions are org-scoped, fire on event types, and carry a structured envelope. Same signing scheme — the same receiver code verifies both.
incident.opened — monitor crossed its alertThreshold and an incident was auto-created (or a log-alert rule fired).incident.acknowledged — a team member acked an incident without resolving.incident.resolved — incident closed (manually OR auto on monitor recovery). Payload's auto_resolved flag tells you which.monitor.status_changed — a monitor's lastStatus transitioned (up → down → degraded → up). Fires on every real transition, not just incident-opening ones.log_alert.fired — a log-alert rule matched its threshold and opened an incident.
An empty eventTypes: [] on the subscription means "send me
every event type the org produces." Useful for the first-day
configuration; agents tend to narrow it later.
curl -X POST https://api.24observe.com/api/v1/webhook-subscriptions \
-H 'Authorization: Bearer obs_<pat-with-webhooks:write>' \
-H 'Content-Type: application/json' \
-d '{
"url": "https://agent.example.com/24observe-events",
"eventTypes": ["incident.opened", "incident.resolved"],
"description": "incident-bot in prod"
}'
# Response (201)
{
"id": 42,
"url": "https://agent.example.com/24observe-events",
"eventTypes": ["incident.opened", "incident.resolved"],
"description": "incident-bot in prod",
"enabled": true,
"disabledAt": null,
"consecutiveFailures": 0,
"lastDeliveryAt": null,
"lastDeliveryStatus": null,
"lastDeliveryStatusCode": null,
"createdAt": "2026-05-23T18:00:00.000Z"
}
Required scope: webhooks:write. Required role:
owner or admin. SSRF preflight runs at write
time — URLs resolving to private/loopback/link-local addresses are
rejected with 400 WEBHOOK_URL_UNSAFE; DNS failures get
400 WEBHOOK_URL_UNRESOLVABLE.
POST https://agent.example.com/24observe-events
Content-Type: application/json
User-Agent: 24observe-webhooks/1.0
X-24Observe-Event: incident.opened
X-24Observe-Delivery-Id: sub-42-incident.157.opened.1
X-24Observe-Timestamp: 1716480000
X-24Observe-Signature: t=1716480000,v1=<64-char-hex>
{
"id": "incident.157.opened",
"type": "incident.opened",
"created": 1716480000,
"data": {
"incident_id": 157,
"monitor_id": 7,
"monitor_name": "checkout-api",
"monitor_url": "https://api.example.com/healthz",
"status": "investigating",
"error_message": "HTTP 503",
"opened_at": "2026-05-23T18:00:00.000Z"
}
} Verify the signature exactly as for per-monitor alert webhooks — same HMAC-SHA256 scheme, same org-level secret. Working Node / Python / Go examples on /docs/webhooks/.
enabled to false and set
disabledReason so your dashboard shows why. A POST that
succeeds resets the consecutive counter to zero.
Re-enable via PATCH after fixing the receiver. The
consecutiveFailures counter is cleared and any new event
fan-out lands again.
# List all subscriptions for your org (bare array — no envelope)
GET /api/v1/webhook-subscriptions
# Disable / re-enable / change eventTypes / change URL
PATCH /api/v1/webhook-subscriptions/:id
{ "enabled": false }
{ "enabled": true } # also clears disabledAt + disabledReason + consecutiveFailures
{ "eventTypes": ["incident.opened"] }
{ "url": "https://new-receiver.example.com/x" }
# Delete (cascades the delivery rows)
DELETE /api/v1/webhook-subscriptions/:id
# Inspect the last 50 delivery attempts for one subscription
GET /api/v1/webhook-subscriptions/:id/deliveries
# Returns: id, eventType, eventId, attempt, status, statusCode, errorMessage,
# payloadJson (exact bytes we sent), responseBodyExcerpt
# (first 1 KB of receiver's response), deliveredAt, createdAt
The payloadJson field on each delivery row is the exact
bytes we POSTed to your receiver — including the timestamp the signature
was computed against. If your receiver's signature check is failing, this
is the byte sequence to verify against.
webhooks:read — list subscriptions, read delivery history.webhooks:write — create / update / delete subscriptions.
Plus role: owner or admin on writes. Members and
viewers can list but not modify.
Minimal Express receiver that verifies signatures and logs deliveries.
Drop in, set OBSERVE24_WEBHOOK_SECRET from GET /api/v1/me/webhook-secret,
and you're done.
import express from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';
const SECRET = process.env.OBSERVE24_WEBHOOK_SECRET;
const app = express();
app.post('/24observe-events', express.raw({ type: 'application/json' }), (req, res) => {
const sig = req.header('x-24observe-signature') || '';
const parts = Object.fromEntries(sig.split(',').map(p => p.split('=')));
const t = Number(parts.t);
if (Math.abs(Date.now() / 1000 - t) > 300) return res.sendStatus(400); // replay defense
const expected = createHmac('sha256', SECRET).update(`${t}.${req.body.toString('utf8')}`).digest();
const actual = Buffer.from(parts.v1, 'hex');
if (actual.length !== expected.length || !timingSafeEqual(expected, actual)) {
return res.sendStatus(401);
}
const envelope = JSON.parse(req.body.toString('utf8'));
const eventType = req.header('x-24observe-event');
console.log(`[${eventType}] ${envelope.id}`, envelope.data);
// Return 200 within 10s. Heavy work belongs in a job queue.
res.sendStatus(204);
});
app.listen(3000); JSON.parse may reorder keys; the HMAC was computed against the raw bytes we sent. Use the raw body for the verification step.X-24Observe-Event. The event type is in the header AND the envelope's type field. If you only branch on the JSON body, missing or malformed bodies look like an unknown event type.alertWebhookUrl field instead — different shape, same signing.envelope.id in your receiver — every event has a stable id, retries land with the same id.