Skip to main content

Webhook Delivery

When a rule transitions ARMED → FIRED, dakkio delivers a signed HTTP POST to your application.

Webhooks are organization-scoped

One endpoint receives alerts from every bucket in the organization. The payload carries bucketId and dataSourceId so your application routes them itself.

Organization "Acme Monitoring" ONE webhook, ONE secret
├── Bucket → customer 1 ─┐
├── Bucket → customer 2 ─┼──▶ https://your-app.com/webhooks/dakkio
└── … one per customer ─┘ payload carries bucketId + dataSourceId
Why not per bucket

An application-shaped organization has one bucket per end-customer. Bucket-scoped webhooks would mean one record per customer — all the same URL, all needing re-registration when the endpoint moves or the secret rotates, and any missed registration silently losing that customer's alerts.

bucketId remains available as an optional field if you genuinely want to route one bucket to its own endpoint. Delivery resolves as: bucketId matches, or bucketId is absent.

Configuring in the dashboard

Webhooks are set up by a person, once. Applications do not register them programmatically, and creating a bucket does not touch them — an endpoint and a signing secret are deployment configuration.

  1. Open Notifications in the dashboard
  2. Add Webhook
  3. Enter your endpoint URL
  4. Copy the signing secret — shown exactly once
Save the secret immediately

It is write-only. Every read afterwards reports only whether one is set. Lose it and you must rotate.

Store it as DAKKIO_WEBHOOK_SECRET in your application.

The payload

{
"type": "alert.triggered",
"alertId": "6a6c835f1ada3a6c5f032599",
"alertRuleId": "6a6c835f1ada3a6c5f032520",
"alertName": "Weight below minimum - Scale 3",
"kind": "threshold",
"bucketId": "69e75875049cfa22d51464ae",
"dataSourceId": "6a04b41c049cfa22d51464d1",
"triggeredAt": "2026-07-31T11:45:26.890Z",
"from": "ARMED",
"to": "FIRED",
"window": { "start": "…", "end": "…" },
"evidence": { "latest": 18.6, "threshold": 20, "operator": "<", "samples": 12 }
}
There is no condition string and no single value

dakkio sends measurements, never conclusions.

A thermal-band rule compares two series across 48 hours — it has no single value. Inventing one would be a fabrication your interface then displays as fact. Instead kind names the computation and evidence carries the numbers it produced.

Composing the sentence is your application's job, because it is the only side that knows what the numbers mean to its users.

Building a message from evidence

function describeAlert(payload, deviceName) {
const e = payload.evidence ?? {};

switch (payload.kind) {
case 'threshold':
return `${deviceName}: measured ${e.latest} (${e.operator} ${e.threshold}).`;

case 'thermal-band':
return `${deviceName}: internal temperature stayed between ` +
`${e.intMin} and ${e.intMax} °C, while external varied by ${e.extSpread} °C.`;

default:
// Never throw on an unknown kind - adding a rule type in dakkio
// must not break a deployed receiver.
return `${deviceName}: ${payload.alertName}`;
}
}

Verifying the signature

Every request carries X-Dakkio-Signature: the HMAC-SHA256 of the raw request body, hex-encoded, using your webhook's secret.

import crypto from 'crypto';

function verify(rawBody, signature, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody, 'utf8')
.digest('hex');

const a = Buffer.from(signature ?? '', 'utf8');
const b = Buffer.from(expected, 'utf8');

// timingSafeEqual throws on a length mismatch, which an absent or malformed
// header produces - check first rather than relying on the throw.
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
Verify against the raw bytes

Re-serialising the parsed body — JSON.stringify(request.body) — happens to work while the round-trip is byte-identical, but it is fragile. A float that reformats, a non-ASCII character or a numeric-looking key will break every signature at once.

Capture the raw body. In Fastify, use a content-type parser that retains it; in Express, express.raw() on that route.

Other headers sent:

HeaderPurpose
X-Dakkio-EventEvent type
X-Dakkio-DeliveryUnique delivery id — use it to deduplicate

Responding

Return 2xx to acknowledge. Anything else is treated as a failure and retried.

Respond quickly and do the work asynchronously — the request times out after 10 seconds. Redirects are not followed.

Delivery, retry and the outbox

Delivery is not fire-and-forget. A fired alert writes an outbox record, and a worker drains it.

StatusMeaning
pendingOwed, due at nextAttemptAt
sentDelivered — terminal
failedRetries exhausted — terminal, needs attention
skippedNo active webhook configured — terminal

skipped is deliberately distinct from failed, so an organization with no webhook does not register as an outage.

Backoff grows from the webhook's backoffMultiplier — 30s, 60s, 120s, 240s at the default of 2 — clamped at six hours so an aggressive multiplier cannot schedule a retry days away and thereby never deliver and never visibly fail.

Endpoints fail independently

The queue is per (alert × webhook). If you have two endpoints and one fails, only the failing one is retried — the endpoint that already succeeded is never sent the same alert twice.

Because state lives in the database, a webhook survives the API restarting mid-send, your endpoint being down for an hour, or a deploy landing between the rule firing and the request going out. Alerts that fired while your receiver was unavailable are still queued when it returns.

Monitoring

The dashboard shows delivery counts and success rate per webhook. A webhook that has never fired reads "No deliveries yet" rather than 0% — a webhook with no traffic is not a failing webhook.

In the logs, each attempt is recorded with a deliveryId, alertEventId, webhookId and a redacted endpoint. URLs are redacted deliberately: for Slack, Teams and Discord the URL is the credential, and logging it in full would scatter that token across log aggregation.

Watch for Alert fired but no active webhook is configured — that means an alert fired and nobody was told.