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 → Webhook Deliveries. - 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": 300,
"baseline_metadata": null,
"link": "https://app.logtide.dev/alerts/history/abc123"
}
} 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. |
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). |
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": 300,
"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/alerts/history/abc123"
} baseline_metadata is null for plain threshold rules and non-null
for anomaly / rate-of-change detections.
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"
} 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.
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 → Webhook Deliveries 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.