Webhooks
LogTide delivers events - alerts, anomalies, errors, monitor status changes, security incidents, and notification-channel webhooks - to your endpoints through a centralized dispatcher with SSRF protection, retry with exponential backoff, a dead-letter queue, and delivery logging.
Overview
Every outbound delivery - whether it originates from an alert rule, an uptime monitor, an error group, a security incident, or a notification channel - flows through one well-tested dispatcher. That gives every webhook the same guarantees:
Signed payloads
Optional HMAC-SHA256 signature so you can verify each delivery came from LogTide untampered.
Automatic retries
Transient failures are retried with exponential backoff before being parked.
Dead-letter queue
Exhausted deliveries are kept and can be replayed from the dashboard.
SSRF protection
Targets are resolved and validated on every request and every redirect hop.
Delivery Semantics
- Retries - transient failures (network errors, timeouts, HTTP 5xx, 429) are retried with exponential backoff (
1s, 5s, 25s, 2m, 10m), up toWEBHOOK_MAX_ATTEMPTS(default 5). Other 4xx responses and SSRF-blocked targets are terminal and not retried. - Dead-letter queue - after the final failed attempt a delivery is marked
dead. Dead and failed deliveries can be replayed from Settings → Notifications → Delivery log. - Idempotency - each delivery carries a stable id, so a receiver can safely deduplicate on the envelope
id. - Deduplication is time-bounded - re-enqueueing the same event (same organization, event type and event id) while a delivery for it is still in flight reuses that delivery instead of creating a second one. The window is
WEBHOOK_DEDUP_WINDOW_MS(default 60s): anything older, or already delivered or dead-lettered, starts a new delivery. Sources that reuse an event id across occurrences - a monitor that goes down, recovers and goes down again, for example - therefore deliver every occurrence, while a genuine double-enqueue within the window still collapses into one. - Timeout - each attempt times out after
WEBHOOK_REQUEST_TIMEOUT_MS(default 10s).
Return 2xx quickly
Acknowledge the delivery with a 2xx as soon as you have persisted it, then do your work asynchronously. A slow handler that exceeds the request timeout is treated as a failed attempt and will be retried.
Event Envelope
Every delivery body is a JSON object with a top-level envelope. The data field
carries the event-specific payload.
{
"id": "evt_4b3e1a2c-8f7d-4e6b-9c0a-1d2e3f4a5b6c",
"type": "alert.triggered",
"version": 1,
"occurredAt": "2026-06-11T14:32:07.841Z",
"organizationId": "9f8e7d6c-5b4a-3c2d-1e0f-a1b2c3d4e5f6",
"projectId": "3a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d",
"data": {
"alert_name": "Error rate spike",
"log_count": 142,
"threshold": 50,
"time_window": 5,
"baseline_metadata": null,
"link": "https://app.logtide.dev/dashboard/incidents/7c1d2e3f-4a5b-6c7d-8e9f-0a1b2c3d4e5f"
}
} Envelope fields
| Field | Type | Description |
|---|---|---|
id | string | Stable delivery id, prefix evt_ followed by a UUID. Doubles as the dedup id when the producer does not supply one. |
type | string | Event type (see table below). |
version | number | Schema version. Currently always 1; the X-Logtide-Event-Version header carries the same value for routing without parsing the body. |
occurredAt | string | ISO 8601 timestamp of when the event occurred. |
organizationId | UUID | Organization that owns the event. |
projectId | UUID | null | Project scope. null for org-wide events (e.g. alert rules without a project filter). |
data | object | Event-specific payload. See per-type fields below. |
Validating with @logtide/shared
The envelope and per-type data schemas ship as Zod schemas, so a TypeScript receiver can parse and validate in one step:
import { webhookEnvelopeSchema, parseWebhookEvent } from '@logtide/shared';
// Parse the envelope only (data stays Record<string, unknown>)
const envelope = webhookEnvelopeSchema.parse(body);
// Parse the envelope AND validate the per-type data schema
const event = parseWebhookEvent(body); parseWebhookEvent throws a Zod ZodError if either the envelope shape
or the per-type data fields are invalid.
Event Types
| Type | Description |
|---|---|
alert.triggered | An alert rule or anomaly detection fired. Anomaly detections carry a non-null data.baseline_metadata. A threshold rule sends it when it starts firing and again every renotify_minutes while it keeps firing (data.renotify is true on those repeats). |
alert.resolved | A firing alert rule returned to OK. Sent only if the firing notification was sent. See alert.resolved data. |
incident.created | A new security incident was created (typically from a Sigma rule detection). |
error.detected | An exception group crossed its notification threshold. |
monitor.status_changed | An uptime monitor changed status (e.g. up → down). |
hardware.health.degraded | A hardware host got worse: from ok to warning or critical, or from warning to critical. Not sent for muted hosts. |
hardware.health.recovered | A hardware host that had an open hardware incident is healthy again. Same data shape as hardware.health.degraded. |
channel.test | A test delivery sent when verifying a notification channel. |
Each type's data payload keeps the snake_case fields you may already consume,
minus the legacy event_type / timestamp keys (now type
and occurredAt on the envelope). The most common shapes:
alert.triggered data
{
"alert_name": "Error rate spike",
"log_count": 142,
"threshold": 50,
"time_window": 5,
"baseline_metadata": {
"baseline_value": 30,
"current_value": 142,
"deviation_ratio": 4.7,
"baseline_type": "rate_of_change",
"evaluation_time": "2026-06-11T14:32:07.841Z"
},
"link": "https://app.logtide.dev/dashboard/incidents/7c1d2e3f-4a5b-6c7d-8e9f-0a1b2c3d4e5f",
"alert_rule_id": "5d6e7f80-9a1b-4c2d-8e3f-4a5b6c7d8e9f",
"renotify": false,
"firing_since": "2026-06-11T14:32:07.841Z",
"incident_id": "7c1d2e3f-4a5b-6c7d-8e9f-0a1b2c3d4e5f"
} baseline_metadata is null for plain threshold rules and non-null
for anomaly / rate-of-change detections. time_window is in minutes.
alert_rule_id, renotify, firing_since and
incident_id are present on payloads from alert rules; they are absent on older
deliveries and on Sigma detections, so treat them as optional. incident_id is
null if the incident could not be recorded.
alert.resolved data
{
"alert_rule_id": "5d6e7f80-9a1b-4c2d-8e3f-4a5b6c7d8e9f",
"alert_name": "Error rate spike",
"alert_type": "threshold",
"title": "Alert resolved: Error rate spike",
"message": "Error rate spike is back to normal after 12 minutes",
"log_count": 3,
"threshold": 50,
"time_window": 5,
"firing_since": "2026-06-11T14:32:07.841Z",
"resolved_at": "2026-06-11T14:44:07.512Z",
"duration_seconds": 720,
"incident_id": "7c1d2e3f-4a5b-6c7d-8e9f-0a1b2c3d4e5f",
"link": "https://app.logtide.dev/dashboard/incidents/7c1d2e3f-4a5b-6c7d-8e9f-0a1b2c3d4e5f"
} alert.resolved goes to the same webhook, Slack, Teams and PagerDuty
destinations as the firing notification; it is not sent by email. A snoozed rule still sends
it. alert_type is threshold or rate_of_change. See
the alert lifecycle for when it fires.
error.detected data
{
"title": "TypeError: cannot read property 'id' of undefined",
"message": "TypeError: cannot read property 'id' of undefined",
"severity": "high",
"organization": { "id": "9f8e...", "name": "Acme" },
"project": { "id": "3a4b...", "name": "api" },
"error_group_id": "grp_8d2f...",
"exception_type": "TypeError",
"language": "nodejs",
"service": "checkout",
"is_new": true,
"link": "https://app.logtide.dev/errors/grp_8d2f"
} monitor.status_changed data
{
"monitor_id": "mon_1a2b...",
"monitor_name": "Checkout API",
"status": "down",
"severity": "critical",
"title": "Checkout API is down",
"message": "HTTP 503 from https://api.example.com/health",
"organization": { "id": "9f8e...", "name": "Acme" },
"target": "https://api.example.com/health",
"error_code": "503",
"response_time_ms": null,
"consecutive_failures": 3,
"downtime_duration": null,
"link": "https://app.logtide.dev/monitoring/mon_1a2b"
} hardware.health.degraded and hardware.health.recovered data
{
"host_id": "5b0c9d2e-3f41-4a7b-9c1d-2e8f6a7b8c90",
"host_name": "nas-01",
"project_id": "3a2b1c0d-9e8f-4a7b-8c6d-5e4f3a2b1c0d",
"project_name": "homelab",
"status": "critical",
"previous_status": "warning",
"severity": "critical",
"title": "Hardware critical: nas-01",
"message": "Disk ZA1ABC12 has 12 sectors pending reallocation; Fan FAN1 stopped",
"findings": [
{
"component": "disks",
"hw_id": "ZA1ABC12",
"severity": "warning",
"code": "disk.pending_sectors",
"message": "Disk ZA1ABC12 has 12 sectors pending reallocation",
"value": 12,
"threshold": 0
}
],
"incident_id": "7c1d2e3f-4a5b-6c7d-8e9f-0a1b2c3d4e5f",
"changed_at": "2026-11-10T14:30:00.000Z",
"link": "https://app.logtide.dev/dashboard/hardware/5b0c9d2e-3f41-4a7b-9c1d-2e8f6a7b8c90"
} status is warning, critical or ok;
previous_status can also be unknown or stale.
findings lists the problems at the time of the change, worst first, and is empty
on hardware.health.recovered. incident_id is the hardware incident
that was opened, updated or resolved, or null. See
Hardware Health.
incident.created and channel.test
incident.created carries title, message,
severity, organization, incident_id, optional
affected_services, and link. channel.test carries
a title / message with optional severity,
organization, link and a free-form metadata map.
Discord Destinations
Discord's webhook endpoint only accepts bodies carrying content,
embeds or file parts, and rejects anything else with HTTP 400. A webhook whose
URL is a Discord endpoint therefore receives a Discord message instead of the envelope - no
channel setting or extra configuration is involved.
Detection covers host discord.com or discordapp.com (including
subdomains such as ptb. and canary.) with a path starting
/api/webhooks/ or /api/v10/webhooks/. It applies to every source -
channel test, monitors, alerts, security incidents, error groups - as well as retries and
replays.
Each event becomes a single embed: title and description come from
the event's title / message, the color from its
severity (green for a monitor recovery), the footer from the organization name,
timestamp from the envelope occurredAt, url from the
event link, and the remaining per-type values become inline embed fields.
Discord's size limits are enforced by truncating values and dropping trailing fields. The
monitor.status_changed payload shown above is delivered like this:
{
"embeds": [
{
"title": "Checkout API is down",
"description": "HTTP 503 from https://api.example.com/health",
"color": 14427686,
"url": "https://app.logtide.dev/monitoring/mon_1a2b",
"timestamp": "2026-08-11T10:15:00.000Z",
"footer": { "text": "LogTide - Acme" },
"fields": [
{ "name": "Status", "value": "down", "inline": true },
{ "name": "Target", "value": "https://api.example.com/health", "inline": true },
{ "name": "Error code", "value": "503", "inline": true },
{ "name": "Consecutive failures", "value": "3", "inline": true }
]
}
]
} Want the raw envelope instead?
Append /slack or /github to the Discord webhook URL - those
are Discord's own compatibility endpoints, and LogTide leaves the body of such URLs
untouched. The delivery log always stores the canonical envelope, so replays and the
delivery detail view show the event rather than the Discord payload.
Slack and Teams Destinations
Like Discord, Slack and Microsoft Teams reject the envelope, so a webhook destination whose URL is recognized receives a native message instead. There is nothing to configure: paste the URL as a normal webhook destination and LogTide shows it with a Slack or Teams label.
- Slack - classic incoming webhooks on
hooks.slack.comorhooks.slack-gov.com(paths like/services/T.../B.../token) receive a Block Kit message with an "Open in LogTide" button. Slack Workflow trigger URLs (/triggers/...) take free-form JSON and keep receiving the envelope. Dynamic text is escaped so a log message cannot ping a channel. - Microsoft Teams - Office 365 connector URLs (
*.webhook.office.com,outlook.office.com) and Power Automate Workflows (*.logic.azure.com/workflows/...,*.api.powerplatform.com/...) receive an Adaptive Card. Because Logic Apps and Power Automate URLs cannot be told apart from Teams flows, a flow that parses the envelope must be reached through a URL on another host.
Both formats apply to every source (alerts, alert resolutions, monitors, security incidents, error groups, channel tests), retries and replays. Test a destination before saving it from the destination form. See Notification Routing for how destinations are chosen.
Verifying Signatures
When a signing secret is configured for a webhook, LogTide signs each delivery so you can verify it came from LogTide and was not tampered with. Two headers are added:
X-Logtide-Timestamp: <unix seconds>X-Logtide-Signature: t=<unix>,v1=<hex hmac>
The signature is HMAC-SHA256(secret, "<timestamp>.<raw request body>"),
hex-encoded, and covers the serialized envelope. For destinations with a vendor-specific body
(see Discord destinations) it covers the bytes actually
sent, which are the vendor payload rather than the envelope.
import crypto from 'crypto';
function verify(secret, headers, rawBody) {
const ts = headers['x-logtide-timestamp'];
const sig = headers['x-logtide-signature'].split('v1=')[1];
const expected = crypto
.createHmac('sha256', secret)
.update(`${ts}.${rawBody}`)
.digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(sig, 'hex');
return a.length === b.length && crypto.timingSafeEqual(a, b);
} Verify against the raw body
Compute the HMAC over the raw request body (before JSON parsing) - any reserialization can change the bytes and break the signature. Reject deliveries whose timestamp is older than your tolerance (e.g. 5 minutes) to prevent replay.
SSRF Protection
Outbound targets are resolved and validated before each request. Loopback, private, link-local (including cloud metadata), CGNAT, multicast, and reserved IPv4/IPv6 ranges are rejected, and every redirect hop is revalidated.
Self-hosted deployments that need to reach internal endpoints can opt in by setting
MONITOR_ALLOW_PRIVATE_TARGETS=true.
Replaying Deliveries
Every delivery and each of its attempts are recorded in a bounded delivery log, browsable under Settings → Notifications → Delivery log with a status filter. Failed and dead-lettered deliveries can be replayed from there.
Historical replays
Deliveries created before the envelope change re-send their stored pre-envelope payload
when replayed, while still carrying the X-Logtide-Event-Version header.
Treat the header as advisory for replayed historical deliveries.
Configuration
| Variable | Default | Description |
|---|---|---|
WEBHOOK_MAX_ATTEMPTS | 5 | Maximum delivery attempts before the dead-letter queue. |
WEBHOOK_PER_ORG_CONCURRENCY | 5 | Concurrent deliveries per organization. |
WEBHOOK_GLOBAL_CONCURRENCY | 50 | Concurrent deliveries across all organizations. |
WEBHOOK_DELIVERY_LOG_LIMIT | 1000 | Attempts retained per delivery in the delivery log. |
WEBHOOK_REQUEST_TIMEOUT_MS | 10000 | Per-attempt request timeout in milliseconds. |
WEBHOOK_DEDUP_WINDOW_MS | 60000 | How long an in-flight delivery suppresses an identical re-enqueue of the same event. |
MONITOR_ALLOW_PRIVATE_TARGETS | false | Allow delivery to private/internal addresses (self-hosted). |
Want to observe, enrich, or reject deliveries before they leave the backend? See the
Lifecycle Hooks guide - the
beforeWebhookDispatch and afterWebhookDispatch phases hook directly
into this dispatcher.