Webhooks
Receive tickets and digests as signed HTTP requests, and verify them.
By the end of this page your own endpoint receives dlogify's tickets and digests, and checks that every request really comes from dlogify.
Create a webhook channel
In the dashboard, open your project, go to Channels and choose Add channel, then Webhook. Enter the URL of your endpoint. dlogify shows the signing secret once, right after the channel is created: store it with your other secrets.
The URL must:
- use
https, with no credentials (user:pass@) and no fragment (#…); - use port 443, or a port between 1024 and 65535;
- point to a public host name that resolves only to public addresses.
Otherwise the API refuses it with invalid-webhook-url. dlogify checks the address again before every delivery and does not follow redirects. Webhooks are part of the paid plans; on a plan without them, creating one returns channel-type-not-allowed.
Request
Every delivery is a POST with a JSON body and these headers:
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | Logify-Webhook/1 |
Logify-Event | ticket, digest or test |
Logify-Delivery-Id | dlv_… for tickets and digests, tst_… for tests. The same on every retry of a delivery: use it to ignore duplicates. |
Logify-Signature | t=<unix seconds>,v1=<signature>, see Verify the signature |
Ignore fields you do not know: new ones can be added at any time.
Ticket body
Sent at once for critical and high tickets. ticket is the same object as GET /v1/tickets/{ticket_id} returns, and ticket_url opens the ticket in the dashboard.
{
"type": "ticket",
"delivery_id": "dlv_01j…",
"organization_id": "org_01j…",
"project": { "id": "prj_01j…", "name": "checkout" },
"ticket": {
"id": "tkt_01j…",
"severity": "critical",
"title": "Payment provider timeout after 3 retries",
"summary": "…",
"facts": { "…": "…" },
"…": "…"
},
"ticket_url": "https://dlogify.com/dashboard/projects/prj_01j…/tickets/tkt_01j…"
}Digest body
Sent on the project's digest schedule (see Notification settings) when there is something to report. Each item of tickets is an object like those of GET /v1/projects/{project_id}/tickets, with a url to its page.
{
"type": "digest",
"delivery_id": "dlv_01j…",
"organization_id": "org_01j…",
"project": { "id": "prj_01j…", "name": "checkout" },
"digest": {
"id": "dig_01j…",
"period": { "start": "2026-10-03T06:00:00.000Z", "end": "2026-10-03T12:00:00.000Z", "timezone": "Europe/Madrid" },
"ticket_count": 73,
"tickets": [{ "id": "tkt_01j…", "severity": "medium", "title": "…", "url": "https://dlogify.com/…", "…": "…" }],
"info": {
"event_count": 120345,
"window_count": 24,
"busiest_window": { "start": "…", "end": "…", "total_count": 9000 },
"latest_summary": { "…": "…" },
"summary_ids": ["ism_01j…"],
"url": "https://dlogify.com/dashboard/projects/prj_01j…/summaries"
}
}
}ticket_countcounts the period's tickets still kept when the digest is sent;ticketsholds at most 100 of them.summary_idsholds at most 300 INFO summaries, newest first;latest_summaryis the newest one.infoisnullwhen the period has no INFO summaries.
Test body
Sent when you choose Send test on a channel, or call POST /v1/channels/{channel_id}/test. A channel can be tested once a minute (channel-test-rate-limited). Tests are not retried.
{
"type": "test",
"delivery_id": "tst_01j…",
"organization_id": "org_01j…",
"project": { "id": "prj_01j…", "name": "checkout" },
"channel": { "id": "chn_01j…", "name": "Ops webhook" }
}Verify the signature
Logify-Signature has the form t=<unix seconds>,v1=<signature>, where the signature is the hex-encoded HMAC-SHA256 of t + "." + body, keyed with the channel's secret. dlogify computes a fresh one for every attempt. To verify a request:
- Use the raw body exactly as received. Parsing the JSON and serializing it again changes the bytes, and the signature no longer matches.
- Compare signatures in constant time.
- Reject timestamps more than 5 minutes away from your clock, so a captured request cannot be replayed later.
import { createHmac, timingSafeEqual } from "node:crypto";
// Verify the Logify-Signature header of a webhook request.
// rawBody must be the exact bytes received, before any JSON parsing.
export function verifyLogifySignature(rawBody, header, secret, { toleranceSeconds = 300, now = Date.now() / 1000 } = {}) {
if (!header || !(typeof rawBody === "string" || Buffer.isBuffer(rawBody))) return false;
const parts = Object.fromEntries(header.split(",").map((part) => part.trim().split("=", 2)));
if (!/^[0-9]{1,12}$/.test(parts.t ?? "") || !/^[0-9a-f]{64}$/.test(parts.v1 ?? "")) return false;
if (Math.abs(now - Number(parts.t)) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret).update(`${parts.t}.`).update(rawBody).digest();
return timingSafeEqual(expected, Buffer.from(parts.v1, "hex"));
}With Express, read the body as raw bytes on the webhook route:
import express from "express";
import { verifyLogifySignature } from "./verify-webhook.mjs";
const app = express();
app.post("/hooks/dlogify", express.raw({ type: "application/json" }), (req, res) => {
if (!verifyLogifySignature(req.body, req.get("Logify-Signature"), process.env.DLOGIFY_WEBHOOK_SECRET)) {
return res.sendStatus(401);
}
res.sendStatus(204);
const event = JSON.parse(req.body.toString("utf8"));
// handle event.type: "ticket", "digest" or "test"
});import hashlib
import hmac
import re
import time
def verify_logify_signature(raw_body: bytes, header: str | None, secret: str, tolerance_seconds: int = 300, now: float | None = None) -> bool:
"""Verify the Logify-Signature header. raw_body must be the exact bytes received."""
if not header:
return False
parts = dict(part.strip().split("=", 1) for part in header.split(",") if "=" in part)
t, v1 = parts.get("t", ""), parts.get("v1", "")
if not re.fullmatch(r"[0-9]{1,12}", t) or not re.fullmatch(r"[0-9a-f]{64}", v1):
return False
current = time.time() if now is None else now
if abs(current - int(t)) > tolerance_seconds:
return False
expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)With Flask, request.get_data() returns the raw body:
import json
import os
from flask import Flask, request
from verify_webhook import verify_logify_signature
app = Flask(__name__)
@app.post("/hooks/dlogify")
def dlogify_webhook():
raw = request.get_data()
if not verify_logify_signature(raw, request.headers.get("Logify-Signature"), os.environ["DLOGIFY_WEBHOOK_SECRET"]):
return "", 401
event = json.loads(raw)
# handle event["type"]: "ticket", "digest" or "test"
return "", 204Respond
Answer with any 2xx status as soon as the signature checks out, and do the slow work afterwards. dlogify waits 5 seconds to connect and 10 seconds for the response; a slower answer counts as a failed attempt.
Retries
A delivery is attempted up to 8 times. After a failed attempt, the next one waits 1 minute, then 5 minutes, 15 minutes, 1 hour, 3 hours, 6 hours and 12 hours (with a little random jitter): about 22 hours in total.
| Your endpoint answers | dlogify |
|---|---|
2xx | Marks the delivery as delivered |
A timeout, network or TLS error, 408, 429 or 5xx | Retries later. On 429 and 503, a larger Retry-After (seconds or HTTP date) replaces the wait, up to 12 hours. |
3xx or any other 4xx | Gives up at once. Redirects are not followed. |
Deliveries are at least once: the same delivery can reach you twice, for example if your answer was lost. Use Logify-Delivery-Id to skip one you already handled.
Channel health
When three deliveries in a row fail for good, the channel is marked as failing. It keeps receiving deliveries, and the next delivered ticket, digest or test clears the mark. The dashboard shows the health of each channel, and email digests list the project's failing channels.